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
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "dependencies": {
8
- "@jcubic/lips": "^0.20.3",
8
+ "@jcubic/lips": "1.0.0-beta.20",
9
9
  "agent-sh": "^0.14.0"
10
10
  }
11
11
  }
@@ -0,0 +1,118 @@
1
+ # Extending ashi
2
+
3
+ Other extensions can customize how chat entries and tool results render — and even swap the whole TUI renderer — without forking ashi. For non-render concerns (commands, settings, tools, providers) use the standard `agent-sh` extension API; see the [agent-sh extension docs](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
4
+
5
+ To **drive** the UI from an extension — post a notice, add a status segment, pin a dock widget, or open a select/confirm/input dialog — use the UI-surface protocol (bus events + named handlers, no `ctx.ui` object); see [`docs/ui-surface-protocol.md`](docs/ui-surface-protocol.md) and the worked example `examples/extensions/ashi-ui-demo.ts`.
6
+
7
+ ## Chat hooks
8
+
9
+ These return a renderer-agnostic chat-entry view built from the active renderer's
10
+ node factories (`args.nodes`), so an override never imports a concrete TUI library:
11
+
12
+ | Hook | Args | Returns |
13
+ |---|---|---|
14
+ | `ashi:render-user-message` | `{ text, nodes, state, invalidate }` | `{ node }` |
15
+ | `ashi:render-assistant` | `{ text, nodes, state, invalidate }` | `{ node, appendText, appendCodeBlock, finalize, hasContent }` |
16
+ | `ashi:render-thinking` | `{ text, hidden, nodes, state, invalidate }` | `{ node, appendText, finalize, setHidden }` |
17
+
18
+ `nodes` is a `RenderNodes` (`text` / `markdown` / `image` / `container` / `spacer`); `src/chat/` holds the default controllers.
19
+
20
+ ## Tool hooks — declarative render schema
21
+
22
+ Tool rendering uses a declarative schema so extensions don't import pi-tui or touch ashi internals. Register a `RenderModel` under `ashi:render-tool:{name}` (with `:default` as the fallback):
23
+
24
+ ```ts
25
+ import type { RenderModel, ToolDisplay } from "@guanyilun/ashi/render";
26
+
27
+ const myModel: RenderModel<{ command: string }> = {
28
+ initial: ({ rawInput }) => ({ command: JSON.parse(String(rawInput)).command ?? "" }),
29
+ view: (s): ToolDisplay => ({
30
+ title: [
31
+ { text: "$ ", style: { bold: true, color: "toolTitle" } },
32
+ { text: s.command, highlight: "bash" },
33
+ ],
34
+ status: s.status,
35
+ body: { kind: "stream", text: s.output },
36
+ expandable: true,
37
+ }),
38
+ };
39
+
40
+ export default function activate(ctx) {
41
+ ctx.define("ashi:render-tool:bash", () => myModel);
42
+ }
43
+ ```
44
+
45
+ `view(state, env)` is a pure function returning a `ToolDisplay`. Ashi owns the mapping to the active renderer, theming, streaming buffer policy (preview / summary / hidden modes from `ashi.display`), diff width memoization, and the Ctrl+O expand toggle. The framework auto-tracks `state.status`, `state.output` (streaming chunks), and `state.hasDiff` (for edit/write) — renderers read these without wiring their own reducers.
46
+
47
+ `ToolDisplay` body kinds: `text`, `code` (with syntax highlighting via `lang`), `stream` (preview/summary/hidden policy applied by ashi), `diff` (closure pushed by the frontend orchestrator), `lines`, `compound`. Custom state transitions can be declared via an optional `reducers` map.
48
+
49
+ To ship a default display policy with your renderer (e.g. "this tool's output is large, default to `summary`"), set `display` on the model. User `settings.json` still wins:
50
+
51
+ ```ts
52
+ const myModel: RenderModel<...> = {
53
+ initial, view,
54
+ display: { result: "summary", previewLines: 3 },
55
+ };
56
+ ```
57
+
58
+ ## Renderers
59
+
60
+ The whole TUI is swappable. ashi (the substrate) depends only on the `Renderer`
61
+ contract from [`@guanyilun/ashi/renderer`](src/renderer.ts) — the schema, theme,
62
+ chat controllers, and frontend never import a concrete TUI library. The built-in
63
+ renderer is pi-tui (`src/renderers/pi-tui`).
64
+
65
+ A renderer is just an extension that registers `ashi:renderer:<name>`:
66
+
67
+ ```ts
68
+ import type { Renderer } from "@guanyilun/ashi/renderer";
69
+
70
+ function createMyRenderer(): Renderer { /* … */ }
71
+
72
+ export default function activate(ctx) {
73
+ ctx.define("ashi:renderer:my-tui", () => createMyRenderer());
74
+ }
75
+ ```
76
+
77
+ **Loading vs. selecting are separate.** A renderer must be *loaded* (so its
78
+ `ashi:renderer:<name>` is registered) and then *selected* by name:
79
+
80
+ - **Load** — `ashi install my-tui-renderer` (installed extensions auto-load every
81
+ launch), or `-e my-tui-renderer` to load from source during development.
82
+ - **Select** — `--renderer my-tui` (flag) **>** `ASHI_RENDERER=my-tui` (env) **>**
83
+ `ashi.renderer` in `settings.json` (persistent preference) **>** `pi-tui`
84
+ (default). An unknown name errors rather than silently falling back.
85
+
86
+ So a persistent setup is `ashi install my-tui-renderer` once + `"ashi": { "renderer":
87
+ "my-tui" }` in settings — no per-command flags. The contract has two halves —
88
+ content-node factories (`text` / `markdown` / `image` / `container` / `spacer`) and
89
+ an app shell (`mount()` → scrollback / footer / queue / input / `belowInput` / status,
90
+ plus select lists, loader, and key events) — together with `mountToolCall` / `mountToolResult`
91
+ and a `capabilities` list so a renderer can declare gaps and the substrate degrades
92
+ rather than crashes. This is how you build a different TUI frontend (Ink, a
93
+ remote/web bridge…) without forking ashi.
94
+
95
+ Tool calls follow the same rule: the renderer owns the look. Same-kind runs of
96
+ `read`/`search` are collapsed by a substrate `ToolGroup` controller that owns only
97
+ the *state* (tail-merge, eviction, expand) and hands the renderer a neutral
98
+ `ToolGroupModel` to draw via the optional `mountToolGroup()`. The substrate ships
99
+ a default rendering — `renderToolGroupLines(model)`, the `├`/`└` tree — that a
100
+ renderer can mount as-is (both pi-tui and Ink do) or ignore and draw the model
101
+ however it likes. Grouping is a presentation policy, not a mandate: a renderer
102
+ that omits `mountToolGroup` opts out entirely, and the substrate renders those
103
+ calls individually through the schema mount.
104
+
105
+ Autocomplete works the same way: the substrate owns the popup and mounts the
106
+ suggestion list in the `belowInput` slot; the renderer only draws it, so the
107
+ slash-command / `@`-file popup behaves identically in every renderer.
108
+
109
+ The substrate also owns terminal setup so renderers don't each rediscover it.
110
+ agent-sh's shell clears OPOST on boot (pi-tui emits its own `\r`); ashi reads
111
+ `capabilities.rawOutput` and restores OPOST for renderers that emit lone `\n`
112
+ (Ink and most libraries — the default), so they don't staircase. A new renderer
113
+ gets the conventional terminal for free; only a raw driver like pi-tui sets
114
+ `rawOutput: true`.
115
+
116
+ See [`examples/extensions/ashi-ink`](../ashi-ink) for a worked example — a **working**
117
+ Ink (React) renderer (`ASHI_RENDERER=ink ashi -e ashi-ink`), verified with
118
+ `ink-testing-library`.
@@ -7,6 +7,14 @@
7
7
 
8
8
  Same backend, tools, slash commands, providers, and skills as `agent-sh`, mounted in a chat-style interface with session history, branching, and LLM-driven compaction.
9
9
 
10
+ Rendering is **decoupled** — even *how* ashi draws tool calls and results is a swappable render extension. Same agent, same conversation; load a different render extension and the whole TUI restyles, no code changes:
11
+
12
+ | pi-style rendering | claude-code-style rendering |
13
+ |---|---|
14
+ | ![ashi rendering tool calls pi-style](https://raw.githubusercontent.com/guanyilun/agent-sh/main/assets/ashi-pi-style.png) | ![ashi rendering tool calls claude-code-style](https://raw.githubusercontent.com/guanyilun/agent-sh/main/assets/ashi-claude-code-style.png) |
15
+
16
+ The claude-code-style renderer is [ashi-ink](../ashi-ink), a working Ink (React) renderer; see [Extending ashi](#extending-ashi) for the contract.
17
+
10
18
  ## Install
11
19
 
12
20
  ```bash
@@ -73,6 +81,8 @@ Ctrl+O Expand/collapse all tool calls and results in chat
73
81
 
74
82
  The current thinking level is shown in the footer as `[level]` next to the model name.
75
83
 
84
+ Typing `/` (commands) or `@` (files) opens a suggestion popup: ↑/↓ to move, Tab or Enter to accept, Esc to dismiss.
85
+
76
86
  ## Sessions
77
87
 
78
88
  Many sessions per cwd, fresh by default:
@@ -122,7 +132,7 @@ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
122
132
  {
123
133
  "ashi": {
124
134
  "display": {
125
- "default": { "result": "preview", "previewLines": 5 },
135
+ "default": { "result": "preview", "previewLines": 5, "expandedLines": 200 },
126
136
  "read": { "result": "hidden" },
127
137
  "ls": { "result": "hidden" },
128
138
  "grep": { "result": "summary" },
@@ -142,63 +152,17 @@ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
142
152
 
143
153
  For `edit_file` / `write_file`, the diff frame is treated as the output and follows the same gating: shown for `preview`, hidden for `hidden`/`summary` (the call line already carries `+12 -3` stats). The line-count hint is suppressed for diff-producing tools so edits stay quiet.
144
154
 
145
- Hit `Ctrl+O` to toggle expansion across all tool entries in chat — result bodies show their full output regardless of mode, and call lines with truncated labels (e.g. long `bash` commands) reveal their full text. Press again to collapse.
155
+ Hit `Ctrl+O` to toggle expansion across all tool entries in chat — result bodies show their output regardless of mode, and call lines with truncated labels (e.g. long `bash` commands) reveal their full text. Press again to collapse. Expanded output is still tail-capped to `expandedLines` (default 200) so `Ctrl+O` on a huge result can't flood the scrollback — the rest shows as a `… (N earlier lines hidden)` note. The agent always receives the full output; only the on-screen display is bounded.
146
156
 
147
157
  Each tool inherits from `default` and is overridden by its own block. Unknown tool names fall through to `default`.
148
158
 
149
- ## Extension surface
150
-
151
- Other extensions can customize how chat entries and tool results render without forking ashi.
152
-
153
- ### Chat hooks
154
-
155
- These return [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) components directly:
159
+ ## Extending ashi
156
160
 
157
- | Hook | Args | Returns |
158
- |---|---|---|
159
- | `ashi:render-user-message` | `{ text, state, invalidate }` | `Component` |
160
- | `ashi:render-assistant` | `{ text, state, invalidate }` | `Component` |
161
- | `ashi:render-thinking` | `{ text, hidden, state, invalidate }` | `Component` |
162
-
163
- ### Tool hooks — declarative render schema
164
-
165
- Tool rendering uses a declarative schema so extensions don't import pi-tui or touch ashi internals. Register a `RenderModel` under `ashi:render-tool:{name}` (with `:default` as the fallback):
166
-
167
- ```ts
168
- import type { RenderModel, ToolDisplay } from "@guanyilun/ashi/render";
169
-
170
- const myModel: RenderModel<{ command: string }> = {
171
- initial: ({ rawInput }) => ({ command: JSON.parse(String(rawInput)).command ?? "" }),
172
- view: (s): ToolDisplay => ({
173
- title: [
174
- { text: "$ ", style: { bold: true, color: "toolTitle" } },
175
- { text: s.command, highlight: "bash" },
176
- ],
177
- status: s.status,
178
- body: { kind: "stream", text: s.output },
179
- expandable: true,
180
- }),
181
- };
182
-
183
- export default function activate(ctx) {
184
- ctx.define("ashi:render-tool:bash", () => myModel);
185
- }
186
- ```
187
-
188
- `view(state, env)` is a pure function returning a `ToolDisplay`. Ashi owns the pi-tui mapping, theming, streaming buffer policy (preview / summary / hidden modes from `ashi.display`), diff width memoization, and the Ctrl+O expand toggle. The framework auto-tracks `state.status`, `state.output` (streaming chunks), and `state.hasDiff` (for edit/write) — renderers read these without wiring their own reducers.
189
-
190
- `ToolDisplay` body kinds: `text`, `code` (with syntax highlighting via `lang`), `stream` (preview/summary/hidden policy applied by ashi), `diff` (closure pushed by the frontend orchestrator), `lines`, `compound`. Custom state transitions can be declared via an optional `reducers` map.
191
-
192
- To ship a default display policy with your renderer (e.g. "this tool's output is large, default to `summary`"), set `display` on the model. User `settings.json` still wins:
193
-
194
- ```ts
195
- const myModel: RenderModel<...> = {
196
- initial, view,
197
- display: { result: "summary", previewLines: 3 },
198
- };
199
- ```
200
-
201
- For non-render concerns (commands, settings, tools, providers) use the standard `agent-sh` extension API. See the [agent-sh extension docs](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
161
+ Other extensions can customize chat and tool-result rendering — and even swap the whole
162
+ TUI renderer (pi-tui, [Ink](../ashi-ink), …) — without forking ashi. See **[EXTENDING.md](EXTENDING.md)**
163
+ for the chat/tool render hooks, the declarative tool render schema, and the renderer
164
+ contract. For non-render concerns (commands, settings, tools, providers), use the
165
+ standard [agent-sh extension API](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
202
166
 
203
167
  ## Install from source
204
168
 
@@ -211,6 +175,14 @@ export PATH="$HOME/.agent-sh/bin:$PATH"
211
175
 
212
176
  `agent-sh install` runs `npm install` and `npm run build` in the copied directory and symlinks the built bin into `~/.agent-sh/bin/`.
213
177
 
178
+ By default the copy pulls the **published** `agent-sh` from npm. When ashi's source depends on an unreleased core — a kernel change you haven't published yet — add `--dev` so the install links against the checkout you're running instead:
179
+
180
+ ```bash
181
+ agent-sh install ashi --dev --force
182
+ ```
183
+
184
+ `--dev` repoints the copied package's `agent-sh` dependency at the running host's checkout (the one the global `agent-sh` is linked to). The build then sees the local types, and a later `npm run build` at the repo root flows through without reinstalling — the kernel rebuild rule from [Development](#development) applies. Re-run the install to refresh ashi's own dist after frontend changes.
185
+
214
186
  ## Development
215
187
 
216
188
  `@guanyilun/ashi` depends on the published `agent-sh` package. To iterate against a local checkout, use `npm link`:
@@ -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
+ ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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",
@@ -12,6 +12,18 @@
12
12
  "./render": {
13
13
  "types": "./dist/schema.d.ts",
14
14
  "import": "./dist/schema.js"
15
+ },
16
+ "./renderer": {
17
+ "types": "./dist/renderer.d.ts",
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"
15
27
  }
16
28
  },
17
29
  "files": [
@@ -56,7 +68,7 @@
56
68
  },
57
69
  "dependencies": {
58
70
  "@earendil-works/pi-tui": "^0.74.0",
59
- "agent-sh": "^0.14.8",
71
+ "agent-sh": "^0.14.11",
60
72
  "chalk": "^5.5.0",
61
73
  "cli-highlight": "^2.1.11"
62
74
  },
@@ -0,0 +1,95 @@
1
+ import type {
2
+ App,
3
+ AutocompleteItem,
4
+ AutocompleteProvider,
5
+ InputView,
6
+ KeyEvent,
7
+ SelectView,
8
+ } from "./renderer.js";
9
+
10
+ export interface AutocompleteController {
11
+ /** Re-query suggestions for the current input. Call from the input's onChange. */
12
+ refresh(): void;
13
+ }
14
+
15
+ const VISIBLE_ROWS = 8;
16
+
17
+ /**
18
+ * Substrate-owned autocomplete: the renderer only draws a SelectView in `belowInput`, so
19
+ * the popup behaves identically in any renderer instead of living inside one editor.
20
+ */
21
+ export function createAutocompleteController(opts: {
22
+ app: App;
23
+ input: InputView;
24
+ provider: AutocompleteProvider;
25
+ suppressed: () => boolean;
26
+ }): AutocompleteController {
27
+ const { app, input, provider, suppressed } = opts;
28
+ let view: SelectView | null = null;
29
+ let items: AutocompleteItem[] = [];
30
+ let prefix = "";
31
+ let index = 0;
32
+ let reqSeq = 0;
33
+ let applying = false;
34
+
35
+ const clear = (): void => {
36
+ if (!view) return;
37
+ app.belowInput.clear();
38
+ view = null;
39
+ items = [];
40
+ };
41
+
42
+ const refresh = (): void => {
43
+ if (applying) return;
44
+ if (suppressed()) { clear(); app.requestRender(); return; }
45
+ const lines = input.getText().split("\n");
46
+ const { line, col } = input.getCursor();
47
+ const seq = ++reqSeq;
48
+ void Promise.resolve(provider.getSuggestions(lines, line, col)).then((result) => {
49
+ if (seq !== reqSeq || applying) return;
50
+ if (!result || result.items.length === 0) { clear(); app.requestRender(); return; }
51
+ items = result.items;
52
+ prefix = result.prefix;
53
+ index = Math.max(0, Math.min(index, items.length - 1));
54
+ app.belowInput.clear();
55
+ view = app.createSelectList(
56
+ items.map((it) => ({ value: it.value, label: it.label, description: it.description })),
57
+ { visibleRows: VISIBLE_ROWS },
58
+ );
59
+ view.setSelectedIndex(index);
60
+ app.belowInput.addChild(view.node);
61
+ app.requestRender();
62
+ });
63
+ };
64
+
65
+ // Enter applies and closes. Tab applies and, for `@`/path completions, re-queries so
66
+ // you can keep completing deeper — slash commands close (else an exact command would
67
+ // re-match itself and the list would never dismiss). `applying` swallows the onChange
68
+ // that replaceBeforeCursor fires mid-apply.
69
+ const apply = (allowReopen: boolean): void => {
70
+ const chosen = items[index];
71
+ if (!chosen) return;
72
+ const slash = prefix.startsWith("/");
73
+ applying = true;
74
+ input.replaceBeforeCursor(prefix.length, chosen.value);
75
+ clear();
76
+ applying = false;
77
+ if (allowReopen && !slash) refresh();
78
+ app.requestRender();
79
+ };
80
+
81
+ app.onKey((key: KeyEvent): { consume: boolean } | void => {
82
+ if (key.isRelease() || key.isRepeat()) return;
83
+ if (!view || items.length === 0) {
84
+ if (key.matches("tab")) { refresh(); return { consume: true }; }
85
+ return;
86
+ }
87
+ if (key.matches("up")) { index = (index - 1 + items.length) % items.length; view.setSelectedIndex(index); app.requestRender(); return { consume: true }; }
88
+ if (key.matches("down")) { index = (index + 1) % items.length; view.setSelectedIndex(index); app.requestRender(); return { consume: true }; }
89
+ if (key.matches("return")) { apply(false); return { consume: true }; }
90
+ if (key.matches("tab")) { apply(true); return { consume: true }; }
91
+ if (key.matches("escape")) { clear(); app.requestRender(); return { consume: true }; }
92
+ });
93
+
94
+ return { refresh };
95
+ }
@@ -2,7 +2,7 @@ import type {
2
2
  AutocompleteItem,
3
3
  AutocompleteProvider,
4
4
  AutocompleteSuggestions,
5
- } from "@earendil-works/pi-tui";
5
+ } from "./renderer.js";
6
6
  import type { EventBus } from "agent-sh/event-bus";
7
7
 
8
8
  /** Adapt pi-tui's AutocompleteProvider to agent-sh's autocomplete:request pipe.
@@ -54,28 +54,6 @@ export class BusAutocompleteProvider implements AutocompleteProvider {
54
54
  }));
55
55
  return { items, prefix: before };
56
56
  }
57
-
58
- applyCompletion(
59
- lines: string[],
60
- cursorLine: number,
61
- cursorCol: number,
62
- item: AutocompleteItem,
63
- prefix: string,
64
- ): { lines: string[]; cursorLine: number; cursorCol: number } {
65
- const line = lines[cursorLine] ?? "";
66
- // Replace the prefix span (the leading slash-word + args we sent) with
67
- // the completion value. Anything after the cursor is preserved.
68
- const head = line.slice(0, cursorCol - prefix.length);
69
- const tail = line.slice(cursorCol);
70
- const newLine = `${head}${item.value}${tail}`;
71
- const out = lines.slice();
72
- out[cursorLine] = newLine;
73
- return {
74
- lines: out,
75
- cursorLine,
76
- cursorCol: (head + item.value).length,
77
- };
78
- }
79
57
  }
80
58
 
81
59
  /** Locate an active `@` file-trigger in the text preceding the cursor.