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.
- package/dist/agent/agent-loop.d.ts +3 -0
- package/dist/agent/agent-loop.js +17 -1
- package/dist/agent/events.d.ts +3 -0
- package/dist/agent/host-types.d.ts +6 -0
- package/dist/agent/index.js +5 -1
- package/dist/agent/llm-client.d.ts +2 -0
- package/dist/agent/llm-client.js +2 -2
- package/dist/agent/providers/openrouter.js +11 -1
- package/dist/shell/input-handler.js +5 -3
- package/dist/shell/strategies/bash.js +10 -2
- package/dist/shell/terminal.d.ts +2 -11
- package/dist/shell/terminal.js +37 -19
- package/dist/shell/tui-renderer.js +1 -1
- package/dist/utils/ansi.d.ts +7 -0
- package/dist/utils/ansi.js +20 -0
- package/dist/utils/floating-panel.js +5 -4
- package/dist/utils/line-editor.js +7 -4
- package/examples/extensions/ashi/package.json +1 -1
- package/examples/extensions/ashi/src/chat/assistant.ts +3 -1
- package/examples/extensions/ashi/src/cli.ts +8 -0
- package/examples/extensions/ashi/src/frontend.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
- package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
- package/examples/extensions/latex-images.ts +152 -7
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +17 -1
- package/src/agent/events.ts +1 -0
- package/src/agent/host-types.ts +2 -0
- package/src/agent/index.ts +7 -1
- package/src/agent/llm-client.ts +4 -2
- package/src/agent/providers/openrouter.ts +10 -1
- package/src/shell/input-handler.ts +5 -3
- package/src/shell/strategies/bash.ts +10 -2
- package/src/shell/terminal.ts +30 -19
- package/src/shell/tui-renderer.ts +1 -1
- package/src/utils/ansi.ts +21 -0
- package/src/utils/floating-panel.ts +5 -4
- 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 ?
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
package/src/agent/agent-loop.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/src/agent/events.ts
CHANGED
|
@@ -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>;
|
package/src/agent/host-types.ts
CHANGED
|
@@ -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
|
|
package/src/agent/index.ts
CHANGED
|
@@ -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
|
|
package/src/agent/llm-client.ts
CHANGED
|
@@ -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", {
|
|
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
|
-
`
|
|
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",
|
package/src/shell/terminal.ts
CHANGED
|
@@ -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(
|
|
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 (
|
|
31
|
-
try {
|
|
34
|
+
if (stdout.writable) {
|
|
35
|
+
try { stdout.write(data); } catch { /* ignore */ }
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
onInput(cb) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
41
|
-
|
|
42
|
-
return () => {
|
|
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
|
|
45
|
-
rows() { return
|
|
55
|
+
cols() { return stdout.columns || 80; },
|
|
56
|
+
rows() { return stdout.rows || 24; },
|
|
46
57
|
suspendInput() {
|
|
47
|
-
const wasRaw =
|
|
48
|
-
if (
|
|
58
|
+
const wasRaw = stdin.isTTY && (stdin as { isRaw?: boolean }).isRaw;
|
|
59
|
+
if (stdin.isTTY) {
|
|
49
60
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
stdin.setRawMode(false);
|
|
62
|
+
stdin.pause();
|
|
52
63
|
} catch { /* ignore */ }
|
|
53
64
|
}
|
|
54
65
|
return {
|
|
55
66
|
resume() {
|
|
56
|
-
if (
|
|
67
|
+
if (stdin.isTTY) {
|
|
57
68
|
try {
|
|
58
|
-
|
|
59
|
-
if (wasRaw)
|
|
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(
|
|
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
|
|
411
|
+
const clean = stripCursorControls(content);
|
|
412
|
+
const plain = stripAnsi(clean);
|
|
412
413
|
const display = plain.length > width
|
|
413
|
-
?
|
|
414
|
-
:
|
|
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
|
});
|