agent-sh 0.15.7 → 0.15.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/agent/agent-loop.d.ts +3 -0
  2. package/dist/agent/agent-loop.js +17 -1
  3. package/dist/agent/events.d.ts +3 -0
  4. package/dist/agent/host-types.d.ts +6 -0
  5. package/dist/agent/index.js +5 -1
  6. package/dist/agent/llm-client.d.ts +2 -0
  7. package/dist/agent/llm-client.js +2 -2
  8. package/dist/agent/providers/openrouter.js +11 -1
  9. package/dist/shell/input-handler.js +5 -3
  10. package/dist/shell/strategies/bash.js +10 -2
  11. package/dist/shell/terminal.d.ts +2 -11
  12. package/dist/shell/terminal.js +37 -19
  13. package/dist/shell/tui-renderer.js +1 -1
  14. package/dist/utils/ansi.d.ts +7 -0
  15. package/dist/utils/ansi.js +20 -0
  16. package/dist/utils/floating-panel.js +5 -4
  17. package/dist/utils/line-editor.js +7 -4
  18. package/examples/extensions/ashi/package.json +1 -1
  19. package/examples/extensions/ashi/src/chat/assistant.ts +3 -1
  20. package/examples/extensions/ashi/src/cli.ts +8 -0
  21. package/examples/extensions/ashi/src/frontend.ts +4 -3
  22. package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
  23. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
  24. package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
  25. package/examples/extensions/latex-images.ts +152 -7
  26. package/package.json +1 -1
  27. package/src/agent/agent-loop.ts +17 -1
  28. package/src/agent/events.ts +1 -0
  29. package/src/agent/host-types.ts +2 -0
  30. package/src/agent/index.ts +7 -1
  31. package/src/agent/llm-client.ts +4 -2
  32. package/src/agent/providers/openrouter.ts +10 -1
  33. package/src/shell/input-handler.ts +5 -3
  34. package/src/shell/strategies/bash.ts +10 -2
  35. package/src/shell/terminal.ts +30 -19
  36. package/src/shell/tui-renderer.ts +1 -1
  37. package/src/utils/ansi.ts +21 -0
  38. package/src/utils/floating-panel.ts +5 -4
  39. package/src/utils/line-editor.ts +7 -4
@@ -84,6 +84,9 @@ export declare class AgentLoop implements AgentBackend {
84
84
  kill(): void;
85
85
  private cancel;
86
86
  private reasoningParams;
87
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
88
+ * when the frontend tracks no session. */
89
+ private currentSessionId;
87
90
  private resolveEndpoint;
88
91
  private pullModels;
89
92
  private emitIdentity;
@@ -390,6 +390,17 @@ export class AgentLoop {
390
390
  const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
391
391
  return { reasoning_effort: effort };
392
392
  }
393
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
394
+ * when the frontend tracks no session. */
395
+ currentSessionId() {
396
+ try {
397
+ const id = this.handlers.call("session:current-id");
398
+ return typeof id === "string" && id ? id : undefined;
399
+ }
400
+ catch {
401
+ return undefined;
402
+ }
403
+ }
393
404
  resolveEndpoint(m) {
394
405
  try {
395
406
  return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id });
@@ -1251,7 +1262,12 @@ export class AgentLoop {
1251
1262
  ...this.reasoningParams(),
1252
1263
  };
1253
1264
  this.bus.emit("llm:request", requestParams);
1254
- const stream = await this.llmClient.stream({ ...requestParams, signal });
1265
+ const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
1266
+ const stream = await this.llmClient.stream({
1267
+ ...requestParams,
1268
+ signal,
1269
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
1270
+ });
1255
1271
  try {
1256
1272
  for await (const chunk of stream) {
1257
1273
  if (signal.aborted)
@@ -21,6 +21,9 @@ declare module "../core/event-bus.js" {
21
21
  id: string;
22
22
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
23
23
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
24
+ requestHeaders?: (info: {
25
+ sessionId?: string;
26
+ }) => Record<string, string>;
24
27
  };
25
28
  "agent:models-changed": Record<string, never>;
26
29
  "config:switch-provider": {
@@ -85,6 +85,9 @@ export interface ModelEndpoint {
85
85
  baseURL?: string;
86
86
  buildReasoningParams?: (level: string) => Record<string, unknown>;
87
87
  extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
88
+ buildRequestHeaders?: (info: {
89
+ sessionId?: string;
90
+ }) => Record<string, string>;
88
91
  }
89
92
  /**
90
93
  * Capabilities the agent host adds on top of CoreContext. Only available
@@ -100,6 +103,9 @@ export interface AgentSurface {
100
103
  configure: (id: string, opts: {
101
104
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
102
105
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
106
+ requestHeaders?: (info: {
107
+ sessionId?: string;
108
+ }) => Record<string, string>;
103
109
  }) => void;
104
110
  };
105
111
  registerTool: (tool: ToolDefinition) => void;
@@ -105,6 +105,7 @@ export default function agentBackend(ctx) {
105
105
  return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
106
106
  };
107
107
  const bindCacheTokens = (shapeId) => providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
108
+ const bindRequestHeaders = (shapeId) => providerHooks.get(shapeId)?.requestHeaders;
108
109
  const agentSurface = {
109
110
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
110
111
  providers: {
@@ -322,6 +323,7 @@ export default function agentBackend(ctx) {
322
323
  baseURL: p.baseURL,
323
324
  buildReasoningParams: bindReasoning(shapeId, modelId),
324
325
  extractCachedTokens: bindCacheTokens(shapeId),
326
+ buildRequestHeaders: bindRequestHeaders(shapeId),
325
327
  };
326
328
  };
327
329
  ctx.define("agent:get-models", () => buildModels());
@@ -365,12 +367,14 @@ export default function agentBackend(ctx) {
365
367
  bus.emit("config:switch-model", { id: pendingModel, provider: pendingProvider });
366
368
  }
367
369
  });
368
- bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
370
+ bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
369
371
  const prev = providerHooks.get(id) ?? {};
370
372
  if (reasoningParams !== undefined)
371
373
  prev.reasoningParams = reasoningParams;
372
374
  if (cacheTokens !== undefined)
373
375
  prev.cacheTokens = cacheTokens;
376
+ if (requestHeaders !== undefined)
377
+ prev.requestHeaders = requestHeaders;
374
378
  providerHooks.set(id, prev);
375
379
  });
376
380
  bus.on("core:extensions-loaded", ({ names }) => {
@@ -39,6 +39,8 @@ export type StreamOpts = {
39
39
  model?: string;
40
40
  max_tokens?: number;
41
41
  signal?: AbortSignal;
42
+ /** Per-request transport headers, forwarded to the SDK (not request body). */
43
+ headers?: Record<string, string>;
42
44
  } & Record<string, unknown>;
43
45
  export type CompleteOpts = {
44
46
  messages: ChatCompletionMessageParam[];
@@ -43,7 +43,7 @@ export class LlmClient {
43
43
  this.model = newConfig.model;
44
44
  }
45
45
  stream(opts) {
46
- const { signal, messages, tools, model, max_tokens, ...rest } = opts;
46
+ const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
47
47
  const body = {
48
48
  ...rest,
49
49
  model: model ?? this.model,
@@ -53,7 +53,7 @@ export class LlmClient {
53
53
  stream: true,
54
54
  stream_options: { include_usage: true },
55
55
  };
56
- return this.client.chat.completions.create(body, { signal });
56
+ return this.client.chat.completions.create(body, { signal, headers });
57
57
  }
58
58
  async complete(opts) {
59
59
  const { messages, model, max_tokens, ...rest } = opts;
@@ -24,7 +24,17 @@ function toModalities(input) {
24
24
  }
25
25
  export default function activate(ctx) {
26
26
  const apiKey = resolveApiKey("openrouter").key;
27
- ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
27
+ ctx.agent.providers.configure("openrouter", {
28
+ reasoningParams: buildReasoningParams,
29
+ // x-session-id pins sticky provider routing across turns so prompt caches
30
+ // stay warm even when compaction rewrites the opening messages.
31
+ requestHeaders: ({ sessionId }) => {
32
+ const headers = {};
33
+ if (sessionId)
34
+ headers["x-session-id"] = sessionId;
35
+ return headers;
36
+ },
37
+ });
28
38
  ctx.agent.providers.register({
29
39
  id: "openrouter",
30
40
  apiKey: apiKey ?? undefined,
@@ -57,7 +57,8 @@ export class InputHandler {
57
57
  loadHistory() {
58
58
  try {
59
59
  const data = fs.readFileSync(HISTORY_FILE, "utf-8");
60
- this.history = data.split("\n").filter(Boolean);
60
+ this.history = data.split("\n").filter(Boolean)
61
+ .map((l) => l.replace(/\\([\\n])/g, (_, c) => c === "n" ? "\n" : "\\"));
61
62
  }
62
63
  catch {
63
64
  }
@@ -66,7 +67,8 @@ export class InputHandler {
66
67
  try {
67
68
  const { historySize } = getSettings();
68
69
  fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
69
- const lines = this.history.slice(-historySize);
70
+ const lines = this.history.slice(-historySize)
71
+ .map((l) => l.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"));
70
72
  fs.writeFileSync(HISTORY_FILE, lines.join("\n") + "\n");
71
73
  }
72
74
  catch {
@@ -373,7 +375,7 @@ export class InputHandler {
373
375
  this.editor.clear();
374
376
  this.view.resetCursor();
375
377
  this.dismissAutocomplete();
376
- if (query && query.startsWith("/")) {
378
+ if (query && query.startsWith("/") && !query.includes("\n")) {
377
379
  const spaceIdx = query.indexOf(" ");
378
380
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
379
381
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
@@ -40,8 +40,16 @@ export const bashStrategy = {
40
40
  ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
41
41
  ' [[ -n $COMP_LINE ]] && return',
42
42
  " __agent_sh_preexec_ran=1",
43
- " local this_cmd",
44
- ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
43
+ " local this_cmd hist_cmd",
44
+ ` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
45
+ " # history 1 carries the full typed line but goes stale when the user's",
46
+ " # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
47
+ " # matches the command bash is about to run; else use $BASH_COMMAND.",
48
+ ' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
49
+ " this_cmd=$hist_cmd",
50
+ " else",
51
+ " this_cmd=$BASH_COMMAND",
52
+ " fi",
45
53
  ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
46
54
  "}",
47
55
  "trap '__agent_sh_emit_preexec' DEBUG",
@@ -1,12 +1,3 @@
1
- /**
2
- * Terminal — the user-facing I/O endpoint that a Shell talks to.
3
- *
4
- * Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
5
- * interface is the *real* terminal (or its substitute) on the other end:
6
- * bytes in, bytes out, dimensions, resize notifications. The default
7
- * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
- * (multi-session web hubs, tests) supply their own.
9
- */
10
1
  import type { RenderSurface } from "../utils/compositor.js";
11
2
  export interface Terminal {
12
3
  write(data: string): void;
@@ -23,8 +14,8 @@ export interface Terminal {
23
14
  resume(): void;
24
15
  };
25
16
  }
26
- /** Default Terminal: wraps process.stdin/stdout. */
27
- export declare function processTerminal(): Terminal;
17
+ /** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
18
+ export declare function processTerminal(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream): Terminal;
28
19
  /**
29
20
  * No-op terminal for non-rendering hosts (tests, agent-only embeds).
30
21
  * Writes are discarded; input/resize never fire.
@@ -1,42 +1,60 @@
1
- /** Default Terminal: wraps process.stdin/stdout. */
2
- export function processTerminal() {
1
+ /**
2
+ * Terminal — the user-facing I/O endpoint that a Shell talks to.
3
+ *
4
+ * Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
5
+ * interface is the *real* terminal (or its substitute) on the other end:
6
+ * bytes in, bytes out, dimensions, resize notifications. The default
7
+ * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
+ * (multi-session web hubs, tests) supply their own.
9
+ */
10
+ import { StringDecoder } from "node:string_decoder";
11
+ /** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
12
+ export function processTerminal(stdin = process.stdin, stdout = process.stdout) {
3
13
  return {
4
14
  write(data) {
5
- if (process.stdout.writable) {
15
+ if (stdout.writable) {
6
16
  try {
7
- process.stdout.write(data);
17
+ stdout.write(data);
8
18
  }
9
19
  catch { /* ignore */ }
10
20
  }
11
21
  },
12
22
  onInput(cb) {
13
- const handler = (b) => cb(b.toString("utf-8"));
14
- process.stdin.on("data", handler);
15
- return () => { process.stdin.off("data", handler); };
23
+ // Stateful decode: tty chunk boundaries can land mid-way through a
24
+ // multibyte UTF-8 sequence (large pastes), so per-chunk toString()
25
+ // would emit U+FFFD for the torn halves.
26
+ const decoder = new StringDecoder("utf-8");
27
+ const handler = (b) => {
28
+ const text = decoder.write(b);
29
+ if (text)
30
+ cb(text);
31
+ };
32
+ stdin.on("data", handler);
33
+ return () => { stdin.off("data", handler); };
16
34
  },
17
35
  onResize(cb) {
18
- const handler = () => cb(process.stdout.columns || 80, process.stdout.rows || 24);
19
- process.stdout.on("resize", handler);
20
- return () => { process.stdout.off("resize", handler); };
36
+ const handler = () => cb(stdout.columns || 80, stdout.rows || 24);
37
+ stdout.on("resize", handler);
38
+ return () => { stdout.off("resize", handler); };
21
39
  },
22
- cols() { return process.stdout.columns || 80; },
23
- rows() { return process.stdout.rows || 24; },
40
+ cols() { return stdout.columns || 80; },
41
+ rows() { return stdout.rows || 24; },
24
42
  suspendInput() {
25
- const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
26
- if (process.stdin.isTTY) {
43
+ const wasRaw = stdin.isTTY && stdin.isRaw;
44
+ if (stdin.isTTY) {
27
45
  try {
28
- process.stdin.setRawMode(false);
29
- process.stdin.pause();
46
+ stdin.setRawMode(false);
47
+ stdin.pause();
30
48
  }
31
49
  catch { /* ignore */ }
32
50
  }
33
51
  return {
34
52
  resume() {
35
- if (process.stdin.isTTY) {
53
+ if (stdin.isTTY) {
36
54
  try {
37
- process.stdin.resume();
55
+ stdin.resume();
38
56
  if (wasRaw)
39
- process.stdin.setRawMode(true);
57
+ stdin.setRawMode(true);
40
58
  }
41
59
  catch { /* ignore */ }
42
60
  }
@@ -845,7 +845,7 @@ export default function activate(ctx) {
845
845
  ? getSettings().readOutputMaxLines
846
846
  : getSettings().maxCommandOutputLines;
847
847
  s.commandOutputBuffer += chunk;
848
- const lines = s.commandOutputBuffer.split("\n");
848
+ const lines = s.commandOutputBuffer.split(/\r?\n/);
849
849
  s.commandOutputBuffer = lines.pop();
850
850
  for (const line of lines) {
851
851
  if (s.commandOutputLineCount < maxLines) {
@@ -44,3 +44,10 @@ export declare function padEndToWidth(str: string, targetWidth: number): string;
44
44
  * CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
45
45
  * but callers rely on it being stripped alongside. */
46
46
  export declare function stripAnsi(str: string): string;
47
+ /**
48
+ * Sanitize text for painting at a fixed screen position: SGR (color/style)
49
+ * passes through; anything else that would move the cursor or mutate
50
+ * terminal state mid-row is dropped. Tabs become a single space so painted
51
+ * width matches `stripAnsi`-based measurement.
52
+ */
53
+ export declare function stripCursorControls(str: string): string;
@@ -134,3 +134,23 @@ export function padEndToWidth(str, targetWidth) {
134
134
  export function stripAnsi(str) {
135
135
  return stripAnsiPkg(str).replace(/\r/g, "");
136
136
  }
137
+ /**
138
+ * Sanitize text for painting at a fixed screen position: SGR (color/style)
139
+ * passes through; anything else that would move the cursor or mutate
140
+ * terminal state mid-row is dropped. Tabs become a single space so painted
141
+ * width matches `stripAnsi`-based measurement.
142
+ */
143
+ export function stripCursorControls(str) {
144
+ // Park SGR behind NUL placeholders so the strips below can't eat their ESC bytes.
145
+ const sgr = [];
146
+ const cleaned = str
147
+ .replace(/\x00/g, "")
148
+ .replace(/\x1b\[[0-9;:]*m/g, (m) => { sgr.push(m); return "\x00"; })
149
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "")
150
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
151
+ .replace(/\x1b./g, "")
152
+ .replace(/\t/g, " ")
153
+ .replace(/[\x01-\x08\x0a-\x1f\x7f]/g, "");
154
+ let i = 0;
155
+ return cleaned.replace(/\x00/g, () => sgr[i++] ?? "");
156
+ }
@@ -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";
@@ -253,10 +253,11 @@ export class FloatingPanel {
253
253
  this.handlers.define(`${p}:input`, (_data) => false);
254
254
  // Default row builder: truncate and pad
255
255
  this.handlers.define(`${p}:build-row`, (content, width) => {
256
- const plain = stripAnsi(content);
256
+ const clean = stripCursorControls(content);
257
+ const plain = stripAnsi(clean);
257
258
  const display = plain.length > width
258
- ? content.slice(0, width - 1) + "\u2026"
259
- : content;
259
+ ? clean.slice(0, width - 1) + "\u2026"
260
+ : clean;
260
261
  const pad = Math.max(0, width - stripAnsi(display).length);
261
262
  return display + " ".repeat(pad);
262
263
  });
@@ -121,7 +121,7 @@ export class LineEditor {
121
121
  // paste, since typed input arrives one keystroke per chunk in raw mode.
122
122
  if (!this.inPaste && data.length > 1 && /[\r\n]/.test(data)
123
123
  && data.indexOf("\x1b[200~") === -1) {
124
- this.pasteAccum = data.replace(/\r\n?/g, "\n");
124
+ this.pasteAccum = data;
125
125
  actions.push(...this.commitPaste());
126
126
  return actions;
127
127
  }
@@ -247,7 +247,7 @@ export class LineEditor {
247
247
  consumePasteChunk(data) {
248
248
  const endIdx = data.indexOf(PASTE_END);
249
249
  if (endIdx !== -1) {
250
- this.pasteAccum += data.slice(0, endIdx).replace(/\r/g, "");
250
+ this.pasteAccum += data.slice(0, endIdx);
251
251
  return endIdx;
252
252
  }
253
253
  let suffixLen = 0;
@@ -258,14 +258,17 @@ export class LineEditor {
258
258
  }
259
259
  }
260
260
  const safeEnd = data.length - suffixLen;
261
- this.pasteAccum += data.slice(0, safeEnd).replace(/\r/g, "");
261
+ this.pasteAccum += data.slice(0, safeEnd);
262
262
  if (suffixLen > 0)
263
263
  this.pendingSeq = data.slice(safeEnd);
264
264
  return -1;
265
265
  }
266
266
  commitPaste() {
267
267
  this.inPaste = false;
268
- const accum = this.pasteAccum;
268
+ // Pasted line separators arrive as \n, \r (xterm convention), or \r\n
269
+ // depending on terminal; normalize on the full accumulation so a \r\n
270
+ // pair split across chunks still collapses to one newline.
271
+ const accum = this.pasteAccum.replace(/\r\n?/g, "\n");
269
272
  this.pasteAccum = "";
270
273
  if (!accum)
271
274
  return [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -38,7 +38,9 @@ export class AssistantMessage {
38
38
  if (this.buffer === "") this.buffer = " ";
39
39
  const blocks = this.transform([{ type: "text", text: this.buffer }]);
40
40
  if (blocks.every((b) => b.type === "text")) {
41
- this.md.setText(stripTrailing(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("")));
42
44
  return;
43
45
  }
44
46
  this.rebuild(blocks);
@@ -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;
@@ -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
  };
@@ -816,12 +816,13 @@ export function mountAshi(
816
816
  bus.on("shell:foreground-busy", ({ busy }) => { shellForegroundBusy = busy; });
817
817
 
818
818
  let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
819
- bus.on("shell:command-start", ({ command }) => {
819
+ bus.on("shell:command-start", () => {
820
820
  if (agentShellActive) return;
821
821
  const intent = pendingUserShell.consume();
822
822
  if (!intent) return;
823
823
  finalizeThinking();
824
824
  if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
825
+ const command = intent.command;
825
826
  const isPrivate = intent.private;
826
827
  const name = isPrivate ? "user_bash_private" : "user_bash";
827
828
  const pair = renderToolPair({
@@ -861,7 +862,7 @@ export function mountAshi(
861
862
  // Drain shell queue before queries so its output lands in the next turn's <shell_events>.
862
863
  while (queuedShellLines.length > 0) {
863
864
  const item = queuedShellLines.shift()!;
864
- pendingUserShell.push({ private: item.private });
865
+ pendingUserShell.push({ private: item.private, command: item.line });
865
866
  if (item.private) bus.emit("shell:user-exec-exclude-next", {});
866
867
  bus.emit("shell:pty-write", { data: item.line + "\n" });
867
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
+ }