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.
Files changed (68) hide show
  1. package/README.md +47 -20
  2. package/dist/agent/agent-loop.js +20 -15
  3. package/dist/agent/events.d.ts +2 -1
  4. package/dist/agent/index.js +44 -7
  5. package/dist/agent/live-view.d.ts +3 -3
  6. package/dist/agent/live-view.js +15 -7
  7. package/dist/agent/providers/ollama.d.ts +11 -0
  8. package/dist/agent/providers/ollama.js +72 -0
  9. package/dist/agent/providers/opencode.d.ts +10 -0
  10. package/dist/agent/providers/opencode.js +112 -0
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/providers/zai-coding-plan.d.ts +5 -0
  13. package/dist/agent/providers/zai-coding-plan.js +26 -0
  14. package/dist/agent/subagent.js +1 -1
  15. package/dist/cli/args.js +2 -2
  16. package/dist/cli/install.js +10 -1
  17. package/dist/shell/events.d.ts +3 -0
  18. package/dist/shell/shell.js +3 -0
  19. package/dist/utils/diff-renderer.d.ts +4 -0
  20. package/dist/utils/diff-renderer.js +15 -20
  21. package/examples/extensions/ads/SKILL.md +170 -0
  22. package/examples/extensions/ads/index.ts +695 -0
  23. package/examples/extensions/ash-scheme/index.ts +339 -605
  24. package/examples/extensions/ash-scheme/package.json +1 -1
  25. package/examples/extensions/ashi/EXTENDING.md +116 -0
  26. package/examples/extensions/ashi/README.md +10 -54
  27. package/examples/extensions/ashi/package.json +6 -2
  28. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  29. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  30. package/examples/extensions/ashi/src/capture.ts +9 -3
  31. package/examples/extensions/ashi/src/chat/assistant.ts +87 -0
  32. package/examples/extensions/ashi/src/chat/lines.ts +20 -0
  33. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  34. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  35. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  36. package/examples/extensions/ashi/src/cli.ts +58 -12
  37. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  38. package/examples/extensions/ashi/src/commands.ts +11 -1
  39. package/examples/extensions/ashi/src/display-config.ts +9 -1
  40. package/examples/extensions/ashi/src/frontend.ts +340 -259
  41. package/examples/extensions/ashi/src/hooks.ts +33 -40
  42. package/examples/extensions/ashi/src/renderer.ts +222 -0
  43. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  44. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +23 -0
  45. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +133 -0
  46. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +193 -0
  47. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  48. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  49. package/examples/extensions/ashi/src/schema.ts +43 -205
  50. package/examples/extensions/ashi/src/status-footer.ts +15 -23
  51. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  52. package/examples/extensions/ashi/src/theme.ts +1 -47
  53. package/examples/extensions/ashi-ink/README.md +59 -0
  54. package/examples/extensions/ashi-ink/package.json +30 -0
  55. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  56. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  57. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  58. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  59. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  60. package/examples/extensions/ashi-scheme-render.ts +4 -10
  61. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  62. package/examples/extensions/latex-images.ts +22 -19
  63. package/examples/extensions/terminal-buffer.ts +4 -2
  64. package/package.json +3 -9
  65. package/examples/extensions/ashi/src/components.ts +0 -238
  66. package/examples/extensions/ollama.ts +0 -108
  67. package/examples/extensions/opencode-provider.ts +0 -251
  68. 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,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 full output regardless of mode, and call lines with truncated labels (e.g. long `bash` commands) reveal their full text. Press again to collapse.
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
- ## 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:
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
- 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).
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.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,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.8",
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 "@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.
@@ -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
- /** Maintains an `(entryId | null)[]` parallel to the live messages array;
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 flush = async (): Promise<void> => {
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
+ }