agent-sh 0.15.7 → 0.15.9

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 (39) hide show
  1. package/dist/agent/agent-loop.d.ts +3 -0
  2. package/dist/agent/agent-loop.js +17 -1
  3. package/dist/agent/events.d.ts +3 -0
  4. package/dist/agent/host-types.d.ts +6 -0
  5. package/dist/agent/index.js +5 -1
  6. package/dist/agent/llm-client.d.ts +2 -0
  7. package/dist/agent/llm-client.js +2 -2
  8. package/dist/agent/providers/openrouter.js +11 -1
  9. package/dist/shell/input-handler.js +5 -3
  10. package/dist/shell/strategies/bash.js +10 -2
  11. package/dist/shell/terminal.d.ts +2 -11
  12. package/dist/shell/terminal.js +37 -19
  13. package/dist/shell/tui-renderer.js +1 -1
  14. package/dist/utils/ansi.d.ts +7 -0
  15. package/dist/utils/ansi.js +20 -0
  16. package/dist/utils/floating-panel.js +5 -4
  17. package/dist/utils/line-editor.js +7 -4
  18. package/examples/extensions/ashi/package.json +1 -1
  19. package/examples/extensions/ashi/src/chat/assistant.ts +3 -1
  20. package/examples/extensions/ashi/src/cli.ts +8 -0
  21. package/examples/extensions/ashi/src/frontend.ts +4 -3
  22. package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
  23. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
  24. package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
  25. package/examples/extensions/latex-images.ts +152 -7
  26. package/package.json +1 -1
  27. package/src/agent/agent-loop.ts +17 -1
  28. package/src/agent/events.ts +1 -0
  29. package/src/agent/host-types.ts +2 -0
  30. package/src/agent/index.ts +7 -1
  31. package/src/agent/llm-client.ts +4 -2
  32. package/src/agent/providers/openrouter.ts +10 -1
  33. package/src/shell/input-handler.ts +5 -3
  34. package/src/shell/strategies/bash.ts +10 -2
  35. package/src/shell/terminal.ts +30 -19
  36. package/src/shell/tui-renderer.ts +1 -1
  37. package/src/utils/ansi.ts +21 -0
  38. package/src/utils/floating-panel.ts +5 -4
  39. package/src/utils/line-editor.ts +7 -4
@@ -14,6 +14,14 @@ import {
14
14
  } from "@earendil-works/pi-tui";
15
15
  import { theme } from "../../theme.js";
16
16
  import { markdownTheme } from "./theme-adapters.js";
17
+ import {
18
+ emitInlineImage,
19
+ inlineCols,
20
+ paintInlineImages,
21
+ PLACEHOLDER,
22
+ SENTINEL_RE,
23
+ type InlineItem,
24
+ } from "./inline-image.js";
17
25
  import type {
18
26
  ContainerView,
19
27
  MarkdownOptions,
@@ -118,6 +126,48 @@ class ZonedMarkdown extends Markdown {
118
126
  }
119
127
  }
120
128
 
129
+ // Each sentinel becomes a bare run of `cols` placeholder cells so the wrapper
130
+ // reserves the right width; render() later paints the image into that run.
131
+ // Exported for tests: with no sentinels (latex-images off / non-kitty terminal,
132
+ // where the sentinel producer never runs) this is a pass-through.
133
+ export function reserveSentinels(full: string): { display: string; items: InlineItem[] } {
134
+ const items: InlineItem[] = [];
135
+ const display = full.replace(SENTINEL_RE, (_m, idStr) => {
136
+ const cols = inlineCols(Number(idStr));
137
+ if (cols === null) return "";
138
+ items.push({ id: Number(idStr), cols });
139
+ return PLACEHOLDER.repeat(cols);
140
+ });
141
+ return { display, items };
142
+ }
143
+
144
+ function injectInlineImages(lines: string[], items: InlineItem[]): string[] {
145
+ const write = (s: string): boolean => process.stdout.write(s);
146
+ return paintInlineImages(lines, items, (id, cols) => emitInlineImage(id, cols, write));
147
+ }
148
+
149
+ type MarkdownCtor = new (...args: ConstructorParameters<typeof Markdown>) => Markdown;
150
+
151
+ // Subclass that flows inline images where the text carries sentinels; a no-op
152
+ // superset of the base when none are present.
153
+ function withInlineImages<T extends MarkdownCtor>(Base: T): T {
154
+ class InlineImageMarkdown extends (Base as MarkdownCtor) {
155
+ private inlineItems: InlineItem[] = [];
156
+ override setText(full: string): void {
157
+ const { display, items } = reserveSentinels(full);
158
+ this.inlineItems = items;
159
+ super.setText(display);
160
+ }
161
+ override render(width: number): string[] {
162
+ return injectInlineImages(super.render(width), this.inlineItems);
163
+ }
164
+ }
165
+ return InlineImageMarkdown as unknown as T;
166
+ }
167
+
168
+ const InlineMarkdown = withInlineImages(Markdown);
169
+ const InlineZonedMarkdown = withInlineImages(ZonedMarkdown);
170
+
121
171
  class FooterSlot extends Container {
122
172
  constructor(private readonly hasContentAbove: () => boolean) {
123
173
  super();
@@ -157,7 +207,7 @@ export function createNodes(opts: { imageScale?: number } = {}): RenderNodes {
157
207
  opts?.color || opts?.bgColor
158
208
  ? { ...(opts.color ? { color: opts.color } : {}), ...(opts.bgColor ? { bgColor: opts.bgColor } : {}) }
159
209
  : undefined;
160
- const Ctor = opts?.osc133Zones ? ZonedMarkdown : Markdown;
210
+ const Ctor = opts?.osc133Zones ? InlineZonedMarkdown : InlineMarkdown;
161
211
  const md = new Ctor("", opts?.paddingX ?? 0, opts?.paddingY ?? 0, markdownTheme(), colorOpts);
162
212
  const view: MarkdownView = {
163
213
  node: asNode(md),
@@ -1,7 +1,10 @@
1
1
  /** Pending intents for ashi-issued shell pty-writes. shell:command-start fires
2
- * for any OSC 9997 — orphans (bash DEBUG-trap noise) are dropped on consume. */
2
+ * for any OSC 9997 — orphans (bash DEBUG-trap noise) are dropped on consume.
3
+ * Carries the command ashi sent so the rendered label is the exact text we
4
+ * wrote to the pty, not whatever the shell echoes back. */
3
5
  export interface UserShellIntent {
4
6
  private: boolean;
7
+ command: string;
5
8
  }
6
9
 
7
10
  export class UserShellIntents {
@@ -22,7 +22,10 @@ import * as path from "node:path";
22
22
  import type { ExtensionContext } from "agent-sh/types";
23
23
 
24
24
  // Settings loaded in activate() via ctx.getExtensionSettings
25
- let config = { dpi: 300, fgColor: "d4d4d4" };
25
+ // inlineScale: inline math font vs ~1 em of text (1.0 ≈ text, <1 smaller).
26
+ let config = { dpi: 300, fgColor: "d4d4d4", inlineScale: 1.0 };
27
+
28
+ let magickBin: string | null = null;
26
29
 
27
30
  /** Encode PNG as iTerm2 or Kitty inline image escape sequence. */
28
31
  function encodeImage(data: Buffer): string {
@@ -56,6 +59,17 @@ $\\displaystyle ${equation}$
56
59
  \\end{document}
57
60
  `;
58
61
 
62
+ // Inline equations: text-style (no \displaystyle) so sizing matches a text line.
63
+ const LATEX_INLINE_TEMPLATE = (equation: string, fg: string) => `
64
+ \\documentclass[border=1pt]{standalone}
65
+ \\usepackage{amsmath,amssymb,amsfonts}
66
+ \\usepackage{xcolor}
67
+ \\begin{document}
68
+ \\color[HTML]{${fg}}
69
+ $ ${equation} $
70
+ \\end{document}
71
+ `;
72
+
59
73
  let tmpDir: string | null = null;
60
74
  let renderCounter = 0;
61
75
 
@@ -66,7 +80,10 @@ function ensureTmpDir(): string {
66
80
  return tmpDir;
67
81
  }
68
82
 
69
- function renderEquation(equation: string): Buffer | null {
83
+ function renderEquation(
84
+ equation: string,
85
+ template: (eq: string, fg: string) => string,
86
+ ): Buffer | null {
70
87
  const dir = ensureTmpDir();
71
88
  const idx = renderCounter++;
72
89
  const texPath = path.join(dir, `eq${idx}.tex`);
@@ -74,7 +91,7 @@ function renderEquation(equation: string): Buffer | null {
74
91
  const pngPath = path.join(dir, `eq${idx}.png`);
75
92
 
76
93
  try {
77
- fs.writeFileSync(texPath, LATEX_TEMPLATE(equation, config.fgColor));
94
+ fs.writeFileSync(texPath, template(equation, config.fgColor));
78
95
 
79
96
  execSync(
80
97
  `latex -interaction=nonstopmode -output-directory="${dir}" "${texPath}"`,
@@ -98,10 +115,50 @@ function renderEquation(equation: string): Buffer | null {
98
115
 
99
116
  const equationCache = new Map<string, Buffer | null>();
100
117
  function renderEquationCached(equation: string): Buffer | null {
101
- if (!equationCache.has(equation)) {
102
- equationCache.set(equation, renderEquation(equation));
118
+ const key = `d:${equation}`;
119
+ if (!equationCache.has(key)) {
120
+ equationCache.set(key, renderEquation(equation, LATEX_TEMPLATE));
121
+ }
122
+ return equationCache.get(key) ?? null;
123
+ }
124
+
125
+ // 1 TeX pt = 1/72.27 inch; standalone's default body font is 10pt.
126
+ const PT_PER_INCH = 72.27;
127
+
128
+ // Pad each equation's height up to a shared ~1 em reference (transparent, centered)
129
+ // so short and tall expressions render at the same font — only their heights differ.
130
+ // Cols are derived downstream from this padded height. No-op for already-tall content.
131
+ function normalizeInlineHeight(buf: Buffer): Buffer {
132
+ if (!magickBin) return buf;
133
+ const w = buf.readUInt32BE(16);
134
+ const h = buf.readUInt32BE(20);
135
+ const emPx = (config.dpi * 10) / PT_PER_INCH;
136
+ const scale = config.inlineScale > 0 ? config.inlineScale : 1;
137
+ const targetH = Math.round(emPx / scale);
138
+ if (h >= targetH) return buf;
139
+ const dir = ensureTmpDir();
140
+ const idx = renderCounter++;
141
+ const inPath = path.join(dir, `pad${idx}-in.png`);
142
+ const outPath = path.join(dir, `pad${idx}-out.png`);
143
+ try {
144
+ fs.writeFileSync(inPath, buf);
145
+ execSync(
146
+ `${magickBin} "${inPath}" -background none -gravity center -extent ${w}x${targetH} "${outPath}"`,
147
+ { timeout: 10000, stdio: "pipe" },
148
+ );
149
+ return fs.readFileSync(outPath);
150
+ } catch {
151
+ return buf;
103
152
  }
104
- return equationCache.get(equation) ?? null;
153
+ }
154
+
155
+ function renderInlineCached(equation: string): Buffer | null {
156
+ const key = `i:${equation}`;
157
+ if (!equationCache.has(key)) {
158
+ const png = renderEquation(equation, LATEX_INLINE_TEMPLATE);
159
+ equationCache.set(key, png ? normalizeInlineHeight(png) : null);
160
+ }
161
+ return equationCache.get(key) ?? null;
105
162
  }
106
163
 
107
164
  const EQ_DELIM = "$$";
@@ -132,6 +189,65 @@ function splitEquations(text: string): Block[] {
132
189
  return out;
133
190
  }
134
191
 
192
+ // ── Inline math ($…$) ────────────────────────────────────────────
193
+
194
+ // KaTeX-style `$…$` rules so prose/currency don't false-match: no space after the
195
+ // opening `$`; no space before the closing `$` and no digit after it; one line; \$.
196
+ export function matchInline(text: string, open: number): { eq: string; end: number } | null {
197
+ if (open + 1 >= text.length || /\s/.test(text[open + 1]!)) return null;
198
+ for (let j = open + 1; j < text.length; j++) {
199
+ const ch = text[j]!;
200
+ if (ch === "\n") return null;
201
+ if (ch === "\\") { j++; continue; }
202
+ if (ch !== "$") continue;
203
+ if (/\s/.test(text[j - 1]!)) continue;
204
+ if (/[0-9]/.test(text[j + 1] ?? "")) continue;
205
+ const eq = text.slice(open + 1, j);
206
+ return eq.trim() === "" ? null : { eq, end: j + 1 };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // Replace inline `$…$` with `\x01LI:<id>\x01` sentinels; leaves escapes and inline
212
+ // code spans untouched, and falls back to literal text when register() returns null.
213
+ export function replaceInline(text: string, register: (eq: string) => number | null): string {
214
+ let out = "";
215
+ let i = 0;
216
+ while (i < text.length) {
217
+ const c = text[i]!;
218
+ if (c === "\\") { out += text.slice(i, i + 2); i += 2; continue; }
219
+ if (c === "`") {
220
+ // Code span: a run of N backticks closes on the next run of exactly N.
221
+ // An unmatched opening run is a literal backtick — keep scanning after it.
222
+ let n = 1;
223
+ while (text[i + n] === "`") n++;
224
+ let j = i + n;
225
+ let close = -1;
226
+ while (j < text.length) {
227
+ if (text[j] === "`") {
228
+ let m = 1;
229
+ while (text[j + m] === "`") m++;
230
+ if (m === n) { close = j; break; }
231
+ j += m;
232
+ } else j++;
233
+ }
234
+ if (close === -1) { out += text.slice(i, i + n); i += n; continue; }
235
+ out += text.slice(i, close + n); i = close + n; continue;
236
+ }
237
+ if (c === "$" && text[i + 1] !== "$") {
238
+ const m = matchInline(text, i);
239
+ if (m) {
240
+ const id = register(m.eq);
241
+ out += id === null ? text.slice(i, m.end) : `\x01LI:${id}\x01`;
242
+ i = m.end;
243
+ continue;
244
+ }
245
+ }
246
+ out += c; i++;
247
+ }
248
+ return out;
249
+ }
250
+
135
251
  // ── Extension entry point ────────────────────────────────────────
136
252
 
137
253
  export default function activate(ctx: ExtensionContext) {
@@ -151,6 +267,15 @@ export default function activate(ctx: ExtensionContext) {
151
267
  return;
152
268
  }
153
269
 
270
+ // ImageMagick is optional; only used to shrink inline glyphs (config.inlineScale).
271
+ for (const bin of ["magick", "convert"]) {
272
+ try {
273
+ execSync(`${bin} --version`, { stdio: "ignore", timeout: 3000 });
274
+ magickBin = bin;
275
+ break;
276
+ } catch { /* not installed */ }
277
+ }
278
+
154
279
  ctx.define("latex:render-equation", (equation: string): Buffer | null => renderEquationCached(equation));
155
280
 
156
281
  if (ctx.shell) {
@@ -172,14 +297,34 @@ export default function activate(ctx: ExtensionContext) {
172
297
  ctx.call("render:image", png);
173
298
  });
174
299
  } else {
300
+ // Cache the id per equation so each image is registered (and transmitted) once.
301
+ const inlineIds = new Map<string, number>();
302
+ const registerInline = (eq: string): number | null => {
303
+ const cached = inlineIds.get(eq);
304
+ if (cached !== undefined) return cached;
305
+ const png = renderInlineCached(eq);
306
+ if (!png) return null;
307
+ const id = ctx.call("ashi:inline-image:register", png) as number | null;
308
+ if (typeof id === "number") inlineIds.set(eq, id);
309
+ return typeof id === "number" ? id : null;
310
+ };
311
+
175
312
  (bus.onPipe as unknown as (e: string, fn: (p: ContentPipe) => ContentPipe) => void)(
176
313
  "render:assistant-content",
177
314
  (payload) => {
178
315
  // Can't show images reliably → leave $$…$$ as text.
179
316
  if (!payload.images) return payload;
317
+ const canInline = ctx.list().includes("ashi:inline-image:register");
180
318
  return {
181
319
  ...payload,
182
- blocks: payload.blocks.flatMap((b) => (b.type === "text" ? splitEquations(b.text) : [b])),
320
+ blocks: payload.blocks.flatMap((b) => {
321
+ if (b.type !== "text") return [b];
322
+ return splitEquations(b.text).map((blk) =>
323
+ blk.type === "text" && canInline
324
+ ? { type: "text" as const, text: replaceInline(blk.text, registerInline) }
325
+ : blk,
326
+ );
327
+ }),
183
328
  };
184
329
  },
185
330
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.7",
3
+ "version": "0.15.9",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -462,6 +462,17 @@ export class AgentLoop implements AgentBackend {
462
462
  }
463
463
 
464
464
 
465
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
466
+ * when the frontend tracks no session. */
467
+ private currentSessionId(): string | undefined {
468
+ try {
469
+ const id = this.handlers.call("session:current-id");
470
+ return typeof id === "string" && id ? id : undefined;
471
+ } catch {
472
+ return undefined;
473
+ }
474
+ }
475
+
465
476
  private resolveEndpoint(m: Model): ModelEndpoint | undefined {
466
477
  try {
467
478
  return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id }) as ModelEndpoint | undefined;
@@ -1439,7 +1450,12 @@ export class AgentLoop implements AgentBackend {
1439
1450
  };
1440
1451
  this.bus.emit("llm:request", requestParams);
1441
1452
 
1442
- const stream = await this.llmClient.stream({ ...requestParams, signal });
1453
+ const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
1454
+ const stream = await this.llmClient.stream({
1455
+ ...requestParams,
1456
+ signal,
1457
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
1458
+ });
1443
1459
 
1444
1460
  try {
1445
1461
  for await (const chunk of stream) {
@@ -22,6 +22,7 @@ declare module "../core/event-bus.js" {
22
22
  id: string;
23
23
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
24
24
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
25
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
25
26
  };
26
27
 
27
28
  "agent:models-changed": Record<string, never>;
@@ -93,6 +93,7 @@ export interface ModelEndpoint {
93
93
  baseURL?: string;
94
94
  buildReasoningParams?: (level: string) => Record<string, unknown>;
95
95
  extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
96
+ buildRequestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
96
97
  }
97
98
 
98
99
  // ── Agent-host extension surface ─────────────────────────────────
@@ -111,6 +112,7 @@ export interface AgentSurface {
111
112
  configure: (id: string, opts: {
112
113
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
113
114
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
115
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
114
116
  }) => void;
115
117
  };
116
118
 
@@ -128,6 +128,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
128
128
  const providerHooks = new Map<string, {
129
129
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
130
130
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
131
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
131
132
  }>();
132
133
 
133
134
  // Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
@@ -139,6 +140,9 @@ export default function agentBackend(ctx: ExtensionContext): void {
139
140
  const bindCacheTokens = (shapeId: string) =>
140
141
  providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
141
142
 
143
+ const bindRequestHeaders = (shapeId: string) =>
144
+ providerHooks.get(shapeId)?.requestHeaders;
145
+
142
146
  const agentSurface: AgentSurface = {
143
147
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
144
148
  providers: {
@@ -345,6 +349,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
345
349
  baseURL: p.baseURL,
346
350
  buildReasoningParams: bindReasoning(shapeId, modelId),
347
351
  extractCachedTokens: bindCacheTokens(shapeId),
352
+ buildRequestHeaders: bindRequestHeaders(shapeId),
348
353
  };
349
354
  };
350
355
 
@@ -388,10 +393,11 @@ export default function agentBackend(ctx: ExtensionContext): void {
388
393
  }
389
394
  });
390
395
 
391
- bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
396
+ bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
392
397
  const prev = providerHooks.get(id) ?? {};
393
398
  if (reasoningParams !== undefined) prev.reasoningParams = reasoningParams;
394
399
  if (cacheTokens !== undefined) prev.cacheTokens = cacheTokens;
400
+ if (requestHeaders !== undefined) prev.requestHeaders = requestHeaders;
395
401
  providerHooks.set(id, prev);
396
402
  });
397
403
 
@@ -68,7 +68,7 @@ export class LlmClient {
68
68
  }
69
69
 
70
70
  stream(opts: StreamOpts) {
71
- const { signal, messages, tools, model, max_tokens, ...rest } = opts;
71
+ const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
72
72
  const body = {
73
73
  ...rest,
74
74
  model: model ?? this.model,
@@ -78,7 +78,7 @@ export class LlmClient {
78
78
  stream: true as const,
79
79
  stream_options: { include_usage: true },
80
80
  };
81
- return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal });
81
+ return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal, headers });
82
82
  }
83
83
 
84
84
  async complete(opts: CompleteOpts): Promise<string> {
@@ -102,6 +102,8 @@ export type StreamOpts = {
102
102
  model?: string;
103
103
  max_tokens?: number;
104
104
  signal?: AbortSignal;
105
+ /** Per-request transport headers, forwarded to the SDK (not request body). */
106
+ headers?: Record<string, string>;
105
107
  } & Record<string, unknown>;
106
108
 
107
109
  export type CompleteOpts = {
@@ -42,7 +42,16 @@ function toModalities(input?: string[]): ("text" | "image")[] | undefined {
42
42
 
43
43
  export default function activate(ctx: AgentContext): void {
44
44
  const apiKey = resolveApiKey("openrouter").key;
45
- ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
45
+ ctx.agent.providers.configure("openrouter", {
46
+ reasoningParams: buildReasoningParams,
47
+ // x-session-id pins sticky provider routing across turns so prompt caches
48
+ // stay warm even when compaction rewrites the opening messages.
49
+ requestHeaders: ({ sessionId }) => {
50
+ const headers: Record<string, string> = {};
51
+ if (sessionId) headers["x-session-id"] = sessionId;
52
+ return headers;
53
+ },
54
+ });
46
55
  ctx.agent.providers.register({
47
56
  id: "openrouter",
48
57
  apiKey: apiKey ?? undefined,
@@ -88,7 +88,8 @@ export class InputHandler {
88
88
  private loadHistory(): void {
89
89
  try {
90
90
  const data = fs.readFileSync(HISTORY_FILE, "utf-8");
91
- this.history = data.split("\n").filter(Boolean);
91
+ this.history = data.split("\n").filter(Boolean)
92
+ .map((l) => l.replace(/\\([\\n])/g, (_, c: string) => c === "n" ? "\n" : "\\"));
92
93
  } catch {
93
94
  }
94
95
  }
@@ -97,7 +98,8 @@ export class InputHandler {
97
98
  try {
98
99
  const { historySize } = getSettings();
99
100
  fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
100
- const lines = this.history.slice(-historySize);
101
+ const lines = this.history.slice(-historySize)
102
+ .map((l) => l.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"));
101
103
  fs.writeFileSync(HISTORY_FILE, lines.join("\n") + "\n");
102
104
  } catch {
103
105
  }
@@ -392,7 +394,7 @@ export class InputHandler {
392
394
  this.editor.clear();
393
395
  this.view.resetCursor();
394
396
  this.dismissAutocomplete();
395
- if (query && query.startsWith("/")) {
397
+ if (query && query.startsWith("/") && !query.includes("\n")) {
396
398
  const spaceIdx = query.indexOf(" ");
397
399
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
398
400
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
@@ -47,8 +47,16 @@ export const bashStrategy: ShellStrategy = {
47
47
  ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
48
48
  ' [[ -n $COMP_LINE ]] && return',
49
49
  " __agent_sh_preexec_ran=1",
50
- " local this_cmd",
51
- ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
50
+ " local this_cmd hist_cmd",
51
+ ` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
52
+ " # history 1 carries the full typed line but goes stale when the user's",
53
+ " # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
54
+ " # matches the command bash is about to run; else use $BASH_COMMAND.",
55
+ ' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
56
+ " this_cmd=$hist_cmd",
57
+ " else",
58
+ " this_cmd=$BASH_COMMAND",
59
+ " fi",
52
60
  ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
53
61
  "}",
54
62
  "trap '__agent_sh_emit_preexec' DEBUG",
@@ -7,6 +7,7 @@
7
7
  * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
8
  * (multi-session web hubs, tests) supply their own.
9
9
  */
10
+ import { StringDecoder } from "node:string_decoder";
10
11
  import type { RenderSurface } from "../utils/compositor.js";
11
12
 
12
13
  export interface Terminal {
@@ -23,40 +24,50 @@ export interface Terminal {
23
24
  suspendInput?(): { resume(): void };
24
25
  }
25
26
 
26
- /** Default Terminal: wraps process.stdin/stdout. */
27
- export function processTerminal(): Terminal {
27
+ /** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
28
+ export function processTerminal(
29
+ stdin: NodeJS.ReadStream = process.stdin,
30
+ stdout: NodeJS.WriteStream = process.stdout,
31
+ ): Terminal {
28
32
  return {
29
33
  write(data) {
30
- if (process.stdout.writable) {
31
- try { process.stdout.write(data); } catch { /* ignore */ }
34
+ if (stdout.writable) {
35
+ try { stdout.write(data); } catch { /* ignore */ }
32
36
  }
33
37
  },
34
38
  onInput(cb) {
35
- const handler = (b: Buffer) => cb(b.toString("utf-8"));
36
- process.stdin.on("data", handler);
37
- return () => { process.stdin.off("data", handler); };
39
+ // Stateful decode: tty chunk boundaries can land mid-way through a
40
+ // multibyte UTF-8 sequence (large pastes), so per-chunk toString()
41
+ // would emit U+FFFD for the torn halves.
42
+ const decoder = new StringDecoder("utf-8");
43
+ const handler = (b: Buffer) => {
44
+ const text = decoder.write(b);
45
+ if (text) cb(text);
46
+ };
47
+ stdin.on("data", handler);
48
+ return () => { stdin.off("data", handler); };
38
49
  },
39
50
  onResize(cb) {
40
- const handler = () => cb(process.stdout.columns || 80, process.stdout.rows || 24);
41
- process.stdout.on("resize", handler);
42
- return () => { process.stdout.off("resize", handler); };
51
+ const handler = () => cb(stdout.columns || 80, stdout.rows || 24);
52
+ stdout.on("resize", handler);
53
+ return () => { stdout.off("resize", handler); };
43
54
  },
44
- cols() { return process.stdout.columns || 80; },
45
- rows() { return process.stdout.rows || 24; },
55
+ cols() { return stdout.columns || 80; },
56
+ rows() { return stdout.rows || 24; },
46
57
  suspendInput() {
47
- const wasRaw = process.stdin.isTTY && (process.stdin as { isRaw?: boolean }).isRaw;
48
- if (process.stdin.isTTY) {
58
+ const wasRaw = stdin.isTTY && (stdin as { isRaw?: boolean }).isRaw;
59
+ if (stdin.isTTY) {
49
60
  try {
50
- process.stdin.setRawMode(false);
51
- process.stdin.pause();
61
+ stdin.setRawMode(false);
62
+ stdin.pause();
52
63
  } catch { /* ignore */ }
53
64
  }
54
65
  return {
55
66
  resume() {
56
- if (process.stdin.isTTY) {
67
+ if (stdin.isTTY) {
57
68
  try {
58
- process.stdin.resume();
59
- if (wasRaw) process.stdin.setRawMode(true);
69
+ stdin.resume();
70
+ if (wasRaw) stdin.setRawMode(true);
60
71
  } catch { /* ignore */ }
61
72
  }
62
73
  },
@@ -939,7 +939,7 @@ export default function activate(ctx: ExtensionContext): void {
939
939
  ? getSettings().readOutputMaxLines
940
940
  : getSettings().maxCommandOutputLines;
941
941
  s.commandOutputBuffer += chunk;
942
- const lines = s.commandOutputBuffer.split("\n");
942
+ const lines = s.commandOutputBuffer.split(/\r?\n/);
943
943
  s.commandOutputBuffer = lines.pop()!;
944
944
  for (const line of lines) {
945
945
  if (s.commandOutputLineCount < maxLines) {
package/src/utils/ansi.ts CHANGED
@@ -138,3 +138,24 @@ export function padEndToWidth(str: string, targetWidth: number): string {
138
138
  export function stripAnsi(str: string): string {
139
139
  return stripAnsiPkg(str).replace(/\r/g, "");
140
140
  }
141
+
142
+ /**
143
+ * Sanitize text for painting at a fixed screen position: SGR (color/style)
144
+ * passes through; anything else that would move the cursor or mutate
145
+ * terminal state mid-row is dropped. Tabs become a single space so painted
146
+ * width matches `stripAnsi`-based measurement.
147
+ */
148
+ export function stripCursorControls(str: string): string {
149
+ // Park SGR behind NUL placeholders so the strips below can't eat their ESC bytes.
150
+ const sgr: string[] = [];
151
+ const cleaned = str
152
+ .replace(/\x00/g, "")
153
+ .replace(/\x1b\[[0-9;:]*m/g, (m) => { sgr.push(m); return "\x00"; })
154
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "")
155
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
156
+ .replace(/\x1b./g, "")
157
+ .replace(/\t/g, " ")
158
+ .replace(/[\x01-\x08\x0a-\x1f\x7f]/g, "");
159
+ let i = 0;
160
+ return cleaned.replace(/\x00/g, () => sgr[i++] ?? "");
161
+ }
@@ -30,7 +30,7 @@
30
30
  * Usage from extensions:
31
31
  * import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
32
32
  */
33
- import { stripAnsi } from "./ansi.js";
33
+ import { stripAnsi, stripCursorControls } from "./ansi.js";
34
34
  import { wrapLine } from "./markdown.js";
35
35
  import { LineEditor } from "./line-editor.js";
36
36
  import { TerminalBuffer } from "./terminal-buffer.js";
@@ -408,10 +408,11 @@ export class FloatingPanel {
408
408
 
409
409
  // Default row builder: truncate and pad
410
410
  this.handlers.define(`${p}:build-row`, (content: string, width: number): string => {
411
- const plain = stripAnsi(content);
411
+ const clean = stripCursorControls(content);
412
+ const plain = stripAnsi(clean);
412
413
  const display = plain.length > width
413
- ? content.slice(0, width - 1) + "\u2026"
414
- : content;
414
+ ? clean.slice(0, width - 1) + "\u2026"
415
+ : clean;
415
416
  const pad = Math.max(0, width - stripAnsi(display).length);
416
417
  return display + " ".repeat(pad);
417
418
  });