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,88 @@
1
+ import type { ExtensionContext } from "agent-sh/types";
2
+ import type { RenderNode, RenderNodes } from "./renderer.js";
3
+ import type { StatusSegment } from "./status-footer.js";
4
+ import type { SelectOpts, ConfirmOpts } from "./dialogs.js";
5
+ import type { InputOpts } from "./input-prompt.js";
6
+
7
+ export type { SelectChoice, SelectOpts, ConfirmOpts } from "./dialogs.js";
8
+ export type { InputOpts } from "./input-prompt.js";
9
+ export type { StatusSegment } from "./status-footer.js";
10
+
11
+ export type NoticeLevel = "info" | "warn" | "error" | "success";
12
+
13
+ export interface Contribution {
14
+ /** Re-pull this surface (call after the content it depends on changes). */
15
+ refresh(): void;
16
+ remove(): void;
17
+ }
18
+
19
+ /**
20
+ * Typed sugar over ashi's UI protocol. Wraps the bus events and named handlers so call
21
+ * sites read like a UI object, with real types and no magic strings — without a `ui` field
22
+ * on the kernel context. Request/response surfaces degrade when no frontend answers them
23
+ * (select/input → undefined, confirm → false, getEditorText → "").
24
+ */
25
+ export interface Ui {
26
+ notify(message: string, level?: NoticeLevel): void;
27
+ select(opts: SelectOpts): Promise<string | undefined>;
28
+ confirm(opts: ConfirmOpts): Promise<boolean>;
29
+ input(opts?: InputOpts): Promise<string | undefined>;
30
+ getEditorText(): string;
31
+ setEditorText(text: string): void;
32
+ /** Contribute a status-bar segment. `get` runs on each repaint; return null to show nothing. */
33
+ status(get: () => StatusSegment | null): Contribution;
34
+ /** Contribute a pinned widget above the input, built from the renderer's node factory. */
35
+ dock(build: (nodes: RenderNodes) => RenderNode | null): Contribution;
36
+ }
37
+
38
+ export function createUi(ctx: ExtensionContext): Ui {
39
+ const has = (name: string): boolean => ctx.list().includes(name);
40
+
41
+ return {
42
+ notify(message, level) {
43
+ ctx.bus.emit("ui:notify", { message, level });
44
+ },
45
+ select(opts) {
46
+ if (!has("ui:select")) return Promise.resolve(undefined);
47
+ return ctx.call("ui:select", opts) as Promise<string | undefined>;
48
+ },
49
+ confirm(opts) {
50
+ if (!has("ui:confirm")) return Promise.resolve(false);
51
+ return ctx.call("ui:confirm", opts) as Promise<boolean>;
52
+ },
53
+ input(opts = {}) {
54
+ if (!has("ui:input")) return Promise.resolve(undefined);
55
+ return ctx.call("ui:input", opts) as Promise<string | undefined>;
56
+ },
57
+ getEditorText() {
58
+ return has("ui:editor:get-text") ? (ctx.call("ui:editor:get-text") as string) : "";
59
+ },
60
+ setEditorText(text) {
61
+ if (has("ui:editor:set-text")) ctx.call("ui:editor:set-text", text);
62
+ },
63
+ status(get) {
64
+ const contribute = (p: { segments: StatusSegment[] }): { segments: StatusSegment[] } => {
65
+ const seg = get();
66
+ return seg ? { segments: [...p.segments, seg] } : p;
67
+ };
68
+ ctx.bus.onPipe("ui:status", contribute);
69
+ return {
70
+ refresh: () => ctx.bus.emit("ui:status:invalidate", {}),
71
+ remove: () => ctx.bus.offPipe("ui:status", contribute),
72
+ };
73
+ },
74
+ dock(build) {
75
+ const contribute = (
76
+ p: { nodes: RenderNodes; views: RenderNode[] },
77
+ ): { nodes: RenderNodes; views: RenderNode[] } => {
78
+ const view = build(p.nodes);
79
+ return view ? { ...p, views: [...p.views, view] } : p;
80
+ };
81
+ ctx.bus.onPipe("ashi:dock:above-input", contribute);
82
+ return {
83
+ refresh: () => ctx.bus.emit("ashi:dock:invalidate", {}),
84
+ remove: () => ctx.bus.offPipe("ashi:dock:above-input", contribute),
85
+ };
86
+ },
87
+ };
88
+ }
@@ -10,6 +10,8 @@ The look follows Claude Code's chat design: a `❯` user prompt on a faint band,
10
10
  read & search groups that collapse to `Read N files`, and box-less diffs with a
11
11
  line-numbered green/red gutter.
12
12
 
13
+ ![ashi-ink rendering tool calls, claude-code-style](https://raw.githubusercontent.com/guanyilun/agent-sh/main/assets/ashi-claude-code-style.png)
14
+
13
15
  > Requires `@guanyilun/ashi` ≥ 0.2.0.
14
16
 
15
17
  ## Use
@@ -29,16 +29,22 @@ const model: RenderModel<SchemeInit> = {
29
29
  { text: "scheme ", style: { bold: true, color: "toolTitle" } },
30
30
  { text: env.expanded ? s.source : compact(s.source), highlight: "scheme" },
31
31
  ];
32
+ const summaryOnResult = env.expanded && env.finalized && !failed && !!s.status;
32
33
  return {
33
34
  titleIcon: "scheme",
34
35
  title,
35
36
  status: s.status,
36
- // Title carries the source; body shows the eval result, not an echo of it.
37
+ hideTitleStatus: summaryOnResult,
37
38
  body: failed
38
39
  ? { kind: "text", segments: [
39
40
  { text: `✗ ${s.output.trim()}`, style: { color: "error" } },
40
41
  ] }
41
- : { kind: "stream", text: s.output },
42
+ : summaryOnResult
43
+ ? { kind: "text", segments: [
44
+ { text: "✓", style: { color: "success" } },
45
+ { text: ` ${s.status!.summary ?? "ok"}`, style: { color: "muted" } },
46
+ ] }
47
+ : { kind: "stream", text: s.output },
42
48
  expandable: true,
43
49
  defaultExpanded: failed,
44
50
  };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ashi UI-surface demo — exercises every surface via the typed `@guanyilun/ashi/ui` helper.
3
+ *
4
+ * Usage: ashi -e ashi-ui-demo (or -e examples/extensions/ashi-ui-demo.ts)
5
+ *
6
+ * Adds a pinned dock line and a status segment, plus commands:
7
+ * /ui-demo walk the dialogs (select → confirm → input) + notify
8
+ * /ui-demo-bump bump the status segment and re-pull the footer
9
+ * /ui-demo-dock toggle the dock widget and re-pull the dock
10
+ */
11
+ import { createUi } from "@guanyilun/ashi/ui";
12
+ import type { ExtensionContext } from "agent-sh/types";
13
+
14
+ export default function activate(ctx: ExtensionContext): void {
15
+ const ui = createUi(ctx);
16
+ let bumps = 0;
17
+ let dockOn = true;
18
+
19
+ const status = ui.status(() => ({ id: "demo", text: `✦ demo ${bumps}`, color: "accent" }));
20
+ const dock = ui.dock((nodes) => {
21
+ if (!dockOn) return null;
22
+ const line = nodes.text({ paddingX: 1 });
23
+ line.setText("📌 ui-demo: a pinned dock widget");
24
+ return line.node;
25
+ });
26
+
27
+ ctx.registerCommand("ui-demo-bump", "Bump the demo status segment", () => {
28
+ bumps++;
29
+ status.refresh();
30
+ });
31
+
32
+ ctx.registerCommand("ui-demo-dock", "Toggle the demo dock widget", () => {
33
+ dockOn = !dockOn;
34
+ dock.refresh();
35
+ });
36
+
37
+ ctx.registerCommand("ui-demo", "Walk the ashi UI dialogs (select/confirm/input)", async () => {
38
+ ui.notify("ui-demo: starting…");
39
+
40
+ const fruit = await ui.select({
41
+ title: "Pick a fruit",
42
+ items: [
43
+ { value: "apple", label: "Apple", description: "crisp" },
44
+ { value: "banana", label: "Banana", description: "soft" },
45
+ { value: "cherry", label: "Cherry" },
46
+ ],
47
+ });
48
+ if (!fruit) {
49
+ ui.notify("ui-demo: cancelled", "warn");
50
+ return;
51
+ }
52
+
53
+ if (!(await ui.confirm({ title: `Really pick ${fruit}?` }))) {
54
+ ui.notify("ui-demo: not confirmed", "warn");
55
+ return;
56
+ }
57
+
58
+ const note = await ui.input({ title: "Add a note — enter to submit, esc to skip" });
59
+ const summary = `picked ${fruit}${note ? ` — ${note}` : ""}`;
60
+ ui.setEditorText(`/* ${summary} */`);
61
+ ui.notify(`ui-demo: ${summary}`, "success");
62
+ });
63
+ }
@@ -96,6 +96,42 @@ function renderEquation(equation: string): Buffer | null {
96
96
  }
97
97
  }
98
98
 
99
+ const equationCache = new Map<string, Buffer | null>();
100
+ function renderEquationCached(equation: string): Buffer | null {
101
+ if (!equationCache.has(equation)) {
102
+ equationCache.set(equation, renderEquation(equation));
103
+ }
104
+ return equationCache.get(equation) ?? null;
105
+ }
106
+
107
+ const EQ_DELIM = "$$";
108
+
109
+ type Block =
110
+ | { type: "text"; text: string }
111
+ | { type: "image"; data: Buffer }
112
+ | { type: "code-block"; language: string; code: string };
113
+ type ContentPipe = { blocks: Block[]; images?: boolean };
114
+
115
+ function splitEquations(text: string): Block[] {
116
+ const out: Block[] = [];
117
+ let i = 0;
118
+ while (i < text.length) {
119
+ const open = text.indexOf(EQ_DELIM, i);
120
+ if (open === -1) {
121
+ const rest = text.slice(i);
122
+ if (rest) out.push({ type: "text", text: rest });
123
+ break;
124
+ }
125
+ const close = text.indexOf(EQ_DELIM, open + EQ_DELIM.length);
126
+ if (close === -1) { out.push({ type: "text", text: text.slice(i) }); break; }
127
+ if (open > i) out.push({ type: "text", text: text.slice(i, open) });
128
+ const png = renderEquationCached(text.slice(open + EQ_DELIM.length, close).trim());
129
+ out.push(png ? { type: "image", data: png } : { type: "text", text: text.slice(open, close + EQ_DELIM.length) });
130
+ i = close + EQ_DELIM.length;
131
+ }
132
+ return out;
133
+ }
134
+
99
135
  // ── Extension entry point ────────────────────────────────────────
100
136
 
101
137
  export default function activate(ctx: ExtensionContext) {
@@ -115,26 +151,38 @@ export default function activate(ctx: ExtensionContext) {
115
151
  return;
116
152
  }
117
153
 
118
- ctx.define("latex:render-equation", (equation: string): Buffer | null => renderEquation(equation));
154
+ ctx.define("latex:render-equation", (equation: string): Buffer | null => renderEquationCached(equation));
119
155
 
120
- // Shell-only — ashi has no shell surface; it uses the capability above instead.
121
156
  if (ctx.shell) {
157
+ // Shell streams output, so it buffers $$ spanning chunks; finalized-content
158
+ // renderers (below) get whole blocks instead.
122
159
  ctx.shell.createBlockTransform({
123
- open: "$$",
124
- close: "$$",
125
- transform(latex) {
126
- const png = renderEquation(latex);
127
- if (!png) return null;
128
- return { type: "image", data: png };
160
+ open: EQ_DELIM,
161
+ close: EQ_DELIM,
162
+ transform(latex: string) {
163
+ const png = renderEquationCached(latex);
164
+ return png ? { type: "image" as const, data: png } : null;
129
165
  },
130
166
  });
131
167
 
132
168
  ctx.advise("render:code-block", (next, language: string, code: string, width: number) => {
133
169
  if (language !== "latex" && language !== "tex") return next(language, code, width);
134
- const png = renderEquation(code);
170
+ const png = renderEquationCached(code);
135
171
  if (!png) return next(language, code, width);
136
172
  ctx.call("render:image", png);
137
173
  });
174
+ } else {
175
+ (bus.onPipe as unknown as (e: string, fn: (p: ContentPipe) => ContentPipe) => void)(
176
+ "render:assistant-content",
177
+ (payload) => {
178
+ // Can't show images reliably → leave $$…$$ as text.
179
+ if (!payload.images) return payload;
180
+ return {
181
+ ...payload,
182
+ blocks: payload.blocks.flatMap((b) => (b.type === "text" ? splitEquations(b.text) : [b])),
183
+ };
184
+ },
185
+ );
138
186
  }
139
187
 
140
188
  process.on("exit", () => {
@@ -22,8 +22,8 @@
22
22
  */
23
23
  import type { AgentContext, ShellContext, RemoteSession } from "agent-sh/types";
24
24
  import type { RenderSurface } from "agent-sh/utils/compositor";
25
- import { FloatingPanel } from "agent-sh/utils/floating-panel";
26
- import { formatScreenContext, type TerminalBuffer } from "agent-sh/utils/terminal-buffer";
25
+ import type { FloatingPanel, FloatingPanelConfig } from "agent-sh/utils/floating-panel";
26
+ import type { TerminalBuffer } from "agent-sh/utils/terminal-buffer";
27
27
 
28
28
  /** Adapt a FloatingPanel to the RenderSurface interface. */
29
29
  function createPanelSurface(panel: FloatingPanel): RenderSurface {
@@ -72,11 +72,11 @@ export default function activate(ctx: AgentContext & ShellContext): void {
72
72
  const { createRemoteSession } = ctx.shell;
73
73
  const terminalBuffer: TerminalBuffer | null = ctx.call("terminal-buffer");
74
74
 
75
- const panel = new FloatingPanel(bus, {
75
+ const panel: FloatingPanel = ctx.call("floating-panel:create", {
76
76
  trigger: "\x1c", // Ctrl+\
77
77
  dimBackground: true,
78
78
  terminalBuffer: terminalBuffer ?? undefined,
79
- });
79
+ } satisfies FloatingPanelConfig, bus);
80
80
 
81
81
  const panelSurface = createPanelSurface(panel);
82
82
  let session: RemoteSession | null = null;
@@ -89,7 +89,7 @@ export default function activate(ctx: AgentContext & ShellContext): void {
89
89
  // `<shell_events>` already covers the visible scrollback — skip to dedupe.
90
90
  ctx.agent.registerContextProducer("terminal-screen", () => {
91
91
  if (!session?.active || !terminalBuffer?.altScreen) return null;
92
- return formatScreenContext(terminalBuffer.readScreen(), 80);
92
+ return terminalBuffer.formatScreen(80);
93
93
  });
94
94
 
95
95
  registerInstruction("Interactive Overlay Sessions", [
@@ -331,34 +331,29 @@ export default function activate(ctx: ExtensionContext): void {
331
331
  const all = modelRegistry.getAvailable() as Array<{ id: string; provider: string }>;
332
332
  const cur = session.model;
333
333
  return {
334
- models: all.map((m) => ({ model: m.id, provider: m.provider })),
335
- active: cur ? { model: cur.id, provider: cur.provider } : null,
334
+ models: all.map((m) => ({ id: m.id, provider: m.provider })),
335
+ active: cur ? { id: cur.id, provider: cur.provider } : null,
336
336
  };
337
337
  };
338
338
 
339
- // Slash command emits `model@provider` for disambiguation; pi looks up by (provider, id).
340
- const onSwitchModel = async ({ model: target }: { model: string }) => {
339
+ const onSwitchModel = async ({ id, provider }: { id: string; provider: string }) => {
341
340
  if (!session || !modelRegistry) return;
342
- const atIdx = target.lastIndexOf("@");
343
- const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
344
- const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
345
-
346
341
  const candidates = (modelRegistry.getAvailable() as Array<{ id: string; provider: string }>)
347
- .filter((m) => m.id === modelId && (!providerHint || m.provider === providerHint));
342
+ .filter((m) => m.id === id && (!provider || m.provider === provider));
348
343
 
349
344
  if (candidates.length === 0) {
350
- bus.emit("ui:error", { message: `Unknown model: ${target}` });
345
+ bus.emit("ui:error", { message: `Unknown model: ${provider}:${id}` });
351
346
  return;
352
347
  }
353
348
  if (candidates.length > 1) {
354
349
  const opts = candidates.map((m) => `${m.id}@${m.provider}`).join(", ");
355
- bus.emit("ui:error", { message: `Ambiguous model "${modelId}". Use one of: ${opts}` });
350
+ bus.emit("ui:error", { message: `Ambiguous model "${id}". Use one of: ${opts}` });
356
351
  return;
357
352
  }
358
353
  const picked = candidates[0]!;
359
354
  const full = modelRegistry.find(picked.provider, picked.id);
360
355
  if (!full) {
361
- bus.emit("ui:error", { message: `Model not found: ${target}` });
356
+ bus.emit("ui:error", { message: `Model not found: ${picked.provider}:${picked.id}` });
362
357
  return;
363
358
  }
364
359
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.14.11",
3
+ "version": "0.15.0",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [