agent-sh 0.14.10 → 0.15.0

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 (97) hide show
  1. package/README.md +36 -13
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +123 -150
  4. package/dist/agent/events.d.ts +10 -12
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +76 -29
  8. package/dist/agent/live-view.d.ts +3 -3
  9. package/dist/agent/live-view.js +15 -7
  10. package/dist/agent/providers/deepseek.js +9 -1
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/session-store.js +1 -1
  13. package/dist/agent/subagent.js +1 -1
  14. package/dist/agent/system-prompt.d.ts +7 -3
  15. package/dist/agent/system-prompt.js +11 -14
  16. package/dist/agent/tool-protocol.js +0 -7
  17. package/dist/cli/args.js +2 -1
  18. package/dist/cli/install.d.ts +1 -0
  19. package/dist/cli/install.js +39 -2
  20. package/dist/cli/subcommands.js +1 -0
  21. package/dist/core/event-bus.js +0 -2
  22. package/dist/core/extension-loader.js +3 -1
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/index.js +3 -2
  25. package/dist/extensions/slash-commands/index.js +16 -11
  26. package/dist/shell/events.d.ts +3 -0
  27. package/dist/shell/index.js +9 -0
  28. package/dist/shell/shell-context.d.ts +2 -2
  29. package/dist/shell/shell-context.js +26 -11
  30. package/dist/shell/shell.js +3 -0
  31. package/dist/shell/tui-renderer.js +0 -1
  32. package/dist/utils/diff-renderer.d.ts +4 -0
  33. package/dist/utils/diff-renderer.js +15 -27
  34. package/dist/utils/handler-registry.d.ts +1 -6
  35. package/dist/utils/handler-registry.js +1 -6
  36. package/dist/utils/line-editor.js +0 -2
  37. package/dist/utils/palette.js +4 -4
  38. package/dist/utils/terminal-buffer.d.ts +2 -0
  39. package/dist/utils/terminal-buffer.js +4 -0
  40. package/examples/extensions/ads/SKILL.md +170 -0
  41. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  42. package/examples/extensions/ash-scheme/index.ts +377 -687
  43. package/examples/extensions/ash-scheme/package.json +1 -1
  44. package/examples/extensions/ashi/EXTENDING.md +118 -0
  45. package/examples/extensions/ashi/README.md +26 -54
  46. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  47. package/examples/extensions/ashi/package.json +14 -2
  48. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  49. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  50. package/examples/extensions/ashi/src/capture.ts +54 -10
  51. package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
  52. package/examples/extensions/ashi/src/chat/lines.ts +39 -0
  53. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  54. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  55. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  56. package/examples/extensions/ashi/src/cli.ts +80 -12
  57. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  58. package/examples/extensions/ashi/src/commands.ts +11 -1
  59. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  60. package/examples/extensions/ashi/src/display-config.ts +16 -1
  61. package/examples/extensions/ashi/src/docks.ts +31 -0
  62. package/examples/extensions/ashi/src/events.ts +16 -0
  63. package/examples/extensions/ashi/src/frontend.ts +456 -268
  64. package/examples/extensions/ashi/src/hooks.ts +27 -40
  65. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  66. package/examples/extensions/ashi/src/renderer.ts +222 -0
  67. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  68. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
  69. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
  70. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
  71. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  72. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  73. package/examples/extensions/ashi/src/schema.ts +46 -205
  74. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  75. package/examples/extensions/ashi/src/status-footer.ts +35 -25
  76. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  77. package/examples/extensions/ashi/src/theme.ts +1 -47
  78. package/examples/extensions/ashi/src/ui.ts +88 -0
  79. package/examples/extensions/ashi-ink/README.md +61 -0
  80. package/examples/extensions/ashi-ink/package.json +30 -0
  81. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  82. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  83. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  84. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  85. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  86. package/examples/extensions/ashi-scheme-render.ts +10 -10
  87. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  88. package/examples/extensions/ashi-ui-demo.ts +63 -0
  89. package/examples/extensions/latex-images.ts +70 -19
  90. package/examples/extensions/overlay-agent.ts +5 -5
  91. package/examples/extensions/pi-bridge/index.ts +7 -12
  92. package/examples/extensions/terminal-buffer.ts +4 -2
  93. package/package.json +3 -9
  94. package/examples/extensions/ashi/src/components.ts +0 -238
  95. package/examples/extensions/ollama.ts +0 -108
  96. package/examples/extensions/opencode-provider.ts +0 -251
  97. package/examples/extensions/zai-coding-plan.ts +0 -35
@@ -2,8 +2,10 @@ import type { ExtensionContext } from "agent-sh/types";
2
2
  import type { MultiSessionStore } from "./multi-session-store.js";
3
3
  import type { AgentShMessage as AgentMessage } from "agent-sh/session-store";
4
4
 
5
- /** Maintains an `(entryId | null)[]` parallel to the live messages array;
6
- * null slots are synthetics like compaction summaries that have no entry. */
5
+ interface DiffEntry { diff: unknown; filePath: string }
6
+ export interface NestedDiff extends DiffEntry { name: string }
7
+
8
+ // liveEntryIds is parallel to the live messages array; null slots are synthetics (e.g. compaction summaries) with no entry.
7
9
  export interface Capture {
8
10
  flush(): Promise<void>;
9
11
  getEntryIdAt(messageIndex: number): string | null;
@@ -15,24 +17,59 @@ export function registerCapture(
15
17
  getStore: () => MultiSessionStore,
16
18
  ): Capture {
17
19
  let liveEntryIds: (string | null)[] = [];
18
- const diffMeta = new Map<string, { diff: unknown; filePath: string }>();
20
+ // A bridged tool call re-emitted under a synthetic id has no conversation message
21
+ // of its own, so bucket its diff under the enclosing real call for replay as a
22
+ // separate edit pair.
23
+ const diffMeta = new Map<string, DiffEntry>();
24
+ const nestedDiffs = new Map<string, NestedDiff[]>();
25
+ const summaryMeta = new Map<string, string>();
26
+ const bridgedNames = new Map<string, string>();
27
+ let activeRealToolId: string | undefined;
28
+
29
+ // `nested` is a bridge-set bus convention (a host tool run inside another tool),
30
+ // not on the core event type — read it defensively.
31
+ const isNested = (e: unknown): boolean => !!(e as { nested?: boolean }).nested;
32
+
33
+ ctx.bus.on("agent:tool-started", (e) => {
34
+ const id = e.toolCallId;
35
+ if (!id) return;
36
+ if (isNested(e)) bridgedNames.set(id, e.name ?? e.title);
37
+ else activeRealToolId = id;
38
+ });
19
39
 
20
40
  ctx.bus.on("agent:tool-completed", (e) => {
21
41
  const id = e.toolCallId;
22
- const body = e.resultDisplay?.body;
23
- if (id && body?.kind === "diff") {
24
- diffMeta.set(id, { diff: body.diff, filePath: body.filePath });
42
+ if (!id) return;
43
+ const display = e.resultDisplay;
44
+ const body = display?.body;
45
+ if (isNested(e)) {
46
+ if (body?.kind === "diff" && activeRealToolId) {
47
+ const arr = nestedDiffs.get(activeRealToolId) ?? [];
48
+ arr.push({ name: bridgedNames.get(id) ?? "edit_file", diff: body.diff, filePath: body.filePath });
49
+ nestedDiffs.set(activeRealToolId, arr);
50
+ }
51
+ return;
25
52
  }
53
+ // resultDisplay isn't persisted; capture the summary for every tool so resume
54
+ // doesn't fall back to re-deriving only a handful.
55
+ if (typeof display?.summary === "string" && display.summary) summaryMeta.set(id, display.summary);
56
+ if (body?.kind === "diff") diffMeta.set(id, { diff: body.diff, filePath: body.filePath });
26
57
  });
27
58
 
28
59
  const enrich = (m: AgentMessage): AgentMessage => {
29
60
  if (m.role !== "tool" || !m.tool_call_id) return m;
30
- const meta = diffMeta.get(m.tool_call_id);
31
- if (!meta) return m;
32
- return { ...m, meta: { ...m.meta, diff: meta.diff, filePath: meta.filePath } };
61
+ const single = diffMeta.get(m.tool_call_id);
62
+ const nested = nestedDiffs.get(m.tool_call_id);
63
+ const summary = summaryMeta.get(m.tool_call_id);
64
+ if (!single && !nested && !summary) return m;
65
+ const meta: Record<string, unknown> = { ...m.meta };
66
+ if (single) { meta.diff = single.diff; meta.filePath = single.filePath; }
67
+ if (nested) meta.diffs = nested;
68
+ if (summary) meta.summary = summary;
69
+ return { ...m, meta };
33
70
  };
34
71
 
35
- const flush = async (): Promise<void> => {
72
+ const writeNewMessages = async (): Promise<void> => {
36
73
  const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
37
74
  if (!messages || messages.length <= liveEntryIds.length) return;
38
75
  const newMessages = messages.slice(liveEntryIds.length).map(enrich);
@@ -41,6 +78,13 @@ export function registerCapture(
41
78
  getStore().markLastSession();
42
79
  };
43
80
 
81
+ // Serialize flushes so an exit-time flush can't race processing-done and double-append.
82
+ let chain: Promise<void> = Promise.resolve();
83
+ const flush = (): Promise<void> => {
84
+ chain = chain.then(writeNewMessages, writeNewMessages);
85
+ return chain;
86
+ };
87
+
44
88
  ctx.bus.on("agent:processing-done", () => { void flush(); });
45
89
 
46
90
  return {
@@ -0,0 +1,67 @@
1
+ import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
2
+
3
+ export type RenderBlock =
4
+ | { type: "text"; text: string }
5
+ | { type: "code-block"; language: string; code: string }
6
+ | { type: "image"; data: Buffer };
7
+
8
+ export type ContentTransform = (blocks: RenderBlock[]) => RenderBlock[];
9
+
10
+ export class AssistantMessage {
11
+ readonly node: RenderNode;
12
+ private container: ContainerView;
13
+ private md: MarkdownView;
14
+ private buffer = "";
15
+
16
+ constructor(private nodes: RenderNodes, private transform: ContentTransform = (b) => b) {
17
+ this.container = nodes.container();
18
+ this.md = nodes.markdown({ paddingX: 1, bullet: true });
19
+ this.container.addChild(nodes.spacer(1));
20
+ this.container.addChild(this.md.node);
21
+ this.node = this.container.node;
22
+ }
23
+
24
+ appendText(t: string): void {
25
+ this.buffer += t;
26
+ this.md.setText(this.buffer);
27
+ }
28
+
29
+ appendCodeBlock(language: string, code: string): void {
30
+ const prefix = this.buffer && !this.buffer.endsWith("\n") ? "\n" : "";
31
+ this.buffer += `${prefix}\`\`\`${language}\n${code}\n\`\`\`\n`;
32
+ this.md.setText(this.buffer);
33
+ }
34
+
35
+ finalize(): void {
36
+ if (this.buffer === "") this.buffer = " ";
37
+ const blocks = this.transform([{ type: "text", text: this.buffer }]);
38
+ if (blocks.every((b) => b.type === "text")) {
39
+ this.md.setText(this.buffer);
40
+ return;
41
+ }
42
+ this.rebuild(blocks);
43
+ }
44
+
45
+ private rebuild(blocks: RenderBlock[]): void {
46
+ this.container.clear();
47
+ this.container.addChild(this.nodes.spacer(1));
48
+ for (const block of blocks) {
49
+ if (block.type === "image") {
50
+ const img = this.nodes.image(block.data);
51
+ if (img) this.container.addChild(img);
52
+ } else if (block.type === "code-block") {
53
+ const m = this.nodes.markdown({ paddingX: 1 });
54
+ m.setText(`\`\`\`${block.language}\n${block.code}\n\`\`\``);
55
+ this.container.addChild(m.node);
56
+ } else if (block.text.trim()) {
57
+ const m = this.nodes.markdown({ paddingX: 1 });
58
+ m.setText(block.text);
59
+ this.container.addChild(m.node);
60
+ }
61
+ }
62
+ }
63
+
64
+ hasContent(): boolean {
65
+ return this.buffer.trim().length > 0;
66
+ }
67
+ }
@@ -0,0 +1,39 @@
1
+ import type { RenderNode, RenderNodes } from "../renderer.js";
2
+ import { theme, type ThemeColor } from "../theme.js";
3
+
4
+ export class InfoLine {
5
+ readonly node: RenderNode;
6
+ constructor(nodes: RenderNodes, message: string) {
7
+ const t = nodes.text({ paddingX: 1 });
8
+ t.setText(theme.fg("muted", message));
9
+ this.node = t.node;
10
+ }
11
+ }
12
+
13
+ export class ErrorLine {
14
+ readonly node: RenderNode;
15
+ constructor(nodes: RenderNodes, message: string) {
16
+ const t = nodes.text({ paddingX: 1 });
17
+ t.setText(`${theme.fg("error", "✗")} ${theme.fg("error", message)}`);
18
+ this.node = t.node;
19
+ }
20
+ }
21
+
22
+ export type NoticeLevel = "info" | "warn" | "error" | "success";
23
+
24
+ const NOTICE: Record<NoticeLevel, { color: ThemeColor; prefix: string }> = {
25
+ info: { color: "muted", prefix: "" },
26
+ success: { color: "success", prefix: "✓ " },
27
+ warn: { color: "warning", prefix: "! " },
28
+ error: { color: "error", prefix: "✗ " },
29
+ };
30
+
31
+ export class NoticeLine {
32
+ readonly node: RenderNode;
33
+ constructor(nodes: RenderNodes, message: string, level: NoticeLevel = "info") {
34
+ const { color, prefix } = NOTICE[level];
35
+ const t = nodes.text({ paddingX: 1 });
36
+ t.setText(`${theme.fg(color, prefix)}${theme.fg(color, message)}`);
37
+ this.node = t.node;
38
+ }
39
+ }
@@ -0,0 +1,42 @@
1
+ // Hidden clears the block but keeps the buffer so a toggle restores it.
2
+ import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
3
+ import { theme } from "../theme.js";
4
+
5
+ const thinkingColor = (t: string): string => theme.dim(theme.italic(theme.fg("thinkingText", t)));
6
+
7
+ export class ThinkingBlock {
8
+ readonly node: RenderNode;
9
+ private container: ContainerView;
10
+ private md: MarkdownView;
11
+ private buffer = "";
12
+ private hidden = false;
13
+
14
+ constructor(private nodes: RenderNodes) {
15
+ this.container = nodes.container();
16
+ this.md = nodes.markdown({ paddingX: 1, color: thinkingColor });
17
+ this.container.addChild(nodes.spacer(1));
18
+ this.container.addChild(this.md.node);
19
+ this.node = this.container.node;
20
+ }
21
+
22
+ appendText(t: string): void {
23
+ this.buffer += t;
24
+ if (!this.hidden) this.md.setText(this.buffer);
25
+ }
26
+
27
+ finalize(): void {
28
+ if (this.buffer === "") this.buffer = " ";
29
+ if (!this.hidden) this.md.setText(this.buffer);
30
+ }
31
+
32
+ setHidden(hidden: boolean): void {
33
+ if (hidden === this.hidden) return;
34
+ this.hidden = hidden;
35
+ this.container.clear();
36
+ if (hidden) return;
37
+ this.container.addChild(this.nodes.spacer(1));
38
+ this.md = this.nodes.markdown({ paddingX: 1, color: thinkingColor });
39
+ this.md.setText(this.buffer);
40
+ this.container.addChild(this.md.node);
41
+ }
42
+ }
@@ -0,0 +1,84 @@
1
+ import type { Renderer, RenderNode, ToolGroupChild, ToolGroupModel, ToolGroupView } from "../renderer.js";
2
+
3
+ export const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
4
+
5
+ const SHORT_TOOL_NAMES: Record<string, string> = {
6
+ read_file: "read",
7
+ edit_file: "edit",
8
+ write_file: "write",
9
+ };
10
+
11
+ function shortToolName(name: string): string {
12
+ return SHORT_TOOL_NAMES[name] ?? name;
13
+ }
14
+
15
+ export class ToolGroup {
16
+ readonly node: RenderNode;
17
+ readonly kind: string;
18
+ private view: ToolGroupView;
19
+ private maxVisible: number;
20
+ private allChildren: ToolGroupChild[] = [];
21
+ private callsById = new Map<string, ToolGroupChild>();
22
+ private expanded = false;
23
+ private open = true;
24
+
25
+ constructor(renderer: Renderer, kind: string, maxVisible: number = Infinity) {
26
+ this.kind = kind;
27
+ this.maxVisible = maxVisible;
28
+ this.view = renderer.mountToolGroup!();
29
+ this.node = this.view.node;
30
+ this.repaint();
31
+ }
32
+
33
+ addCall(toolCallId: string, name: string, detail: string): void {
34
+ const child: ToolGroupChild = { name: shortToolName(name), detail: detail || "…" };
35
+ if (toolCallId) this.callsById.set(toolCallId, child);
36
+ this.allChildren.push(child);
37
+ this.repaint();
38
+ }
39
+
40
+ recordCompletion(toolCallId: string, exitCode: number | null, summary?: string): void {
41
+ const child = this.callsById.get(toolCallId);
42
+ if (!child) return;
43
+ child.status = { exitCode, summary };
44
+ this.repaint();
45
+ }
46
+
47
+ toggleExpanded(): void {
48
+ this.expanded = !this.expanded;
49
+ this.repaint();
50
+ }
51
+
52
+ seal(): void {
53
+ if (!this.open) return;
54
+ this.open = false;
55
+ this.repaint();
56
+ }
57
+
58
+ // When collapsed and over-cap, one visible slot is reserved for the summary.
59
+ private visibleSliceStart(): number {
60
+ if (this.expanded || !Number.isFinite(this.maxVisible)) return 0;
61
+ if (this.allChildren.length <= this.maxVisible) return 0;
62
+ return this.allChildren.length - (this.maxVisible - 1);
63
+ }
64
+
65
+ private repaint(): void {
66
+ const start = this.visibleSliceStart();
67
+ const evicted = this.allChildren.slice(0, start);
68
+ const hidden = evicted.length === 0
69
+ ? null
70
+ : {
71
+ count: evicted.length,
72
+ ok: evicted.every((c) => !c.status || c.status.exitCode === null || c.status.exitCode === 0),
73
+ };
74
+ const model: ToolGroupModel = {
75
+ kind: this.kind,
76
+ icon: GROUP_ICONS[this.kind] ?? "▶",
77
+ children: this.allChildren.slice(start),
78
+ hidden,
79
+ expanded: this.expanded,
80
+ open: this.open,
81
+ };
82
+ this.view.update(model);
83
+ }
84
+ }
@@ -0,0 +1,20 @@
1
+ import type { RenderNode, RenderNodes } from "../renderer.js";
2
+ import { theme } from "../theme.js";
3
+
4
+ export class UserMessage {
5
+ readonly node: RenderNode;
6
+ constructor(nodes: RenderNodes, text: string) {
7
+ const container = nodes.container();
8
+ container.addChild(nodes.spacer(1));
9
+ const md = nodes.markdown({
10
+ paddingX: 1,
11
+ paddingY: 1,
12
+ bgColor: (t) => theme.bg("userMessageBg", t),
13
+ color: (t) => theme.fg("userMessageText", t),
14
+ osc133Zones: true,
15
+ });
16
+ md.setText(text);
17
+ container.addChild(md.node);
18
+ this.node = container.node;
19
+ }
20
+ }
@@ -8,11 +8,12 @@ import { loadExtensions } from "agent-sh/extension-loader";
8
8
  import { activateAgent } from "agent-sh/agent";
9
9
  import { getSettings } from "agent-sh/settings";
10
10
  import { Shell } from "agent-sh/shell";
11
+ import { TerminalBuffer } from "agent-sh/utils/terminal-buffer";
11
12
  import type { Terminal } from "agent-sh/shell/terminal";
12
13
  import activateShellContext from "agent-sh/shell/context";
13
14
  import type { AppConfig } from "agent-sh/types";
14
15
 
15
- /** No-op: ashi renders via pi-tui, the PTY only needs to exist. */
16
+ /** ashi renders through its own Renderer; this PTY only needs to exist. */
16
17
  function headlessTerminal(): Terminal {
17
18
  return {
18
19
  write() {},
@@ -23,23 +24,41 @@ function headlessTerminal(): Terminal {
23
24
  };
24
25
  }
25
26
 
27
+ import "./events.js";
26
28
  import { mountAshi } from "./frontend.js";
27
29
  import { MultiSessionStore } from "./multi-session-store.js";
28
30
  import { registerForkCommands, applyBranchMessages } from "./commands.js";
29
31
  import { registerSessionCommands } from "./session-commands.js";
30
32
  import { registerCompaction } from "./compaction.js";
31
- import { registerCapture } from "./capture.js";
33
+ import { registerCapture, type Capture } from "./capture.js";
32
34
  import { registerRenderDefaults } from "./hooks.js";
33
35
  import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
36
+ import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
37
+ import type { Renderer } from "./renderer.js";
38
+ import { loadRendererPreference } from "./display-config.js";
39
+ import { applyOutputMode } from "./terminal-mode.js";
34
40
  import * as os from "node:os";
35
41
  import * as path from "node:path";
42
+ import { fileURLToPath } from "node:url";
36
43
 
37
- function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean } {
44
+ // Package root (dist/cli.js and src/cli.ts both sit one level down) the running copy.
45
+ const ASHI_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
46
+ const ASHI_SURFACE = `You're attached through ashi, an interactive terminal UI. A person is at the keyboard reading your replies as they render — address them directly and keep the exchange conversational.
47
+
48
+ Your working directory is ${process.cwd()}; your tools run there and it stays fixed. The user can also run shell commands with a \`!\` prefix — those run in a separate shell that may sit elsewhere, and don't change your working directory.
49
+
50
+ ashi's own source lives at ${ASHI_ROOT}. Read it when the user asks how the TUI works, or wants to change how it looks or behaves:
51
+ - ${path.join(ASHI_ROOT, "README.md")} — what ashi is and how rendering decouples into swappable render extensions
52
+ - ${path.join(ASHI_ROOT, "EXTENDING.md")} — the render-extension contract
53
+ - ${path.join(ASHI_ROOT, "src")} — the frontend, session capture/resume, and the pi-tui renderer`;
54
+
55
+ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean; renderer?: string } {
38
56
  let model: string | undefined;
39
57
  let apiKey: string | undefined;
40
58
  let baseURL: string | undefined;
41
59
  let provider: string | undefined;
42
60
  let backend: string | undefined;
61
+ let renderer: string | undefined;
43
62
  let continueLast = false;
44
63
  const extensions: string[] = [];
45
64
 
@@ -50,6 +69,7 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continu
50
69
  else if (a === "--base-url" && argv[i + 1]) baseURL = argv[++i];
51
70
  else if (a === "--provider" && argv[i + 1]) provider = argv[++i];
52
71
  else if (a === "--backend" && argv[i + 1]) backend = argv[++i];
72
+ else if (a === "--renderer" && argv[i + 1]) renderer = argv[++i];
53
73
  else if ((a === "-e" || a === "--extensions") && argv[i + 1]) {
54
74
  extensions.push(...argv[++i]!.split(",").map(s => s.trim()).filter(Boolean));
55
75
  } else if (a === "-c" || a === "--continue") {
@@ -60,7 +80,7 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continu
60
80
  }
61
81
  }
62
82
 
63
- return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions, continueLast };
83
+ return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, renderer, extensions, continueLast };
64
84
  }
65
85
 
66
86
  const MANAGEMENT_HELP = `ashi — ash (agent-sh's built-in agent) in an interactive TUI
@@ -76,9 +96,12 @@ Management:
76
96
 
77
97
  Launch (default):
78
98
  ashi [--provider <name>] [--model <id>] [--api-key <key>] [--base-url <url>]
79
- [--backend <name>] [-e <ext>[,<ext>...]] [-c | --continue]
99
+ [--backend <name>] [--renderer <name>] [-e <ext>[,<ext>...]] [-c | --continue]
80
100
 
81
101
  -c, --continue Resume the last session in this cwd (fresh session if none)
102
+ --renderer TUI renderer (default: pi-tui). Also ASHI_RENDERER, or
103
+ ashi.renderer in settings.json. A renderer is an extension;
104
+ install it (or -e it) so its ashi:renderer:<name> is registered.
82
105
 
83
106
  Reads ~/.agent-sh/settings.json for providers and defaults.`;
84
107
 
@@ -139,7 +162,11 @@ async function main(): Promise<void> {
139
162
  let stopFrontend: (() => void) | null = null;
140
163
 
141
164
  let shellRef: { kill(): void } | null = null;
142
- const cleanup = (): void => {
165
+ let captureRef: Capture | null = null;
166
+ const cleanup = async (): Promise<void> => {
167
+ // The per-turn flush is fire-and-forget; await it so a quick exit can't drop
168
+ // a just-completed turn.
169
+ try { await captureRef?.flush(); } catch {}
143
170
  try { stopFrontend?.(); } catch {}
144
171
  try { shellRef?.kill(); } catch {}
145
172
  try { core.kill(); } catch {}
@@ -153,7 +180,7 @@ async function main(): Promise<void> {
153
180
 
154
181
  activateAgent(ctx);
155
182
  activateShellContext(ctx);
156
- await loadBuiltinExtensions(ctx, ["rolling-history"]);
183
+ const builtinExtensions = await loadBuiltinExtensions(ctx);
157
184
 
158
185
  const shell = new Shell({
159
186
  bus: core.bus,
@@ -167,6 +194,17 @@ async function main(): Promise<void> {
167
194
  });
168
195
  shellRef = shell;
169
196
 
197
+ let terminalBuffer: TerminalBuffer | null | undefined;
198
+ ctx.define("terminal-buffer", (): TerminalBuffer | null => {
199
+ if (terminalBuffer !== undefined) return terminalBuffer;
200
+ try {
201
+ terminalBuffer = TerminalBuffer.createWired(core.bus);
202
+ } catch {
203
+ terminalBuffer = null;
204
+ }
205
+ return terminalBuffer;
206
+ });
207
+
170
208
  const loaded = await loadExtensions(ctx, config.extensions);
171
209
  core.bus.emit("core:extensions-loaded", { names: loaded });
172
210
 
@@ -179,29 +217,59 @@ async function main(): Promise<void> {
179
217
  }
180
218
 
181
219
  const capture = registerCapture(ctx, getStore);
220
+ captureRef = capture;
182
221
  registerCompaction(ctx, getStore, capture);
183
- registerRenderDefaults(ctx);
222
+
223
+ // Renderers are extensions; selection is --renderer > ASHI_RENDERER >
224
+ // ashi.renderer (settings) > pi-tui.
225
+ ctx.define("ashi:renderer:pi-tui", () => createPiTuiRenderer());
226
+ const rendererName = (
227
+ config.renderer ?? process.env.ASHI_RENDERER ?? loadRendererPreference() ?? "pi-tui"
228
+ ).trim();
229
+ const rendererKey = `ashi:renderer:${rendererName}`;
230
+ if (!ctx.list().includes(rendererKey)) {
231
+ process.stderr.write(
232
+ `ashi: no renderer registered for "${rendererName}" (${rendererKey}). ` +
233
+ `Install the extension that provides it (or pass -e <ext>) so it registers ${rendererKey}.\n`,
234
+ );
235
+ process.exit(1);
236
+ }
237
+ const renderer = ctx.call(rendererKey) as Renderer;
238
+ applyOutputMode(renderer.capabilities.rawOutput);
239
+ registerRenderDefaults(ctx, renderer);
184
240
  registerDefaultSchemaRenderers(ctx);
185
241
 
186
- ctx.advise("system-prompt:build", (next) => `${next()}\n\n<cwd>${process.cwd()}</cwd>`);
242
+ ctx.advise("system-prompt:frontend", (next) => {
243
+ const base = (next() as string) ?? "";
244
+ return base ? `${base}\n\n${ASHI_SURFACE}` : ASHI_SURFACE;
245
+ });
187
246
 
188
- const handle = mountAshi(ctx, getStore, capture);
247
+ const handle = mountAshi(ctx, getStore, capture, renderer);
189
248
  stopFrontend = handle.stop;
190
249
 
250
+ core.bus.emit("ashi:ready", {});
251
+
191
252
  registerForkCommands(ctx, getStore, handle.openTreePicker, handle.rebuildChat, capture);
192
253
  registerSessionCommands(ctx, getStore, capture, {
193
254
  openSessionPicker: handle.openSessionPicker,
194
255
  rebuildChat: handle.rebuildChat,
195
256
  });
196
257
 
258
+ await core.activateBackend(config.backend ?? getSettings().defaultBackend);
259
+
197
260
  if (resumeId) {
261
+ // After activateBackend: conversation:replace-messages is a no-op until the agent backend exists.
198
262
  applyBranchMessages(ctx, getStore, capture);
199
263
  await handle.rebuildChat();
200
264
  ctx.bus.emit("ui:info", { message: `continued session ${resumeId.slice(0, 12)}…` });
265
+ } else {
266
+ // New-session only: skip on resume so a restored transcript isn't prefixed with this.
267
+ const loadedExtensions = [...new Set([...builtinExtensions, ...loaded])];
268
+ if (loadedExtensions.length > 0) {
269
+ ctx.bus.emit("ui:info", { message: `extensions: ${loadedExtensions.join(" · ")}` });
270
+ }
201
271
  }
202
272
 
203
- await core.activateBackend(config.backend ?? getSettings().defaultBackend);
204
-
205
273
  process.on("SIGTERM", cleanup);
206
274
  process.on("SIGHUP", cleanup);
207
275
  }
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { readFile, unlink } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const exec = promisify(execFile);
8
+
9
+ export interface CapturedImage {
10
+ /** Base64-encoded image data (no data: URL prefix). */
11
+ data: string;
12
+ mimeType: string;
13
+ }
14
+
15
+ let counter = 0;
16
+
17
+ /** Terminals don't deliver image bytes through paste, so we read the macOS
18
+ * pasteboard directly via osascript. Null when the clipboard holds no image. */
19
+ export async function readClipboardImage(): Promise<CapturedImage | null> {
20
+ if (process.platform !== "darwin") return null;
21
+ const tmp = join(tmpdir(), `ashi-clip-${process.pid}-${counter++}.png`);
22
+ try {
23
+ await exec("osascript", [
24
+ "-e", "set png_data to (the clipboard as «class PNGf»)",
25
+ "-e", `set fp to open for access POSIX file ${JSON.stringify(tmp)} with write permission`,
26
+ "-e", "write png_data to fp",
27
+ "-e", "close access fp",
28
+ ], { timeout: 10_000 });
29
+ } catch {
30
+ return null;
31
+ }
32
+ try {
33
+ const buf = await readFile(tmp);
34
+ if (buf.length === 0) return null;
35
+ return { data: buf.toString("base64"), mimeType: "image/png" };
36
+ } catch {
37
+ return null;
38
+ } finally {
39
+ void unlink(tmp).catch(() => {});
40
+ }
41
+ }
@@ -40,7 +40,17 @@ export function applyBranchMessages(
40
40
  capture: Capture,
41
41
  ): void {
42
42
  const store = getStore().current();
43
- ctx.call("conversation:replace-messages", store.buildMessages());
43
+ const messages = store.buildMessages();
44
+ ctx.call("conversation:replace-messages", messages);
45
+
46
+ // replace-messages no-ops until the agent backend is active; seeding then desyncs
47
+ // capture's baseline against an empty conversation and silently drops later turns.
48
+ const live = ctx.call("conversation:get-messages") as unknown[] | undefined;
49
+ if ((live?.length ?? 0) !== messages.length) {
50
+ throw new Error(
51
+ `applyBranchMessages: conversation not seeded (live ${live?.length ?? 0} vs ${messages.length}); call after the agent backend is active`,
52
+ );
53
+ }
44
54
 
45
55
  const branch = store.getBranch();
46
56
  let compaction: { firstKeptId: string } | null = null;
@@ -0,0 +1,67 @@
1
+ import type { App, Renderer } from "./renderer.js";
2
+ import { InfoLine } from "./chat/lines.js";
3
+
4
+ export interface SelectChoice {
5
+ value: string;
6
+ label: string;
7
+ description?: string;
8
+ }
9
+ export interface SelectOpts {
10
+ title?: string;
11
+ items: SelectChoice[];
12
+ }
13
+ export interface ConfirmOpts {
14
+ title: string;
15
+ }
16
+
17
+ export interface DialogGuard {
18
+ isOpen(): boolean;
19
+ setOpen(open: boolean): void;
20
+ }
21
+
22
+ export interface Dialogs {
23
+ select(opts: SelectOpts): Promise<string | undefined>;
24
+ confirm(opts: ConfirmOpts): Promise<boolean>;
25
+ }
26
+
27
+ export function createDialogs(app: App, renderer: Renderer, guard: DialogGuard): Dialogs {
28
+ const select = (opts: SelectOpts): Promise<string | undefined> => {
29
+ if (guard.isOpen() || opts.items.length === 0) return Promise.resolve(undefined);
30
+ return new Promise((resolve) => {
31
+ const hint = new InfoLine(renderer, opts.title ?? "↑↓ move · enter: select · esc: cancel");
32
+ const picker = app.createSelectList(
33
+ opts.items.map((c) => ({ value: c.value, label: c.label, description: c.description })),
34
+ { visibleRows: Math.min(15, Math.max(1, opts.items.length)) },
35
+ );
36
+ let settled = false;
37
+ const close = (result?: string): void => {
38
+ if (settled) return;
39
+ settled = true;
40
+ guard.setOpen(false);
41
+ app.footerSlot.removeChild(picker.node);
42
+ app.footerSlot.removeChild(hint.node);
43
+ app.focusInput();
44
+ app.requestRender();
45
+ resolve(result);
46
+ };
47
+ picker.onSelect((item) => close(item.value));
48
+ picker.onCancel(() => close());
49
+ guard.setOpen(true);
50
+ app.footerSlot.addChild(hint.node);
51
+ app.footerSlot.addChild(picker.node);
52
+ app.setFocus(picker.node);
53
+ app.requestRender();
54
+ });
55
+ };
56
+
57
+ const confirm = (opts: ConfirmOpts): Promise<boolean> =>
58
+ select({
59
+ title: opts.title,
60
+ items: [
61
+ { value: "yes", label: "Yes" },
62
+ { value: "no", label: "No" },
63
+ ],
64
+ }).then((v) => v === "yes");
65
+
66
+ return { select, confirm };
67
+ }