agent-sh 0.14.9 → 0.14.11
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 +47 -20
- package/dist/agent/agent-loop.js +20 -15
- package/dist/agent/events.d.ts +2 -1
- package/dist/agent/index.js +44 -7
- package/dist/agent/live-view.d.ts +3 -3
- package/dist/agent/live-view.js +15 -7
- package/dist/agent/providers/ollama.d.ts +11 -0
- package/dist/agent/providers/ollama.js +72 -0
- package/dist/agent/providers/opencode.d.ts +10 -0
- package/dist/agent/providers/opencode.js +112 -0
- package/dist/agent/providers/openrouter.js +9 -0
- package/dist/agent/providers/zai-coding-plan.d.ts +5 -0
- package/dist/agent/providers/zai-coding-plan.js +26 -0
- package/dist/agent/subagent.js +1 -1
- package/dist/cli/args.js +2 -2
- package/dist/cli/install.js +10 -1
- package/dist/shell/events.d.ts +3 -0
- package/dist/shell/shell.js +3 -0
- package/dist/utils/diff-renderer.d.ts +4 -0
- package/dist/utils/diff-renderer.js +15 -20
- package/examples/extensions/ads/SKILL.md +170 -0
- package/examples/extensions/ads/index.ts +695 -0
- package/examples/extensions/ash-scheme/index.ts +339 -605
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +116 -0
- package/examples/extensions/ashi/README.md +10 -54
- package/examples/extensions/ashi/package.json +6 -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 +9 -3
- package/examples/extensions/ashi/src/chat/assistant.ts +87 -0
- package/examples/extensions/ashi/src/chat/lines.ts +20 -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 +58 -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/display-config.ts +9 -1
- package/examples/extensions/ashi/src/frontend.ts +340 -259
- package/examples/extensions/ashi/src/hooks.ts +33 -40
- 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 +23 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +133 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +193 -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 +43 -205
- package/examples/extensions/ashi/src/status-footer.ts +15 -23
- package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
- package/examples/extensions/ashi/src/theme.ts +1 -47
- package/examples/extensions/ashi-ink/README.md +59 -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 +4 -10
- package/examples/extensions/ashi-shell-passthrough.ts +95 -0
- package/examples/extensions/latex-images.ts +22 -19
- 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,116 @@
|
|
|
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
|
+
## Chat hooks
|
|
6
|
+
|
|
7
|
+
These return a renderer-agnostic chat-entry view built from the active renderer's
|
|
8
|
+
node factories (`args.nodes`), so an override never imports a concrete TUI library:
|
|
9
|
+
|
|
10
|
+
| Hook | Args | Returns |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `ashi:render-user-message` | `{ text, nodes, state, invalidate }` | `{ node }` |
|
|
13
|
+
| `ashi:render-assistant` | `{ text, nodes, state, invalidate }` | `{ node, appendText, appendCodeBlock, finalize, hasContent }` |
|
|
14
|
+
| `ashi:render-thinking` | `{ text, hidden, nodes, state, invalidate }` | `{ node, appendText, finalize, setHidden }` |
|
|
15
|
+
|
|
16
|
+
`nodes` is a `RenderNodes` (`text` / `markdown` / `image` / `container` / `spacer`); `src/chat/` holds the default controllers.
|
|
17
|
+
|
|
18
|
+
## Tool hooks — declarative render schema
|
|
19
|
+
|
|
20
|
+
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):
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import type { RenderModel, ToolDisplay } from "@guanyilun/ashi/render";
|
|
24
|
+
|
|
25
|
+
const myModel: RenderModel<{ command: string }> = {
|
|
26
|
+
initial: ({ rawInput }) => ({ command: JSON.parse(String(rawInput)).command ?? "" }),
|
|
27
|
+
view: (s): ToolDisplay => ({
|
|
28
|
+
title: [
|
|
29
|
+
{ text: "$ ", style: { bold: true, color: "toolTitle" } },
|
|
30
|
+
{ text: s.command, highlight: "bash" },
|
|
31
|
+
],
|
|
32
|
+
status: s.status,
|
|
33
|
+
body: { kind: "stream", text: s.output },
|
|
34
|
+
expandable: true,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default function activate(ctx) {
|
|
39
|
+
ctx.define("ashi:render-tool:bash", () => myModel);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`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.
|
|
44
|
+
|
|
45
|
+
`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.
|
|
46
|
+
|
|
47
|
+
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:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const myModel: RenderModel<...> = {
|
|
51
|
+
initial, view,
|
|
52
|
+
display: { result: "summary", previewLines: 3 },
|
|
53
|
+
};
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Renderers
|
|
57
|
+
|
|
58
|
+
The whole TUI is swappable. ashi (the substrate) depends only on the `Renderer`
|
|
59
|
+
contract from [`@guanyilun/ashi/renderer`](src/renderer.ts) — the schema, theme,
|
|
60
|
+
chat controllers, and frontend never import a concrete TUI library. The built-in
|
|
61
|
+
renderer is pi-tui (`src/renderers/pi-tui`).
|
|
62
|
+
|
|
63
|
+
A renderer is just an extension that registers `ashi:renderer:<name>`:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import type { Renderer } from "@guanyilun/ashi/renderer";
|
|
67
|
+
|
|
68
|
+
function createMyRenderer(): Renderer { /* … */ }
|
|
69
|
+
|
|
70
|
+
export default function activate(ctx) {
|
|
71
|
+
ctx.define("ashi:renderer:my-tui", () => createMyRenderer());
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Loading vs. selecting are separate.** A renderer must be *loaded* (so its
|
|
76
|
+
`ashi:renderer:<name>` is registered) and then *selected* by name:
|
|
77
|
+
|
|
78
|
+
- **Load** — `ashi install my-tui-renderer` (installed extensions auto-load every
|
|
79
|
+
launch), or `-e my-tui-renderer` to load from source during development.
|
|
80
|
+
- **Select** — `--renderer my-tui` (flag) **>** `ASHI_RENDERER=my-tui` (env) **>**
|
|
81
|
+
`ashi.renderer` in `settings.json` (persistent preference) **>** `pi-tui`
|
|
82
|
+
(default). An unknown name errors rather than silently falling back.
|
|
83
|
+
|
|
84
|
+
So a persistent setup is `ashi install my-tui-renderer` once + `"ashi": { "renderer":
|
|
85
|
+
"my-tui" }` in settings — no per-command flags. The contract has two halves —
|
|
86
|
+
content-node factories (`text` / `markdown` / `image` / `container` / `spacer`) and
|
|
87
|
+
an app shell (`mount()` → scrollback / footer / queue / input / `belowInput` / status,
|
|
88
|
+
plus select lists, loader, and key events) — together with `mountToolCall` / `mountToolResult`
|
|
89
|
+
and a `capabilities` list so a renderer can declare gaps and the substrate degrades
|
|
90
|
+
rather than crashes. This is how you build a different TUI frontend (Ink, a
|
|
91
|
+
remote/web bridge…) without forking ashi.
|
|
92
|
+
|
|
93
|
+
Tool calls follow the same rule: the renderer owns the look. Same-kind runs of
|
|
94
|
+
`read`/`search` are collapsed by a substrate `ToolGroup` controller that owns only
|
|
95
|
+
the *state* (tail-merge, eviction, expand) and hands the renderer a neutral
|
|
96
|
+
`ToolGroupModel` to draw via the optional `mountToolGroup()`. The substrate ships
|
|
97
|
+
a default rendering — `renderToolGroupLines(model)`, the `├`/`└` tree — that a
|
|
98
|
+
renderer can mount as-is (both pi-tui and Ink do) or ignore and draw the model
|
|
99
|
+
however it likes. Grouping is a presentation policy, not a mandate: a renderer
|
|
100
|
+
that omits `mountToolGroup` opts out entirely, and the substrate renders those
|
|
101
|
+
calls individually through the schema mount.
|
|
102
|
+
|
|
103
|
+
Autocomplete works the same way: the substrate owns the popup and mounts the
|
|
104
|
+
suggestion list in the `belowInput` slot; the renderer only draws it, so the
|
|
105
|
+
slash-command / `@`-file popup behaves identically in every renderer.
|
|
106
|
+
|
|
107
|
+
The substrate also owns terminal setup so renderers don't each rediscover it.
|
|
108
|
+
agent-sh's shell clears OPOST on boot (pi-tui emits its own `\r`); ashi reads
|
|
109
|
+
`capabilities.rawOutput` and restores OPOST for renderers that emit lone `\n`
|
|
110
|
+
(Ink and most libraries — the default), so they don't staircase. A new renderer
|
|
111
|
+
gets the conventional terminal for free; only a raw driver like pi-tui sets
|
|
112
|
+
`rawOutput: true`.
|
|
113
|
+
|
|
114
|
+
See [`examples/extensions/ashi-ink`](../ashi-ink) for a worked example — a **working**
|
|
115
|
+
Ink (React) renderer (`ASHI_RENDERER=ink ashi -e ashi-ink`), verified with
|
|
116
|
+
`ink-testing-library`.
|
|
@@ -73,6 +73,8 @@ Ctrl+O Expand/collapse all tool calls and results in chat
|
|
|
73
73
|
|
|
74
74
|
The current thinking level is shown in the footer as `[level]` next to the model name.
|
|
75
75
|
|
|
76
|
+
Typing `/` (commands) or `@` (files) opens a suggestion popup: ↑/↓ to move, Tab or Enter to accept, Esc to dismiss.
|
|
77
|
+
|
|
76
78
|
## Sessions
|
|
77
79
|
|
|
78
80
|
Many sessions per cwd, fresh by default:
|
|
@@ -122,7 +124,7 @@ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
|
|
|
122
124
|
{
|
|
123
125
|
"ashi": {
|
|
124
126
|
"display": {
|
|
125
|
-
"default": { "result": "preview", "previewLines": 5 },
|
|
127
|
+
"default": { "result": "preview", "previewLines": 5, "expandedLines": 200 },
|
|
126
128
|
"read": { "result": "hidden" },
|
|
127
129
|
"ls": { "result": "hidden" },
|
|
128
130
|
"grep": { "result": "summary" },
|
|
@@ -142,63 +144,17 @@ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
|
|
|
142
144
|
|
|
143
145
|
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
146
|
|
|
145
|
-
Hit `Ctrl+O` to toggle expansion across all tool entries in chat — result bodies show their
|
|
147
|
+
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
148
|
|
|
147
149
|
Each tool inherits from `default` and is overridden by its own block. Unknown tool names fall through to `default`.
|
|
148
150
|
|
|
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:
|
|
156
|
-
|
|
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
|
-
```
|
|
151
|
+
## Extending ashi
|
|
200
152
|
|
|
201
|
-
|
|
153
|
+
Other extensions can customize chat and tool-result rendering — and even swap the whole
|
|
154
|
+
TUI renderer (pi-tui, Ink, …) — without forking ashi. See **[EXTENDING.md](EXTENDING.md)**
|
|
155
|
+
for the chat/tool render hooks, the declarative tool render schema, and the renderer
|
|
156
|
+
contract. For non-render concerns (commands, settings, tools, providers), use the
|
|
157
|
+
standard [agent-sh extension API](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
|
|
202
158
|
|
|
203
159
|
## Install from source
|
|
204
160
|
|
|
@@ -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,10 @@
|
|
|
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"
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
@@ -56,7 +60,7 @@
|
|
|
56
60
|
},
|
|
57
61
|
"dependencies": {
|
|
58
62
|
"@earendil-works/pi-tui": "^0.74.0",
|
|
59
|
-
"agent-sh": "^0.14.
|
|
63
|
+
"agent-sh": "^0.14.10",
|
|
60
64
|
"chalk": "^5.5.0",
|
|
61
65
|
"cli-highlight": "^2.1.11"
|
|
62
66
|
},
|
|
@@ -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.
|
|
@@ -2,8 +2,7 @@ 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
|
-
|
|
6
|
-
* null slots are synthetics like compaction summaries that have no entry. */
|
|
5
|
+
// liveEntryIds is parallel to the live messages array; null slots are synthetics (e.g. compaction summaries) with no entry.
|
|
7
6
|
export interface Capture {
|
|
8
7
|
flush(): Promise<void>;
|
|
9
8
|
getEntryIdAt(messageIndex: number): string | null;
|
|
@@ -32,7 +31,7 @@ export function registerCapture(
|
|
|
32
31
|
return { ...m, meta: { ...m.meta, diff: meta.diff, filePath: meta.filePath } };
|
|
33
32
|
};
|
|
34
33
|
|
|
35
|
-
const
|
|
34
|
+
const writeNewMessages = async (): Promise<void> => {
|
|
36
35
|
const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
|
|
37
36
|
if (!messages || messages.length <= liveEntryIds.length) return;
|
|
38
37
|
const newMessages = messages.slice(liveEntryIds.length).map(enrich);
|
|
@@ -41,6 +40,13 @@ export function registerCapture(
|
|
|
41
40
|
getStore().markLastSession();
|
|
42
41
|
};
|
|
43
42
|
|
|
43
|
+
// Serialize flushes so an exit-time flush can't race processing-done and double-append.
|
|
44
|
+
let chain: Promise<void> = Promise.resolve();
|
|
45
|
+
const flush = (): Promise<void> => {
|
|
46
|
+
chain = chain.then(writeNewMessages, writeNewMessages);
|
|
47
|
+
return chain;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
ctx.bus.on("agent:processing-done", () => { void flush(); });
|
|
45
51
|
|
|
46
52
|
return {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
+
|
|
3
|
+
export type EquationRenderer = (latexSrc: string) => RenderNode | null;
|
|
4
|
+
|
|
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
|
+
}
|
|
21
|
+
|
|
22
|
+
export class AssistantMessage {
|
|
23
|
+
readonly node: RenderNode;
|
|
24
|
+
private container: ContainerView;
|
|
25
|
+
private md: MarkdownView;
|
|
26
|
+
private buffer = "";
|
|
27
|
+
|
|
28
|
+
constructor(private nodes: RenderNodes, private renderEquation?: EquationRenderer) {
|
|
29
|
+
this.container = nodes.container();
|
|
30
|
+
this.md = nodes.markdown({ paddingX: 1, bullet: true });
|
|
31
|
+
this.container.addChild(nodes.spacer(1));
|
|
32
|
+
this.container.addChild(this.md.node);
|
|
33
|
+
this.node = this.container.node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
appendText(t: string): void {
|
|
37
|
+
this.buffer += t;
|
|
38
|
+
this.md.setText(this.buffer);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
appendCodeBlock(language: string, code: string): void {
|
|
42
|
+
const prefix = this.buffer && !this.buffer.endsWith("\n") ? "\n" : "";
|
|
43
|
+
this.buffer += `${prefix}\`\`\`${language}\n${code}\n\`\`\`\n`;
|
|
44
|
+
this.md.setText(this.buffer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
finalize(): void {
|
|
48
|
+
if (this.buffer === "") this.buffer = " ";
|
|
49
|
+
if (this.renderEquation && this.buffer.includes("$$")) {
|
|
50
|
+
this.rebuildWithEquations();
|
|
51
|
+
} else {
|
|
52
|
+
this.md.setText(this.buffer);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
}
|
|
62
|
+
this.container.clear();
|
|
63
|
+
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
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
hasContent(): boolean {
|
|
85
|
+
return this.buffer.trim().length > 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
+
import { theme } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export class InfoLine {
|
|
5
|
+
readonly node: RenderNode;
|
|
6
|
+
constructor(nodes: RenderNodes, message: string) {
|
|
7
|
+
const t = nodes.text({ paddingX: 1 });
|
|
8
|
+
t.setText(theme.fg("muted", message));
|
|
9
|
+
this.node = t.node;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ErrorLine {
|
|
14
|
+
readonly node: RenderNode;
|
|
15
|
+
constructor(nodes: RenderNodes, message: string) {
|
|
16
|
+
const t = nodes.text({ paddingX: 1 });
|
|
17
|
+
t.setText(`${theme.fg("error", "✗")} ${theme.fg("error", message)}`);
|
|
18
|
+
this.node = t.node;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Hidden clears the block but keeps the buffer so a toggle restores it.
|
|
2
|
+
import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
|
|
5
|
+
const thinkingColor = (t: string): string => theme.dim(theme.italic(theme.fg("thinkingText", t)));
|
|
6
|
+
|
|
7
|
+
export class ThinkingBlock {
|
|
8
|
+
readonly node: RenderNode;
|
|
9
|
+
private container: ContainerView;
|
|
10
|
+
private md: MarkdownView;
|
|
11
|
+
private buffer = "";
|
|
12
|
+
private hidden = false;
|
|
13
|
+
|
|
14
|
+
constructor(private nodes: RenderNodes) {
|
|
15
|
+
this.container = nodes.container();
|
|
16
|
+
this.md = nodes.markdown({ paddingX: 1, color: thinkingColor });
|
|
17
|
+
this.container.addChild(nodes.spacer(1));
|
|
18
|
+
this.container.addChild(this.md.node);
|
|
19
|
+
this.node = this.container.node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
appendText(t: string): void {
|
|
23
|
+
this.buffer += t;
|
|
24
|
+
if (!this.hidden) this.md.setText(this.buffer);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
finalize(): void {
|
|
28
|
+
if (this.buffer === "") this.buffer = " ";
|
|
29
|
+
if (!this.hidden) this.md.setText(this.buffer);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setHidden(hidden: boolean): void {
|
|
33
|
+
if (hidden === this.hidden) return;
|
|
34
|
+
this.hidden = hidden;
|
|
35
|
+
this.container.clear();
|
|
36
|
+
if (hidden) return;
|
|
37
|
+
this.container.addChild(this.nodes.spacer(1));
|
|
38
|
+
this.md = this.nodes.markdown({ paddingX: 1, color: thinkingColor });
|
|
39
|
+
this.md.setText(this.buffer);
|
|
40
|
+
this.container.addChild(this.md.node);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Renderer, RenderNode, ToolGroupChild, ToolGroupModel, ToolGroupView } from "../renderer.js";
|
|
2
|
+
|
|
3
|
+
export const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
|
|
4
|
+
|
|
5
|
+
const SHORT_TOOL_NAMES: Record<string, string> = {
|
|
6
|
+
read_file: "read",
|
|
7
|
+
edit_file: "edit",
|
|
8
|
+
write_file: "write",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function shortToolName(name: string): string {
|
|
12
|
+
return SHORT_TOOL_NAMES[name] ?? name;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ToolGroup {
|
|
16
|
+
readonly node: RenderNode;
|
|
17
|
+
readonly kind: string;
|
|
18
|
+
private view: ToolGroupView;
|
|
19
|
+
private maxVisible: number;
|
|
20
|
+
private allChildren: ToolGroupChild[] = [];
|
|
21
|
+
private callsById = new Map<string, ToolGroupChild>();
|
|
22
|
+
private expanded = false;
|
|
23
|
+
private open = true;
|
|
24
|
+
|
|
25
|
+
constructor(renderer: Renderer, kind: string, maxVisible: number = Infinity) {
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.maxVisible = maxVisible;
|
|
28
|
+
this.view = renderer.mountToolGroup!();
|
|
29
|
+
this.node = this.view.node;
|
|
30
|
+
this.repaint();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
addCall(toolCallId: string, name: string, detail: string): void {
|
|
34
|
+
const child: ToolGroupChild = { name: shortToolName(name), detail: detail || "…" };
|
|
35
|
+
if (toolCallId) this.callsById.set(toolCallId, child);
|
|
36
|
+
this.allChildren.push(child);
|
|
37
|
+
this.repaint();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recordCompletion(toolCallId: string, exitCode: number | null, summary?: string): void {
|
|
41
|
+
const child = this.callsById.get(toolCallId);
|
|
42
|
+
if (!child) return;
|
|
43
|
+
child.status = { exitCode, summary };
|
|
44
|
+
this.repaint();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
toggleExpanded(): void {
|
|
48
|
+
this.expanded = !this.expanded;
|
|
49
|
+
this.repaint();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
seal(): void {
|
|
53
|
+
if (!this.open) return;
|
|
54
|
+
this.open = false;
|
|
55
|
+
this.repaint();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// When collapsed and over-cap, one visible slot is reserved for the summary.
|
|
59
|
+
private visibleSliceStart(): number {
|
|
60
|
+
if (this.expanded || !Number.isFinite(this.maxVisible)) return 0;
|
|
61
|
+
if (this.allChildren.length <= this.maxVisible) return 0;
|
|
62
|
+
return this.allChildren.length - (this.maxVisible - 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private repaint(): void {
|
|
66
|
+
const start = this.visibleSliceStart();
|
|
67
|
+
const evicted = this.allChildren.slice(0, start);
|
|
68
|
+
const hidden = evicted.length === 0
|
|
69
|
+
? null
|
|
70
|
+
: {
|
|
71
|
+
count: evicted.length,
|
|
72
|
+
ok: evicted.every((c) => !c.status || c.status.exitCode === null || c.status.exitCode === 0),
|
|
73
|
+
};
|
|
74
|
+
const model: ToolGroupModel = {
|
|
75
|
+
kind: this.kind,
|
|
76
|
+
icon: GROUP_ICONS[this.kind] ?? "▶",
|
|
77
|
+
children: this.allChildren.slice(start),
|
|
78
|
+
hidden,
|
|
79
|
+
expanded: this.expanded,
|
|
80
|
+
open: this.open,
|
|
81
|
+
};
|
|
82
|
+
this.view.update(model);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
+
import { theme } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export class UserMessage {
|
|
5
|
+
readonly node: RenderNode;
|
|
6
|
+
constructor(nodes: RenderNodes, text: string) {
|
|
7
|
+
const container = nodes.container();
|
|
8
|
+
container.addChild(nodes.spacer(1));
|
|
9
|
+
const md = nodes.markdown({
|
|
10
|
+
paddingX: 1,
|
|
11
|
+
paddingY: 1,
|
|
12
|
+
bgColor: (t) => theme.bg("userMessageBg", t),
|
|
13
|
+
color: (t) => theme.fg("userMessageText", t),
|
|
14
|
+
osc133Zones: true,
|
|
15
|
+
});
|
|
16
|
+
md.setText(text);
|
|
17
|
+
container.addChild(md.node);
|
|
18
|
+
this.node = container.node;
|
|
19
|
+
}
|
|
20
|
+
}
|