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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/agent/agent-loop.d.ts +3 -0
  4. package/dist/agent/agent-loop.js +19 -6
  5. package/dist/agent/events.d.ts +3 -0
  6. package/dist/agent/extensions/rolling-history/index.js +20 -8
  7. package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
  8. package/dist/agent/extensions/rolling-history/recall.js +17 -7
  9. package/dist/agent/host-types.d.ts +6 -0
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/llm-client.d.ts +2 -0
  12. package/dist/agent/llm-client.js +2 -2
  13. package/dist/agent/providers/openai-compatible.d.ts +8 -0
  14. package/dist/agent/providers/openai-compatible.js +9 -2
  15. package/dist/agent/providers/openrouter.js +11 -1
  16. package/dist/agent/store.js +6 -1
  17. package/dist/agent/token-budget.d.ts +2 -1
  18. package/dist/agent/token-budget.js +6 -1
  19. package/dist/cli/index.js +1 -1
  20. package/dist/core/event-bus.d.ts +16 -1
  21. package/dist/core/event-bus.js +73 -11
  22. package/dist/core/index.js +18 -0
  23. package/dist/shell/strategies/bash.js +10 -2
  24. package/dist/shell/tui-renderer.js +115 -174
  25. package/dist/utils/executor.js +19 -11
  26. package/dist/utils/floating-panel.d.ts +1 -0
  27. package/dist/utils/floating-panel.js +28 -26
  28. package/dist/utils/markdown.js +19 -21
  29. package/dist/utils/palette.d.ts +11 -0
  30. package/dist/utils/palette.js +11 -0
  31. package/docs/agent.md +13 -11
  32. package/docs/architecture.md +3 -5
  33. package/docs/extensions.md +21 -20
  34. package/docs/library.md +6 -3
  35. package/docs/troubleshooting.md +2 -2
  36. package/docs/tui-composition.md +11 -3
  37. package/docs/usage.md +70 -50
  38. package/examples/extensions/ashi/package.json +1 -1
  39. package/examples/extensions/ashi/src/chat/assistant.ts +8 -4
  40. package/examples/extensions/ashi/src/cli.ts +8 -0
  41. package/examples/extensions/ashi/src/compaction.ts +4 -7
  42. package/examples/extensions/ashi/src/frontend.ts +6 -3
  43. package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
  44. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
  45. package/examples/extensions/ashi/src/schema.ts +8 -2
  46. package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
  47. package/examples/extensions/command-suggest.ts +4 -0
  48. package/examples/extensions/latex-images.ts +152 -7
  49. package/examples/extensions/solarized-theme.ts +11 -0
  50. package/package.json +1 -1
  51. package/src/agent/agent-loop.ts +19 -6
  52. package/src/agent/events.ts +1 -0
  53. package/src/agent/extensions/rolling-history/index.ts +20 -8
  54. package/src/agent/extensions/rolling-history/recall.ts +28 -7
  55. package/src/agent/host-types.ts +2 -0
  56. package/src/agent/index.ts +7 -1
  57. package/src/agent/llm-client.ts +4 -2
  58. package/src/agent/providers/openai-compatible.ts +19 -4
  59. package/src/agent/providers/openrouter.ts +10 -1
  60. package/src/agent/store.ts +5 -1
  61. package/src/agent/token-budget.ts +10 -1
  62. package/src/cli/index.ts +1 -1
  63. package/src/core/event-bus.ts +67 -12
  64. package/src/core/index.ts +18 -0
  65. package/src/shell/strategies/bash.ts +10 -2
  66. package/src/shell/tui-renderer.ts +130 -207
  67. package/src/utils/executor.ts +17 -14
  68. package/src/utils/floating-panel.ts +24 -22
  69. package/src/utils/markdown.ts +17 -20
  70. 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
- this.md.setText(this.buffer);
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
- const keptIds = kept.map((_, i) => capture.getEntryIdAt(cutIdx + i));
51
- if (keptIds.some((id) => id === null)) {
52
- ctx.bus.emit("ui:error", { message: "compaction: a kept message has no on-disk entry — capture invariant broken" });
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", ({ command }) => {
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 ? 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,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).join("\n");
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).join("\n");
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
- 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
  );
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.6",
3
+ "version": "0.15.8",
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": [