agent-sh 0.14.11 → 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 (64) hide show
  1. package/README.md +38 -42
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +104 -136
  4. package/dist/agent/events.d.ts +8 -11
  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 +38 -22
  8. package/dist/agent/providers/deepseek.js +9 -1
  9. package/dist/agent/session-store.js +1 -1
  10. package/dist/agent/system-prompt.d.ts +7 -3
  11. package/dist/agent/system-prompt.js +11 -14
  12. package/dist/agent/tool-protocol.js +0 -7
  13. package/dist/cli/args.js +2 -1
  14. package/dist/cli/install.d.ts +1 -0
  15. package/dist/cli/install.js +29 -1
  16. package/dist/cli/subcommands.js +1 -0
  17. package/dist/core/event-bus.js +0 -2
  18. package/dist/core/extension-loader.js +3 -1
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +3 -2
  21. package/dist/extensions/slash-commands/index.js +16 -11
  22. package/dist/shell/index.js +9 -0
  23. package/dist/shell/shell-context.d.ts +2 -2
  24. package/dist/shell/shell-context.js +26 -11
  25. package/dist/shell/tui-renderer.js +0 -1
  26. package/dist/utils/diff-renderer.js +2 -9
  27. package/dist/utils/handler-registry.d.ts +1 -6
  28. package/dist/utils/handler-registry.js +1 -6
  29. package/dist/utils/line-editor.js +0 -2
  30. package/dist/utils/palette.js +4 -4
  31. package/dist/utils/terminal-buffer.d.ts +2 -0
  32. package/dist/utils/terminal-buffer.js +4 -0
  33. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  34. package/examples/extensions/ash-scheme/index.ts +104 -74
  35. package/examples/extensions/ashi/EXTENDING.md +2 -0
  36. package/examples/extensions/ashi/README.md +17 -1
  37. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  38. package/examples/extensions/ashi/package.json +9 -1
  39. package/examples/extensions/ashi/src/capture.ts +45 -7
  40. package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
  41. package/examples/extensions/ashi/src/chat/lines.ts +20 -1
  42. package/examples/extensions/ashi/src/cli.ts +25 -3
  43. package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
  44. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  45. package/examples/extensions/ashi/src/display-config.ts +7 -0
  46. package/examples/extensions/ashi/src/docks.ts +31 -0
  47. package/examples/extensions/ashi/src/events.ts +16 -0
  48. package/examples/extensions/ashi/src/frontend.ts +134 -27
  49. package/examples/extensions/ashi/src/hooks.ts +6 -12
  50. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  51. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
  52. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
  53. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
  54. package/examples/extensions/ashi/src/schema.ts +3 -0
  55. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  56. package/examples/extensions/ashi/src/status-footer.ts +21 -3
  57. package/examples/extensions/ashi/src/ui.ts +88 -0
  58. package/examples/extensions/ashi-ink/README.md +2 -0
  59. package/examples/extensions/ashi-scheme-render.ts +8 -2
  60. package/examples/extensions/ashi-ui-demo.ts +63 -0
  61. package/examples/extensions/latex-images.ts +57 -9
  62. package/examples/extensions/overlay-agent.ts +5 -5
  63. package/examples/extensions/pi-bridge/index.ts +7 -12
  64. package/package.json +1 -1
@@ -0,0 +1,163 @@
1
+ # ashi UI surfaces
2
+
3
+ How an extension drives ashi's terminal UI — post a notice, add a status-bar segment, pin a
4
+ widget above the input, or ask the user a question.
5
+
6
+ ashi doesn't hand extensions a `ui` object. Instead it answers a small set of **bus events**
7
+ and **named handlers**, so the same extension degrades gracefully under a frontend that doesn't
8
+ implement a given surface (and under headless/RPC use). There are three shapes:
9
+
10
+ - **Notices** are fire-and-forget bus events (`ctx.bus.emit`).
11
+ - **Status segments and docks** are *pull* pipes (`ctx.bus.onPipe`): ashi asks for contributions
12
+ while it repaints and you append yours — ashi owns the layout.
13
+ - **Dialogs** are request/response calls (`await ctx.call(...)`) that resolve when the user answers.
14
+
15
+ ## Setup
16
+
17
+ For typed events, import the augmentation once (types only — no runtime cost):
18
+
19
+ ```ts
20
+ import "@guanyilun/ashi/events";
21
+ ```
22
+
23
+ `ui:*` names are meant to work under any ashi-compatible frontend; `ashi:*` names are specific to
24
+ ashi's terminal UI. Before a `ctx.call(...)` that must return a value, feature-detect:
25
+
26
+ ```ts
27
+ if (ctx.list().includes("ui:select")) { /* … */ }
28
+ ```
29
+
30
+ Bus emits (`ui:notify`, the `*:invalidate` nudges) need no detection — they're no-ops when nothing
31
+ is listening.
32
+
33
+ ## Timing
34
+
35
+ Extensions load before ashi mounts, so the surfaces aren't all live at `activate()` time:
36
+
37
+ - **Pull contributors (`status`, `dock`, any `onPipe`) can be registered any time** — ashi reads them
38
+ on its next repaint, so registering during `activate()` is fine; the first paint picks them up.
39
+ - **Imperative surfaces (notify, dialogs, editor) are ready once ashi has mounted.** From a command
40
+ or key handler they're always ready. To use one at startup, wait for the `ashi:ready` event:
41
+
42
+ ```ts
43
+ ctx.bus.on("ashi:ready", () => createUi(ctx).notify("loaded"));
44
+ ```
45
+
46
+ The helper degrades (`select`/`input` → `undefined`, `confirm` → `false`) if called before a frontend
47
+ answers, so a premature call can't throw; a premature `notify` is simply dropped.
48
+
49
+ ## Post a notice
50
+
51
+ ```ts
52
+ ctx.bus.emit("ui:notify", { message: "Saved.", level: "success" });
53
+ // level: "info" (default) | "warn" | "error" | "success"
54
+ ```
55
+
56
+ Appends a themed line to the transcript.
57
+
58
+ ## Add a status-bar segment
59
+
60
+ Contribute to the footer; ashi appends your segment and owns placement:
61
+
62
+ ```ts
63
+ ctx.bus.onPipe("ui:status", (p) => ({
64
+ segments: [...p.segments, { id: "build", text: "✓ build ok", color: "success" }],
65
+ }));
66
+ ```
67
+
68
+ A segment is `{ id: string; text: string; color?: ThemeColor }`, where `ThemeColor` is a theme name
69
+ such as `"accent"`, `"success"`, `"warning"`, `"error"`, or `"muted"`. When your data changes
70
+ outside a repaint, ask ashi to re-pull:
71
+
72
+ ```ts
73
+ ctx.bus.emit("ui:status:invalidate", {});
74
+ ```
75
+
76
+ ## Pin a widget above the input
77
+
78
+ Same pull model, but you build the view from the renderer's node factory (so you never import a
79
+ TUI library):
80
+
81
+ ```ts
82
+ ctx.bus.onPipe("ashi:dock:above-input", (p) => {
83
+ const line = p.nodes.text({ paddingX: 1 });
84
+ line.setText("📌 2 todos remaining");
85
+ return { ...p, views: [...p.views, line.node] };
86
+ });
87
+
88
+ // after a change:
89
+ ctx.bus.emit("ashi:dock:invalidate", {});
90
+ ```
91
+
92
+ `p.nodes` offers `text`, `markdown`, `container`, `spacer`, and `image`. Return the payload
93
+ unchanged to contribute nothing — the dock takes zero space when empty.
94
+
95
+ ## Ask the user
96
+
97
+ ```ts
98
+ const fruit = await ctx.call("ui:select", {
99
+ title: "Pick a fruit",
100
+ items: [
101
+ { value: "apple", label: "Apple", description: "crisp" },
102
+ { value: "banana", label: "Banana" },
103
+ ],
104
+ }); // → the chosen value, or undefined if cancelled
105
+
106
+ const ok = await ctx.call("ui:confirm", { title: "Delete it?" }); // → boolean
107
+
108
+ const name = await ctx.call("ui:input", {
109
+ title: "Name?", // hint shown above the input
110
+ prefill: "untitled", // optional starting text
111
+ }); // → the text, or undefined if cancelled (Esc)
112
+ ```
113
+
114
+ Only one dialog (or built-in picker) is open at a time; a call made while one is open resolves
115
+ `undefined`.
116
+
117
+ ## Read or seed the input
118
+
119
+ ```ts
120
+ const draft = ctx.call("ui:editor:get-text") as string;
121
+ ctx.call("ui:editor:set-text", "/commit ");
122
+ ```
123
+
124
+ ## Typed helper
125
+
126
+ If you're willing to depend on `@guanyilun/ashi`, `createUi(ctx)` wraps everything above with
127
+ full types and no magic strings. Request/response surfaces also degrade on their own — `select`
128
+ and `input` resolve `undefined`, `confirm` resolves `false` — when no frontend answers:
129
+
130
+ ```ts
131
+ import { createUi } from "@guanyilun/ashi/ui";
132
+
133
+ const ui = createUi(ctx);
134
+ ui.notify("Saved.", "success");
135
+ const fruit = await ui.select({ title: "Pick", items: [{ value: "a", label: "Apple" }] });
136
+ const seg = ui.status(() => ({ id: "build", text: "✓ ok", color: "success" })); // seg.refresh() / seg.remove()
137
+ const widget = ui.dock((nodes) => {
138
+ const t = nodes.text({ paddingX: 1 });
139
+ t.setText("📌 note");
140
+ return t.node;
141
+ });
142
+ ```
143
+
144
+ The raw events and calls above carry no build-time dependency on ashi — reach for them if you
145
+ want a dependency-free extension. The helper is the same protocol with types and degradation
146
+ bolted on.
147
+
148
+ ## Not yet available
149
+
150
+ Floating/overlay panels and fully custom interactive components aren't exposed — the renderer
151
+ contract has no free-placement layer yet. Use the dock, dialogs, and notices above.
152
+
153
+ ## Working example
154
+
155
+ [`ashi-ui-demo.ts`](../../ashi-ui-demo.ts) exercises every surface through the typed helper. Load
156
+ it and try the commands:
157
+
158
+ ```
159
+ ashi -e ashi-ui-demo
160
+ /ui-demo # select → confirm → input, then a notice
161
+ /ui-demo-bump # update the status segment
162
+ /ui-demo-dock # toggle the pinned widget
163
+ ```
@@ -16,6 +16,14 @@
16
16
  "./renderer": {
17
17
  "types": "./dist/renderer.d.ts",
18
18
  "import": "./dist/renderer.js"
19
+ },
20
+ "./events": {
21
+ "types": "./dist/events.d.ts",
22
+ "import": "./dist/events.js"
23
+ },
24
+ "./ui": {
25
+ "types": "./dist/ui.d.ts",
26
+ "import": "./dist/ui.js"
19
27
  }
20
28
  },
21
29
  "files": [
@@ -60,7 +68,7 @@
60
68
  },
61
69
  "dependencies": {
62
70
  "@earendil-works/pi-tui": "^0.74.0",
63
- "agent-sh": "^0.14.10",
71
+ "agent-sh": "^0.14.11",
64
72
  "chalk": "^5.5.0",
65
73
  "cli-highlight": "^2.1.11"
66
74
  },
@@ -2,6 +2,9 @@ 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
+ interface DiffEntry { diff: unknown; filePath: string }
6
+ export interface NestedDiff extends DiffEntry { name: string }
7
+
5
8
  // liveEntryIds is parallel to the live messages array; null slots are synthetics (e.g. compaction summaries) with no entry.
6
9
  export interface Capture {
7
10
  flush(): Promise<void>;
@@ -14,21 +17,56 @@ export function registerCapture(
14
17
  getStore: () => MultiSessionStore,
15
18
  ): Capture {
16
19
  let liveEntryIds: (string | null)[] = [];
17
- 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
+ });
18
39
 
19
40
  ctx.bus.on("agent:tool-completed", (e) => {
20
41
  const id = e.toolCallId;
21
- const body = e.resultDisplay?.body;
22
- if (id && body?.kind === "diff") {
23
- 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;
24
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 });
25
57
  });
26
58
 
27
59
  const enrich = (m: AgentMessage): AgentMessage => {
28
60
  if (m.role !== "tool" || !m.tool_call_id) return m;
29
- const meta = diffMeta.get(m.tool_call_id);
30
- if (!meta) return m;
31
- 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 };
32
70
  };
33
71
 
34
72
  const writeNewMessages = async (): Promise<void> => {
@@ -1,23 +1,11 @@
1
1
  import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
2
2
 
3
- export type EquationRenderer = (latexSrc: string) => RenderNode | null;
3
+ export type RenderBlock =
4
+ | { type: "text"; text: string }
5
+ | { type: "code-block"; language: string; code: string }
6
+ | { type: "image"; data: Buffer };
4
7
 
5
- type LatexSegment = { type: "text"; value: string } | { type: "latex"; value: string };
6
-
7
- function segmentLatex(text: string): LatexSegment[] {
8
- const segments: LatexSegment[] = [];
9
- let i = 0;
10
- while (i < text.length) {
11
- const open = text.indexOf("$$", i);
12
- if (open === -1) { segments.push({ type: "text", value: text.slice(i) }); break; }
13
- const close = text.indexOf("$$", open + 2);
14
- if (close === -1) { segments.push({ type: "text", value: text.slice(i) }); break; }
15
- if (open > i) segments.push({ type: "text", value: text.slice(i, open) });
16
- segments.push({ type: "latex", value: text.slice(open + 2, close).trim() });
17
- i = close + 2;
18
- }
19
- return segments;
20
- }
8
+ export type ContentTransform = (blocks: RenderBlock[]) => RenderBlock[];
21
9
 
22
10
  export class AssistantMessage {
23
11
  readonly node: RenderNode;
@@ -25,7 +13,7 @@ export class AssistantMessage {
25
13
  private md: MarkdownView;
26
14
  private buffer = "";
27
15
 
28
- constructor(private nodes: RenderNodes, private renderEquation?: EquationRenderer) {
16
+ constructor(private nodes: RenderNodes, private transform: ContentTransform = (b) => b) {
29
17
  this.container = nodes.container();
30
18
  this.md = nodes.markdown({ paddingX: 1, bullet: true });
31
19
  this.container.addChild(nodes.spacer(1));
@@ -46,37 +34,29 @@ export class AssistantMessage {
46
34
 
47
35
  finalize(): void {
48
36
  if (this.buffer === "") this.buffer = " ";
49
- if (this.renderEquation && this.buffer.includes("$$")) {
50
- this.rebuildWithEquations();
51
- } else {
37
+ const blocks = this.transform([{ type: "text", text: this.buffer }]);
38
+ if (blocks.every((b) => b.type === "text")) {
52
39
  this.md.setText(this.buffer);
40
+ return;
53
41
  }
42
+ this.rebuild(blocks);
54
43
  }
55
44
 
56
- private rebuildWithEquations(): void {
57
- const segments = segmentLatex(this.buffer);
58
- if (!segments.some((s) => s.type === "latex")) {
59
- this.md.setText(this.buffer);
60
- return;
61
- }
45
+ private rebuild(blocks: RenderBlock[]): void {
62
46
  this.container.clear();
63
47
  this.container.addChild(this.nodes.spacer(1));
64
- for (const seg of segments) {
65
- if (seg.type === "text") {
66
- if (seg.value.trim()) {
67
- const m = this.nodes.markdown({ paddingX: 1 });
68
- m.setText(seg.value);
69
- this.container.addChild(m.node);
70
- }
71
- } else {
72
- const eq = this.renderEquation!(seg.value);
73
- if (eq) {
74
- this.container.addChild(eq);
75
- } else {
76
- const m = this.nodes.markdown({ paddingX: 1 });
77
- m.setText(`$$${seg.value}$$`);
78
- this.container.addChild(m.node);
79
- }
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);
80
60
  }
81
61
  }
82
62
  }
@@ -1,5 +1,5 @@
1
1
  import type { RenderNode, RenderNodes } from "../renderer.js";
2
- import { theme } from "../theme.js";
2
+ import { theme, type ThemeColor } from "../theme.js";
3
3
 
4
4
  export class InfoLine {
5
5
  readonly node: RenderNode;
@@ -18,3 +18,22 @@ export class ErrorLine {
18
18
  this.node = t.node;
19
19
  }
20
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
+ }
@@ -24,6 +24,7 @@ function headlessTerminal(): Terminal {
24
24
  };
25
25
  }
26
26
 
27
+ import "./events.js";
27
28
  import { mountAshi } from "./frontend.js";
28
29
  import { MultiSessionStore } from "./multi-session-store.js";
29
30
  import { registerForkCommands, applyBranchMessages } from "./commands.js";
@@ -38,6 +39,18 @@ import { loadRendererPreference } from "./display-config.js";
38
39
  import { applyOutputMode } from "./terminal-mode.js";
39
40
  import * as os from "node:os";
40
41
  import * as path from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+
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`;
41
54
 
42
55
  function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean; renderer?: string } {
43
56
  let model: string | undefined;
@@ -167,7 +180,7 @@ async function main(): Promise<void> {
167
180
 
168
181
  activateAgent(ctx);
169
182
  activateShellContext(ctx);
170
- await loadBuiltinExtensions(ctx, ["rolling-history"]);
183
+ const builtinExtensions = await loadBuiltinExtensions(ctx);
171
184
 
172
185
  const shell = new Shell({
173
186
  bus: core.bus,
@@ -226,12 +239,15 @@ async function main(): Promise<void> {
226
239
  registerRenderDefaults(ctx, renderer);
227
240
  registerDefaultSchemaRenderers(ctx);
228
241
 
229
- 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
+ });
230
246
 
231
247
  const handle = mountAshi(ctx, getStore, capture, renderer);
232
248
  stopFrontend = handle.stop;
233
249
 
234
- (core.bus.emit as (event: string, payload: unknown) => void)("ashi:ready", {});
250
+ core.bus.emit("ashi:ready", {});
235
251
 
236
252
  registerForkCommands(ctx, getStore, handle.openTreePicker, handle.rebuildChat, capture);
237
253
  registerSessionCommands(ctx, getStore, capture, {
@@ -246,6 +262,12 @@ async function main(): Promise<void> {
246
262
  applyBranchMessages(ctx, getStore, capture);
247
263
  await handle.rebuildChat();
248
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
+ }
249
271
  }
250
272
 
251
273
  process.on("SIGTERM", cleanup);
@@ -25,7 +25,7 @@ export async function readClipboardImage(): Promise<CapturedImage | null> {
25
25
  "-e", `set fp to open for access POSIX file ${JSON.stringify(tmp)} with write permission`,
26
26
  "-e", "write png_data to fp",
27
27
  "-e", "close access fp",
28
- ]);
28
+ ], { timeout: 10_000 });
29
29
  } catch {
30
30
  return null;
31
31
  }
@@ -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
+ }
@@ -31,6 +31,7 @@ interface AshiSettings extends Record<string, unknown> {
31
31
  display?: Record<string, Partial<ToolEntryConfig>>;
32
32
  groupMaxVisible?: number;
33
33
  renderer?: string;
34
+ imageScale?: number;
34
35
  }
35
36
 
36
37
  export function loadRendererPreference(): string | undefined {
@@ -45,6 +46,12 @@ export function loadGroupMaxVisible(): number {
45
46
  return Math.floor(v);
46
47
  }
47
48
 
49
+ export function loadImageScale(fallback: number): number {
50
+ const v = getExtensionSettings<AshiSettings>("ashi", {}).imageScale;
51
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return fallback;
52
+ return v;
53
+ }
54
+
48
55
  function mergeEntry(base: ToolEntryConfig, patch?: Partial<ToolEntryConfig>): ToolEntryConfig {
49
56
  if (!patch) return { ...base };
50
57
  return {
@@ -0,0 +1,31 @@
1
+ import type { EventBus } from "agent-sh/event-bus";
2
+ import type { App, Renderer } from "./renderer.js";
3
+
4
+ export interface Dock {
5
+ refresh(): void;
6
+ }
7
+
8
+ export function createDock(app: App, renderer: Renderer, bus: EventBus): Dock {
9
+ const container = renderer.container();
10
+ let mounted = false;
11
+
12
+ const refresh = (): void => {
13
+ container.clear();
14
+ const { views } = bus.emitPipe("ashi:dock:above-input", { nodes: renderer, views: [] });
15
+ for (const view of views) container.addChild(view);
16
+ // Mount only when non-empty: an always-present footer child (even empty) defeats the
17
+ // footer slot's blank-line spacing above the input.
18
+ if (views.length > 0 && !mounted) {
19
+ app.footerSlot.addChild(container.node);
20
+ mounted = true;
21
+ } else if (views.length === 0 && mounted) {
22
+ app.footerSlot.removeChild(container.node);
23
+ mounted = false;
24
+ }
25
+ app.requestRender();
26
+ };
27
+
28
+ bus.on("ashi:dock:invalidate", refresh);
29
+ refresh();
30
+ return { refresh };
31
+ }
@@ -0,0 +1,16 @@
1
+ // ui:* names are neutral by intent but declared HERE in ashi, not core, on purpose: a name
2
+ // graduates to core's BusEvents only when a second frontend outside ashi speaks it
3
+ // (see docs/ui-surface-protocol.md). ashi:* names carry TUI vocabulary and stay ashi-owned.
4
+ import type { RenderNode, RenderNodes } from "./renderer.js";
5
+ import type { StatusSegment } from "./status-footer.js";
6
+
7
+ declare module "agent-sh/event-bus" {
8
+ interface BusEvents {
9
+ "ui:notify": { message: string; level?: "info" | "warn" | "error" | "success" };
10
+ "ui:status": { segments: StatusSegment[] };
11
+ "ui:status:invalidate": Record<string, never>;
12
+ "ashi:dock:above-input": { nodes: RenderNodes; views: RenderNode[] };
13
+ "ashi:dock:invalidate": Record<string, never>;
14
+ "ashi:ready": Record<string, never>;
15
+ }
16
+ }