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.
- package/README.md +36 -13
- package/dist/agent/agent-loop.d.ts +9 -17
- package/dist/agent/agent-loop.js +123 -150
- package/dist/agent/events.d.ts +10 -12
- package/dist/agent/host-types.d.ts +17 -11
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +76 -29
- package/dist/agent/live-view.d.ts +3 -3
- package/dist/agent/live-view.js +15 -7
- package/dist/agent/providers/deepseek.js +9 -1
- package/dist/agent/providers/openrouter.js +9 -0
- package/dist/agent/session-store.js +1 -1
- package/dist/agent/subagent.js +1 -1
- package/dist/agent/system-prompt.d.ts +7 -3
- package/dist/agent/system-prompt.js +11 -14
- package/dist/agent/tool-protocol.js +0 -7
- package/dist/cli/args.js +2 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +39 -2
- package/dist/cli/subcommands.js +1 -0
- package/dist/core/event-bus.js +0 -2
- package/dist/core/extension-loader.js +3 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +3 -2
- package/dist/extensions/slash-commands/index.js +16 -11
- package/dist/shell/events.d.ts +3 -0
- package/dist/shell/index.js +9 -0
- package/dist/shell/shell-context.d.ts +2 -2
- package/dist/shell/shell-context.js +26 -11
- package/dist/shell/shell.js +3 -0
- package/dist/shell/tui-renderer.js +0 -1
- package/dist/utils/diff-renderer.d.ts +4 -0
- package/dist/utils/diff-renderer.js +15 -27
- package/dist/utils/handler-registry.d.ts +1 -6
- package/dist/utils/handler-registry.js +1 -6
- package/dist/utils/line-editor.js +0 -2
- package/dist/utils/palette.js +4 -4
- package/dist/utils/terminal-buffer.d.ts +2 -0
- package/dist/utils/terminal-buffer.js +4 -0
- package/examples/extensions/ads/SKILL.md +170 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
- package/examples/extensions/ash-scheme/index.ts +377 -687
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +118 -0
- package/examples/extensions/ashi/README.md +26 -54
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
- package/examples/extensions/ashi/package.json +14 -2
- package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
- package/examples/extensions/ashi/src/autocomplete.ts +1 -23
- package/examples/extensions/ashi/src/capture.ts +54 -10
- package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
- package/examples/extensions/ashi/src/chat/lines.ts +39 -0
- package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
- package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
- package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
- package/examples/extensions/ashi/src/cli.ts +80 -12
- package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
- package/examples/extensions/ashi/src/commands.ts +11 -1
- package/examples/extensions/ashi/src/dialogs.ts +67 -0
- package/examples/extensions/ashi/src/display-config.ts +16 -1
- package/examples/extensions/ashi/src/docks.ts +31 -0
- package/examples/extensions/ashi/src/events.ts +16 -0
- package/examples/extensions/ashi/src/frontend.ts +456 -268
- package/examples/extensions/ashi/src/hooks.ts +27 -40
- package/examples/extensions/ashi/src/input-prompt.ts +64 -0
- package/examples/extensions/ashi/src/renderer.ts +222 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
- package/examples/extensions/ashi/src/schema.ts +46 -205
- package/examples/extensions/ashi/src/session-commands.ts +2 -1
- package/examples/extensions/ashi/src/status-footer.ts +35 -25
- package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
- package/examples/extensions/ashi/src/theme.ts +1 -47
- package/examples/extensions/ashi/src/ui.ts +88 -0
- package/examples/extensions/ashi-ink/README.md +61 -0
- package/examples/extensions/ashi-ink/package.json +30 -0
- package/examples/extensions/ashi-ink/src/index.ts +6 -0
- package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
- package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
- package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
- package/examples/extensions/ashi-ink/tsconfig.json +14 -0
- package/examples/extensions/ashi-scheme-render.ts +10 -10
- package/examples/extensions/ashi-shell-passthrough.ts +95 -0
- package/examples/extensions/ashi-ui-demo.ts +63 -0
- package/examples/extensions/latex-images.ts +70 -19
- package/examples/extensions/overlay-agent.ts +5 -5
- package/examples/extensions/pi-bridge/index.ts +7 -12
- package/examples/extensions/terminal-buffer.ts +4 -2
- package/package.json +3 -9
- package/examples/extensions/ashi/src/components.ts +0 -238
- package/examples/extensions/ollama.ts +0 -108
- package/examples/extensions/opencode-provider.ts +0 -251
- package/examples/extensions/zai-coding-plan.ts +0 -35
|
@@ -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
|
+
|  |  |
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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.
|
|
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 "
|
|
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.
|