agent-sh 0.15.6 → 0.15.8
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/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/agent/agent-loop.d.ts +3 -0
- package/dist/agent/agent-loop.js +19 -6
- package/dist/agent/events.d.ts +3 -0
- package/dist/agent/extensions/rolling-history/index.js +20 -8
- package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
- package/dist/agent/extensions/rolling-history/recall.js +17 -7
- 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/openai-compatible.d.ts +8 -0
- package/dist/agent/providers/openai-compatible.js +9 -2
- package/dist/agent/providers/openrouter.js +11 -1
- package/dist/agent/store.js +6 -1
- package/dist/agent/token-budget.d.ts +2 -1
- package/dist/agent/token-budget.js +6 -1
- package/dist/cli/index.js +1 -1
- package/dist/core/event-bus.d.ts +16 -1
- package/dist/core/event-bus.js +73 -11
- package/dist/core/index.js +18 -0
- package/dist/shell/strategies/bash.js +10 -2
- package/dist/shell/tui-renderer.js +115 -174
- package/dist/utils/executor.js +19 -11
- package/dist/utils/floating-panel.d.ts +1 -0
- package/dist/utils/floating-panel.js +28 -26
- package/dist/utils/markdown.js +19 -21
- package/dist/utils/palette.d.ts +11 -0
- package/dist/utils/palette.js +11 -0
- package/docs/agent.md +13 -11
- package/docs/architecture.md +3 -5
- package/docs/extensions.md +21 -20
- package/docs/library.md +6 -3
- package/docs/troubleshooting.md +2 -2
- package/docs/tui-composition.md +11 -3
- package/docs/usage.md +70 -50
- package/examples/extensions/ashi/package.json +1 -1
- package/examples/extensions/ashi/src/chat/assistant.ts +8 -4
- package/examples/extensions/ashi/src/cli.ts +8 -0
- package/examples/extensions/ashi/src/compaction.ts +4 -7
- package/examples/extensions/ashi/src/frontend.ts +6 -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/schema.ts +8 -2
- package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
- package/examples/extensions/command-suggest.ts +4 -0
- package/examples/extensions/latex-images.ts +152 -7
- package/examples/extensions/solarized-theme.ts +11 -0
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +19 -6
- package/src/agent/events.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +20 -8
- package/src/agent/extensions/rolling-history/recall.ts +28 -7
- 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/openai-compatible.ts +19 -4
- package/src/agent/providers/openrouter.ts +10 -1
- package/src/agent/store.ts +5 -1
- package/src/agent/token-budget.ts +10 -1
- package/src/cli/index.ts +1 -1
- package/src/core/event-bus.ts +67 -12
- package/src/core/index.ts +18 -0
- package/src/shell/strategies/bash.ts +10 -2
- package/src/shell/tui-renderer.ts +130 -207
- package/src/utils/executor.ts +17 -14
- package/src/utils/floating-panel.ts +24 -22
- package/src/utils/markdown.ts +17 -20
- package/src/utils/palette.ts +30 -5
|
@@ -7,6 +7,8 @@ export type RenderBlock =
|
|
|
7
7
|
|
|
8
8
|
export type ContentTransform = (blocks: RenderBlock[]) => RenderBlock[];
|
|
9
9
|
|
|
10
|
+
const stripTrailing = (s: string): string => s.replace(/\s+$/, "");
|
|
11
|
+
|
|
10
12
|
export class AssistantMessage {
|
|
11
13
|
readonly node: RenderNode;
|
|
12
14
|
private container: ContainerView;
|
|
@@ -23,20 +25,22 @@ export class AssistantMessage {
|
|
|
23
25
|
|
|
24
26
|
appendText(t: string): void {
|
|
25
27
|
this.buffer += t;
|
|
26
|
-
this.md.setText(this.buffer);
|
|
28
|
+
this.md.setText(stripTrailing(this.buffer));
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
appendCodeBlock(language: string, code: string): void {
|
|
30
32
|
const prefix = this.buffer && !this.buffer.endsWith("\n") ? "\n" : "";
|
|
31
33
|
this.buffer += `${prefix}\`\`\`${language}\n${code}\n\`\`\`\n`;
|
|
32
|
-
this.md.setText(this.buffer);
|
|
34
|
+
this.md.setText(stripTrailing(this.buffer));
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
finalize(): void {
|
|
36
38
|
if (this.buffer === "") this.buffer = " ";
|
|
37
39
|
const blocks = this.transform([{ type: "text", text: this.buffer }]);
|
|
38
40
|
if (blocks.every((b) => b.type === "text")) {
|
|
39
|
-
|
|
41
|
+
// Render the transformed text, not the raw buffer — transforms may rewrite
|
|
42
|
+
// content in place (e.g. inline-image sentinels) while staying all-text.
|
|
43
|
+
this.md.setText(stripTrailing(blocks.map((b) => (b.type === "text" ? b.text : "")).join("")));
|
|
40
44
|
return;
|
|
41
45
|
}
|
|
42
46
|
this.rebuild(blocks);
|
|
@@ -55,7 +59,7 @@ export class AssistantMessage {
|
|
|
55
59
|
this.container.addChild(m.node);
|
|
56
60
|
} else if (block.text.trim()) {
|
|
57
61
|
const m = this.nodes.markdown({ paddingX: 1 });
|
|
58
|
-
m.setText(block.text);
|
|
62
|
+
m.setText(stripTrailing(block.text));
|
|
59
63
|
this.container.addChild(m.node);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
@@ -34,6 +34,7 @@ import { registerCapture, type Capture } from "./capture.js";
|
|
|
34
34
|
import { registerRenderDefaults } from "./hooks.js";
|
|
35
35
|
import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
|
|
36
36
|
import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
|
|
37
|
+
import { registerInlineImage, supportsInlineImages } from "./renderers/pi-tui/inline-image.js";
|
|
37
38
|
import type { Renderer } from "./renderer.js";
|
|
38
39
|
import { loadRendererPreference } from "./display-config.js";
|
|
39
40
|
import { applyOutputMode } from "./terminal-mode.js";
|
|
@@ -177,6 +178,8 @@ async function main(): Promise<void> {
|
|
|
177
178
|
|
|
178
179
|
const ctx = core.extensionContext({ quit: cleanup });
|
|
179
180
|
|
|
181
|
+
ctx.define("session:current-id", () => store.current().id);
|
|
182
|
+
|
|
180
183
|
activateAgent(ctx);
|
|
181
184
|
activateShellContext(ctx);
|
|
182
185
|
await loadBuiltinExtensions(ctx);
|
|
@@ -238,6 +241,11 @@ async function main(): Promise<void> {
|
|
|
238
241
|
registerRenderDefaults(ctx, renderer);
|
|
239
242
|
registerDefaultSchemaRenderers(ctx);
|
|
240
243
|
|
|
244
|
+
// Handler presence is how producers detect that inline images are available.
|
|
245
|
+
if (rendererName === "pi-tui" && supportsInlineImages()) {
|
|
246
|
+
ctx.define("ashi:inline-image:register", (png: Buffer) => registerInlineImage(png));
|
|
247
|
+
}
|
|
248
|
+
|
|
241
249
|
ctx.advise("system-prompt:frontend", (next) => {
|
|
242
250
|
const base = (next() as string) ?? "";
|
|
243
251
|
return base ? `${base}\n\n${ASHI_SURFACE}` : ASHI_SURFACE;
|
|
@@ -39,19 +39,16 @@ export function registerCompaction(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const older = messages.slice(0, cutIdx);
|
|
42
|
-
const kept = messages.slice(cutIdx);
|
|
43
42
|
const tokensBefore = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
|
|
44
43
|
const customSummary = (await ctx.call("ashi:compact:build-summary", older)) as string | null | undefined;
|
|
45
44
|
|
|
46
45
|
const store = getStore().current();
|
|
47
46
|
await store.appendCompaction(firstKeptId, tokensBefore, customSummary ?? undefined);
|
|
48
|
-
ctx.call("conversation:replace-messages", store.buildMessages());
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
capture.resetTo([null, ...keptIds]);
|
|
48
|
+
// Take messages and ids from one rebuild so capture's index→id map can't drift.
|
|
49
|
+
const { messages: rebuilt, entryIds } = store.buildBranchWithIds();
|
|
50
|
+
ctx.call("conversation:replace-messages", rebuilt);
|
|
51
|
+
capture.resetTo(entryIds);
|
|
55
52
|
|
|
56
53
|
const tokensAfter = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
|
|
57
54
|
return { before: tokensBefore, after: tokensAfter, evictedCount: older.length };
|
|
@@ -399,7 +399,7 @@ export function mountAshi(
|
|
|
399
399
|
app.requestRender();
|
|
400
400
|
return;
|
|
401
401
|
}
|
|
402
|
-
pendingUserShell.push({ private: !!opts?.private });
|
|
402
|
+
pendingUserShell.push({ private: !!opts?.private, command: line });
|
|
403
403
|
if (opts?.private) bus.emit("shell:user-exec-exclude-next", {});
|
|
404
404
|
bus.emit("shell:pty-write", { data: line + "\n" });
|
|
405
405
|
};
|
|
@@ -650,6 +650,8 @@ export function mountAshi(
|
|
|
650
650
|
activeThinking = null;
|
|
651
651
|
activeTools.clear();
|
|
652
652
|
openGroup = null;
|
|
653
|
+
compactions = 0;
|
|
654
|
+
statusFooter.update({ compactions });
|
|
653
655
|
clearChat();
|
|
654
656
|
const branch = getStore().current().getBranch();
|
|
655
657
|
const toolMap = new Map<string, ReplayEntry>();
|
|
@@ -814,12 +816,13 @@ export function mountAshi(
|
|
|
814
816
|
bus.on("shell:foreground-busy", ({ busy }) => { shellForegroundBusy = busy; });
|
|
815
817
|
|
|
816
818
|
let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
|
|
817
|
-
bus.on("shell:command-start", (
|
|
819
|
+
bus.on("shell:command-start", () => {
|
|
818
820
|
if (agentShellActive) return;
|
|
819
821
|
const intent = pendingUserShell.consume();
|
|
820
822
|
if (!intent) return;
|
|
821
823
|
finalizeThinking();
|
|
822
824
|
if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
|
|
825
|
+
const command = intent.command;
|
|
823
826
|
const isPrivate = intent.private;
|
|
824
827
|
const name = isPrivate ? "user_bash_private" : "user_bash";
|
|
825
828
|
const pair = renderToolPair({
|
|
@@ -859,7 +862,7 @@ export function mountAshi(
|
|
|
859
862
|
// Drain shell queue before queries so its output lands in the next turn's <shell_events>.
|
|
860
863
|
while (queuedShellLines.length > 0) {
|
|
861
864
|
const item = queuedShellLines.shift()!;
|
|
862
|
-
pendingUserShell.push({ private: item.private });
|
|
865
|
+
pendingUserShell.push({ private: item.private, command: item.line });
|
|
863
866
|
if (item.private) bus.emit("shell:user-exec-exclude-next", {});
|
|
864
867
|
bus.emit("shell:pty-write", { data: item.line + "\n" });
|
|
865
868
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline images in markdown text via the kitty/Ghostty Unicode placeholder
|
|
3
|
+
* protocol. The image is transmitted out-of-band (cursor-neutral); the frame
|
|
4
|
+
* carries only `cols` width-1 placeholder cells, so the renderer's wrap/measure
|
|
5
|
+
* math is unaffected and the line is never treated as an image line. kitty-only.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
allocateImageId,
|
|
9
|
+
getCapabilities,
|
|
10
|
+
getCellDimensions,
|
|
11
|
+
getImageDimensions,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export const SENTINEL_RE = /\x01LI:(\d+)\x01/g;
|
|
15
|
+
export const PLACEHOLDER = String.fromCodePoint(0x10eeee);
|
|
16
|
+
|
|
17
|
+
// kitty rowcolumn-diacritics: the Nth entry encodes row/column index N
|
|
18
|
+
// (gen/rowcolumn-diacritics.txt in the kitty source).
|
|
19
|
+
const DIACRITICS = [0x0305,0x030D,0x030E,0x0310,0x0312,0x033D,0x033E,0x033F,0x0346,0x034A,0x034B,0x034C,0x0350,0x0351,0x0352,0x0357,0x035B,0x0363,0x0364,0x0365,0x0366,0x0367,0x0368,0x0369,0x036A,0x036B,0x036C,0x036D,0x036E,0x036F,0x0483,0x0484,0x0485,0x0486,0x0487,0x0592,0x0593,0x0594,0x0595,0x0597,0x0598,0x0599,0x059C,0x059D,0x059E,0x059F,0x05A0,0x05A1,0x05A8,0x05A9,0x05AB,0x05AC,0x05AF,0x05C4,0x0610,0x0611,0x0612,0x0613,0x0614,0x0615,0x0616,0x0617,0x0657,0x0658,0x0659,0x065A,0x065B,0x065D,0x065E,0x06D6,0x06D7,0x06D8,0x06D9,0x06DA,0x06DB,0x06DC,0x06DF,0x06E0,0x06E1,0x06E2,0x06E4,0x06E7,0x06E8,0x06EB,0x06EC,0x0730,0x0732,0x0733,0x0735,0x0736,0x073A,0x073D,0x073F,0x0740,0x0741,0x0743,0x0745,0x0747,0x0749,0x074A,0x07EB,0x07EC,0x07ED,0x07EE,0x07EF,0x07F0,0x07F1,0x07F3,0x0816,0x0817,0x0818,0x0819,0x081B,0x081C,0x081D,0x081E,0x081F,0x0820,0x0821,0x0822,0x0823,0x0825,0x0826,0x0827,0x0829,0x082A,0x082B,0x082C,0x082D,0x0951,0x0953,0x0954,0x0F82,0x0F83,0x0F86,0x0F87,0x135D,0x135E,0x135F,0x17DD,0x193A,0x1A17,0x1A75,0x1A76,0x1A77,0x1A78,0x1A79,0x1A7A,0x1A7B,0x1A7C,0x1B6B,0x1B6D,0x1B6E,0x1B6F,0x1B70,0x1B71,0x1B72,0x1B73,0x1CD0,0x1CD1,0x1CD2,0x1CDA,0x1CDB,0x1CE0,0x1DC0,0x1DC1,0x1DC3,0x1DC4,0x1DC5,0x1DC6,0x1DC7,0x1DC8,0x1DC9,0x1DCB,0x1DCC,0x1DD1,0x1DD2,0x1DD3,0x1DD4,0x1DD5,0x1DD6,0x1DD7,0x1DD8,0x1DD9,0x1DDA,0x1DDB,0x1DDC,0x1DDD,0x1DDE,0x1DDF,0x1DE0,0x1DE1,0x1DE2,0x1DE3,0x1DE4,0x1DE5,0x1DE6,0x1DFE,0x20D0,0x20D1,0x20D4,0x20D5,0x20D6,0x20D7,0x20DB,0x20DC,0x20E1,0x20E7,0x20E9,0x20F0,0x2CEF,0x2CF0,0x2CF1,0x2DE0,0x2DE1,0x2DE2,0x2DE3,0x2DE4,0x2DE5,0x2DE6,0x2DE7,0x2DE8,0x2DE9,0x2DEA,0x2DEB,0x2DEC,0x2DED,0x2DEE,0x2DEF,0x2DF0,0x2DF1,0x2DF2,0x2DF3,0x2DF4,0x2DF5,0x2DF6,0x2DF7,0x2DF8,0x2DF9,0x2DFA,0x2DFB,0x2DFC,0x2DFD,0x2DFE,0x2DFF,0xA66F,0xA67C,0xA67D,0xA6F0,0xA6F1,0xA8E0,0xA8E1,0xA8E2,0xA8E3,0xA8E4,0xA8E5,0xA8E6,0xA8E7,0xA8E8,0xA8E9,0xA8EA,0xA8EB,0xA8EC,0xA8ED,0xA8EE,0xA8EF,0xA8F0,0xA8F1,0xAAB0,0xAAB2,0xAAB3,0xAAB7,0xAAB8,0xAABE,0xAABF,0xAAC1,0xFE20,0xFE21,0xFE22,0xFE23,0xFE24,0xFE25,0xFE26,0x10A0F,0x10A38,0x1D185,0x1D186,0x1D187,0x1D188,0x1D189,0x1D1AA,0x1D1AB,0x1D1AC,0x1D1AD,0x1D242,0x1D243,0x1D244];
|
|
20
|
+
|
|
21
|
+
const ESC = "\x1b";
|
|
22
|
+
const diacritic = (i: number): string =>
|
|
23
|
+
String.fromCodePoint(DIACRITICS[Math.min(i, DIACRITICS.length - 1)]!);
|
|
24
|
+
|
|
25
|
+
interface Entry {
|
|
26
|
+
base64: string;
|
|
27
|
+
widthPx: number;
|
|
28
|
+
heightPx: number;
|
|
29
|
+
transmitted: boolean;
|
|
30
|
+
placedCols: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registry = new Map<number, Entry>();
|
|
34
|
+
|
|
35
|
+
export function supportsInlineImages(): boolean {
|
|
36
|
+
return getCapabilities().images === "kitty";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerInlineImage(png: Buffer): number | null {
|
|
40
|
+
if (!supportsInlineImages()) return null;
|
|
41
|
+
const base64 = png.toString("base64");
|
|
42
|
+
const dims = getImageDimensions(base64, "image/png");
|
|
43
|
+
if (!dims || dims.heightPx <= 0) return null;
|
|
44
|
+
const id = allocateImageId();
|
|
45
|
+
registry.set(id, { base64, widthPx: dims.widthPx, heightPx: dims.heightPx, transmitted: false, placedCols: 0 });
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const maxInlineCols = (): number => Math.max(8, (process.stdout.columns ?? 80) - 4);
|
|
50
|
+
|
|
51
|
+
export function inlineCols(id: number, maxCols = maxInlineCols()): number | null {
|
|
52
|
+
const e = registry.get(id);
|
|
53
|
+
if (!e) return null;
|
|
54
|
+
const cell = getCellDimensions();
|
|
55
|
+
const cols = Math.round((e.widthPx / e.heightPx) * (cell.heightPx / cell.widthPx));
|
|
56
|
+
// Column index is carried by a diacritic, so cols can't exceed the table.
|
|
57
|
+
return Math.max(1, Math.min(maxCols, DIACRITICS.length, cols));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Transmits once, then re-places only when cols changes (resize). `write` must
|
|
61
|
+
// target the same stream as the composed frame so the data lands before the cells.
|
|
62
|
+
export function emitInlineImage(id: number, cols: number, write: (s: string) => void): void {
|
|
63
|
+
const e = registry.get(id);
|
|
64
|
+
if (!e) return;
|
|
65
|
+
if (!e.transmitted) {
|
|
66
|
+
e.transmitted = true;
|
|
67
|
+
e.placedCols = cols;
|
|
68
|
+
const b64 = e.base64;
|
|
69
|
+
const CHUNK = 4096;
|
|
70
|
+
for (let i = 0; i < b64.length; i += CHUNK) {
|
|
71
|
+
const chunk = b64.slice(i, i + CHUNK);
|
|
72
|
+
const last = i + CHUNK >= b64.length;
|
|
73
|
+
write(
|
|
74
|
+
i === 0
|
|
75
|
+
? `${ESC}_Gq=2,a=T,U=1,f=100,t=d,i=${id},c=${cols},r=1,m=${last ? 0 : 1};${chunk}${ESC}\\`
|
|
76
|
+
: `${ESC}_Gq=2,m=${last ? 0 : 1};${chunk}${ESC}\\`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
} else if (e.placedCols !== cols) {
|
|
80
|
+
e.placedCols = cols;
|
|
81
|
+
write(`${ESC}_Gq=2,a=p,U=1,i=${id},c=${cols},r=1${ESC}\\`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Each cell encodes the id's low 24 bits in the fg colour and its high byte in the
|
|
86
|
+
// third diacritic (row, column, id-high) — allocateImageId() ids use all 32 bits.
|
|
87
|
+
// `colStart` offsets the column index so an image split across lines (wrap) keeps
|
|
88
|
+
// continuous columns instead of repeating its left edge.
|
|
89
|
+
export function inlinePlaceholder(id: number, count: number, colStart = 0): string {
|
|
90
|
+
const hi = diacritic((id >> 24) & 255);
|
|
91
|
+
let s = `${ESC}[38;2;${(id >> 16) & 255};${(id >> 8) & 255};${id & 255}m`;
|
|
92
|
+
for (let j = 0; j < count; j++) s += PLACEHOLDER + diacritic(0) + diacritic(colStart + j) + hi;
|
|
93
|
+
return s + `${ESC}[39m`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InlineItem {
|
|
97
|
+
id: number;
|
|
98
|
+
cols: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Repaint the reserved PLACEHOLDER runs as real kitty placeholder cells. Cells are
|
|
102
|
+
// partitioned by each image's reserved `cols` rather than by contiguous run, so
|
|
103
|
+
// adjacent images (`$a$$b$` → one fused run) and an image the wrapper split across
|
|
104
|
+
// lines both keep their ids aligned. `transmit` fires once per image, when its
|
|
105
|
+
// first cell is placed. Pure apart from the injected `transmit` callback.
|
|
106
|
+
export function paintInlineImages(
|
|
107
|
+
lines: string[],
|
|
108
|
+
items: InlineItem[],
|
|
109
|
+
transmit: (id: number, cols: number) => void,
|
|
110
|
+
): string[] {
|
|
111
|
+
if (items.length === 0) return lines;
|
|
112
|
+
let k = 0; // index of the image currently being placed
|
|
113
|
+
let placed = 0; // cells of image k already emitted across prior lines
|
|
114
|
+
return lines.map((line) => {
|
|
115
|
+
if (k >= items.length || !line.includes(PLACEHOLDER)) return line;
|
|
116
|
+
let out = "";
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < line.length) {
|
|
119
|
+
const ch = String.fromCodePoint(line.codePointAt(i)!);
|
|
120
|
+
if (ch === PLACEHOLDER && k < items.length) {
|
|
121
|
+
const item = items[k]!;
|
|
122
|
+
let count = 0;
|
|
123
|
+
while (
|
|
124
|
+
i < line.length &&
|
|
125
|
+
String.fromCodePoint(line.codePointAt(i)!) === PLACEHOLDER &&
|
|
126
|
+
placed + count < item.cols
|
|
127
|
+
) {
|
|
128
|
+
count++;
|
|
129
|
+
i += PLACEHOLDER.length;
|
|
130
|
+
}
|
|
131
|
+
if (placed === 0) transmit(item.id, item.cols);
|
|
132
|
+
out += inlinePlaceholder(item.id, count, placed);
|
|
133
|
+
placed += count;
|
|
134
|
+
if (placed >= item.cols) {
|
|
135
|
+
k++;
|
|
136
|
+
placed = 0;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
out += ch;
|
|
140
|
+
i += ch.length;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import { theme } from "./theme.js";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import { highlight, supportsLanguage } from "cli-highlight";
|
|
3
4
|
import type { ThemeColor } from "./theme.js";
|
|
4
5
|
import type { ToolEntryConfig } from "./display-config.js";
|
|
@@ -188,6 +189,11 @@ export function renderBody(body: Body, env: Env, diff: DiffSlot): string {
|
|
|
188
189
|
// The tail is capped even when expanded so a huge result can't flood scrollback; the agent still sees it all.
|
|
189
190
|
const DEFAULT_EXPANDED_LINES = 200;
|
|
190
191
|
|
|
192
|
+
function clampLines(lines: string[], width: number): string {
|
|
193
|
+
if (width <= 0) return lines.join("\n");
|
|
194
|
+
return lines.map((l) => truncateToWidth(l, width, "…")).join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
191
197
|
function renderStream(buffer: string, env: Env): string {
|
|
192
198
|
const display = buffer.replace(/\n+$/, "");
|
|
193
199
|
if (env.expanded) {
|
|
@@ -205,14 +211,14 @@ function renderStream(buffer: string, env: Env): string {
|
|
|
205
211
|
}
|
|
206
212
|
if (env.mode === "summary") {
|
|
207
213
|
if (!env.finalized) {
|
|
208
|
-
const tail = display.split("\n").slice(-2).
|
|
214
|
+
const tail = clampLines(display.split("\n").slice(-2), env.width);
|
|
209
215
|
return theme.fg("muted", tail);
|
|
210
216
|
}
|
|
211
217
|
return lineCountHint(buffer);
|
|
212
218
|
}
|
|
213
219
|
if (!display) return "";
|
|
214
220
|
const lines = display.split("\n");
|
|
215
|
-
const trimmed = lines.slice(-env.previewLines).
|
|
221
|
+
const trimmed = clampLines(lines.slice(-env.previewLines), env.width);
|
|
216
222
|
const remaining = Math.max(0, lines.length - env.previewLines);
|
|
217
223
|
// The preview is the tail, so the hidden lines come before it — note goes above.
|
|
218
224
|
const overflow = remaining > 0
|
|
@@ -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 {
|
|
@@ -15,6 +15,10 @@ import type { ExtensionContext } from "agent-sh/types";
|
|
|
15
15
|
|
|
16
16
|
export default function activate(ctx: ExtensionContext): void {
|
|
17
17
|
const { bus } = ctx;
|
|
18
|
+
|
|
19
|
+
// No shell to deliver to (e.g. ashi) — the suggestion would go nowhere.
|
|
20
|
+
if (!ctx.shell) return;
|
|
21
|
+
|
|
18
22
|
let pendingCommand: string | null = null;
|
|
19
23
|
|
|
20
24
|
// ── Tool ────────────────────────────────────────────────────────
|
|
@@ -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
|
);
|
|
@@ -23,5 +23,16 @@ export default function activate(ctx: ShellContext) {
|
|
|
23
23
|
errorBg: "\x1b[48;2;42;30;30m", // base03 with red tint
|
|
24
24
|
successBgEmph: "\x1b[48;2;20;70;50m", // stronger green tint
|
|
25
25
|
errorBgEmph: "\x1b[48;2;70;30;30m", // stronger red tint
|
|
26
|
+
|
|
27
|
+
mdHeading: "\x1b[38;2;181;137;0m", // yellow (#b58900)
|
|
28
|
+
mdLink: "\x1b[38;2;38;139;210m", // blue (#268bd2)
|
|
29
|
+
mdLinkUrl: "\x1b[38;2;88;110;117m", // base01 (#586e75)
|
|
30
|
+
mdCode: "\x1b[38;2;42;161;152m", // cyan (#2aa198)
|
|
31
|
+
mdCodeBlock: "\x1b[38;2;133;153;0m", // green (#859900)
|
|
32
|
+
mdCodeBlockBorder: "\x1b[38;2;88;110;117m", // base01 (#586e75)
|
|
33
|
+
mdQuote: "\x1b[38;2;88;110;117m", // base01 (#586e75)
|
|
34
|
+
mdQuoteBorder: "\x1b[38;2;88;110;117m", // base01 (#586e75)
|
|
35
|
+
mdHr: "\x1b[38;2;88;110;117m", // base01 (#586e75)
|
|
36
|
+
mdListBullet: "\x1b[38;2;38;139;210m", // blue (#268bd2)
|
|
26
37
|
});
|
|
27
38
|
}
|