agent-sh 0.12.26 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +13 -2
  2. package/dist/agent/agent-loop.d.ts +3 -5
  3. package/dist/agent/agent-loop.js +44 -100
  4. package/dist/agent/conversation-state.d.ts +9 -0
  5. package/dist/agent/conversation-state.js +38 -1
  6. package/dist/agent/history-file.d.ts +6 -0
  7. package/dist/agent/history-file.js +1 -1
  8. package/dist/agent/host-types.d.ts +125 -0
  9. package/dist/agent/index.d.ts +12 -4
  10. package/dist/agent/index.js +357 -6
  11. package/dist/agent/nuclear-form.d.ts +7 -0
  12. package/dist/{extensions → agent}/providers/deepseek.d.ts +2 -2
  13. package/dist/{extensions → agent}/providers/deepseek.js +5 -4
  14. package/dist/{extensions → agent}/providers/openai-compatible.d.ts +2 -2
  15. package/dist/{extensions → agent}/providers/openai.d.ts +2 -2
  16. package/dist/{extensions → agent}/providers/openai.js +3 -2
  17. package/dist/{extensions → agent}/providers/openrouter.d.ts +2 -2
  18. package/dist/{extensions → agent}/providers/openrouter.js +4 -3
  19. package/dist/agent/skills.js +51 -7
  20. package/dist/agent/subagent.d.ts +1 -1
  21. package/dist/agent/system-prompt.js +14 -17
  22. package/dist/agent/tool-protocol.d.ts +1 -1
  23. package/dist/agent/tool-protocol.js +5 -3
  24. package/dist/agent/tool-registry.d.ts +9 -4
  25. package/dist/agent/tool-registry.js +27 -4
  26. package/dist/agent/tools/bash.d.ts +1 -1
  27. package/dist/agent/tools/bash.js +3 -2
  28. package/dist/agent/tools/edit-file.js +0 -1
  29. package/dist/agent/tools/glob.js +1 -1
  30. package/dist/agent/tools/grep.js +1 -1
  31. package/dist/agent/tools/pwsh.d.ts +1 -1
  32. package/dist/agent/tools/pwsh.js +1 -2
  33. package/dist/agent/tools/read-file.js +7 -4
  34. package/dist/agent/tools/write-file.js +0 -1
  35. package/dist/agent/types.d.ts +17 -2
  36. package/dist/cli/auth/cli.d.ts +1 -0
  37. package/dist/cli/auth/cli.js +216 -0
  38. package/dist/cli/auth/keys.d.ts +31 -0
  39. package/dist/cli/auth/keys.js +102 -0
  40. package/dist/{index.js → cli/index.js} +29 -32
  41. package/dist/{init.js → cli/init.js} +1 -1
  42. package/dist/{install.js → cli/install.js} +114 -5
  43. package/dist/cli/subcommands.d.ts +1 -0
  44. package/dist/cli/subcommands.js +17 -0
  45. package/dist/{event-bus.d.ts → core/event-bus.d.ts} +7 -13
  46. package/dist/{extension-loader.d.ts → core/extension-loader.d.ts} +1 -1
  47. package/dist/{extension-loader.js → core/extension-loader.js} +62 -70
  48. package/dist/{core.d.ts → core/index.d.ts} +18 -15
  49. package/dist/{core.js → core/index.js} +18 -92
  50. package/dist/{settings.d.ts → core/settings.d.ts} +7 -0
  51. package/dist/{settings.js → core/settings.js} +1 -0
  52. package/dist/core/types.d.ts +49 -0
  53. package/dist/core/types.js +1 -0
  54. package/dist/extensions/file-autocomplete.d.ts +1 -1
  55. package/dist/extensions/index.d.ts +7 -14
  56. package/dist/extensions/index.js +2 -19
  57. package/dist/extensions/slash-commands.d.ts +1 -1
  58. package/dist/extensions/slash-commands.js +7 -2
  59. package/dist/shell/host-types.d.ts +114 -0
  60. package/dist/shell/host-types.js +1 -0
  61. package/dist/shell/index.d.ts +8 -7
  62. package/dist/shell/index.js +58 -9
  63. package/dist/shell/input-handler.d.ts +7 -1
  64. package/dist/shell/input-handler.js +5 -2
  65. package/dist/shell/output-parser.d.ts +1 -1
  66. package/dist/{extensions → shell}/shell-context.d.ts +1 -1
  67. package/dist/{extensions → shell}/shell-context.js +18 -12
  68. package/dist/shell/shell.d.ts +6 -4
  69. package/dist/shell/shell.js +33 -109
  70. package/dist/shell/strategies/bash.d.ts +2 -0
  71. package/dist/shell/strategies/bash.js +68 -0
  72. package/dist/shell/strategies/fish.d.ts +2 -0
  73. package/dist/shell/strategies/fish.js +65 -0
  74. package/dist/shell/strategies/index.d.ts +13 -0
  75. package/dist/shell/strategies/index.js +17 -0
  76. package/dist/shell/strategies/types.d.ts +50 -0
  77. package/dist/shell/strategies/types.js +9 -0
  78. package/dist/shell/strategies/zsh.d.ts +2 -0
  79. package/dist/shell/strategies/zsh.js +72 -0
  80. package/dist/shell/tui-input-view.js +14 -3
  81. package/dist/{extensions → shell}/tui-renderer.d.ts +1 -1
  82. package/dist/{extensions → shell}/tui-renderer.js +27 -55
  83. package/dist/utils/box-frame.d.ts +4 -0
  84. package/dist/utils/box-frame.js +17 -6
  85. package/dist/utils/compositor.d.ts +1 -1
  86. package/dist/utils/compositor.js +2 -1
  87. package/dist/{executor.js → utils/executor.js} +1 -1
  88. package/dist/utils/floating-panel.d.ts +17 -5
  89. package/dist/utils/floating-panel.js +218 -70
  90. package/dist/utils/llm-facade.d.ts +7 -3
  91. package/dist/utils/stream-transform.d.ts +1 -1
  92. package/dist/utils/terminal-buffer.d.ts +1 -1
  93. package/dist/utils/tool-display.js +4 -0
  94. package/dist/utils/tool-interactive.d.ts +1 -1
  95. package/dist/utils/tty.d.ts +7 -0
  96. package/dist/utils/tty.js +15 -0
  97. package/examples/extensions/ash-acp-bridge/README.md +4 -1
  98. package/examples/extensions/ash-acp-bridge/src/index.ts +654 -0
  99. package/examples/extensions/ash-mcp-bridge/index.ts +1 -1
  100. package/examples/extensions/ashi/README.md +250 -0
  101. package/examples/extensions/ashi/package.json +60 -0
  102. package/examples/extensions/ashi/src/autocomplete.ts +91 -0
  103. package/examples/extensions/ashi/src/capture.ts +34 -0
  104. package/examples/extensions/ashi/src/cli.ts +126 -0
  105. package/examples/extensions/ashi/src/commands.ts +82 -0
  106. package/examples/extensions/ashi/src/compaction.ts +157 -0
  107. package/examples/extensions/ashi/src/components.ts +332 -0
  108. package/examples/extensions/ashi/src/default-renderers.ts +153 -0
  109. package/examples/extensions/ashi/src/display-config.ts +62 -0
  110. package/examples/extensions/ashi/src/frontend.ts +735 -0
  111. package/examples/extensions/ashi/src/hooks.ts +136 -0
  112. package/examples/extensions/ashi/src/multi-session-store.ts +146 -0
  113. package/examples/extensions/ashi/src/session-commands.ts +76 -0
  114. package/examples/extensions/ashi/src/session-store.ts +264 -0
  115. package/examples/extensions/ashi/src/status-footer.ts +66 -0
  116. package/examples/extensions/ashi/src/theme.ts +151 -0
  117. package/examples/extensions/ashi/tsconfig.json +14 -0
  118. package/examples/extensions/emacs-buffer.ts +364 -0
  119. package/examples/extensions/interactive-prompts.ts +114 -69
  120. package/examples/extensions/latex-images.ts +3 -3
  121. package/examples/extensions/opencode-bridge/index.ts +1 -1
  122. package/examples/extensions/overlay-agent.ts +35 -10
  123. package/examples/extensions/peer-mesh.ts +1 -1
  124. package/examples/extensions/pi-bridge/index.ts +0 -1
  125. package/examples/extensions/questionnaire.ts +2 -1
  126. package/examples/extensions/rtk-proxy.ts +3 -3
  127. package/examples/extensions/solarized-theme.ts +3 -3
  128. package/examples/extensions/subagents.ts +6 -6
  129. package/examples/extensions/terminal-buffer.ts +174 -33
  130. package/examples/extensions/tmux-pane.ts +6 -4
  131. package/examples/extensions/tunnel-vision.ts +405 -0
  132. package/examples/extensions/user-shell.ts +1 -1
  133. package/examples/extensions/web-access.ts +8 -113
  134. package/package.json +26 -22
  135. package/dist/extensions/agent-backend.d.ts +0 -14
  136. package/dist/extensions/agent-backend.js +0 -307
  137. package/dist/types.d.ts +0 -227
  138. /package/dist/{types.js → agent/host-types.js} +0 -0
  139. /package/dist/{extensions → agent}/providers/openai-compatible.js +0 -0
  140. /package/dist/{index.d.ts → cli/index.d.ts} +0 -0
  141. /package/dist/{init.d.ts → cli/init.d.ts} +0 -0
  142. /package/dist/{install.d.ts → cli/install.d.ts} +0 -0
  143. /package/dist/{event-bus.js → core/event-bus.js} +0 -0
  144. /package/dist/{executor.d.ts → utils/executor.d.ts} +0 -0
@@ -0,0 +1,735 @@
1
+ import {
2
+ TUI,
3
+ ProcessTerminal,
4
+ Container,
5
+ Editor,
6
+ Image,
7
+ Loader,
8
+ SelectList,
9
+ Spacer,
10
+ type Component,
11
+ type SelectItem,
12
+ getImageDimensions,
13
+ matchesKey,
14
+ isKeyRelease,
15
+ isKeyRepeat,
16
+ } from "@earendil-works/pi-tui";
17
+ import type { ExtensionContext } from "agent-sh/types";
18
+ import { editorTheme, selectListTheme, theme } from "./theme.js";
19
+ import {
20
+ AssistantMessage,
21
+ ErrorLine,
22
+ InfoLine,
23
+ ThinkingBlock,
24
+ ToolGroup,
25
+ } from "./components.js";
26
+ import type { ToolCallView, ToolResultView } from "./hooks.js";
27
+ import { createToolHookResolver } from "./hooks.js";
28
+
29
+ const GROUPABLE_KINDS = new Set(["read", "search"]);
30
+ const TOOL_KIND: Record<string, string> = {
31
+ read_file: "read", ls: "read",
32
+ grep: "search", glob: "search",
33
+ };
34
+ import { BusAutocompleteProvider } from "./autocomplete.js";
35
+ import { StatusFooter } from "./status-footer.js";
36
+ import type { MultiSessionStore } from "./multi-session-store.js";
37
+ import type { SessionEntry } from "./session-store.js";
38
+ import { formatSessionRow } from "./session-commands.js";
39
+ import { resumeSession } from "./session-commands.js";
40
+ import { applyBranchMessages } from "./commands.js";
41
+ import type { Capture } from "./capture.js";
42
+ import { execSync } from "node:child_process";
43
+ import { renderDiff } from "agent-sh/utils/diff-renderer.js";
44
+ import { renderBoxFrame } from "agent-sh/utils/box-frame.js";
45
+
46
+ interface DiffStats { added: number; removed: number; isNewFile: boolean; isIdentical: boolean }
47
+
48
+ function diffFrameTitle(filePath: string, diff: DiffStats): string {
49
+ const stats = diff.isNewFile
50
+ ? theme.fg("success", `+${diff.added}`)
51
+ : `${theme.fg("success", `+${diff.added}`)} ${theme.fg("error", `-${diff.removed}`)}`;
52
+ return `${theme.fg("muted", filePath)} ${stats}`;
53
+ }
54
+
55
+ function readReasoning(m: unknown): string {
56
+ const mm = m as { reasoning?: unknown; reasoning_content?: unknown };
57
+ const r = mm.reasoning ?? mm.reasoning_content;
58
+ return typeof r === "string" ? r : "";
59
+ }
60
+
61
+ function currentGitBranch(cwd: string): string | undefined {
62
+ try {
63
+ const out = execSync("git rev-parse --abbrev-ref HEAD", {
64
+ cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 500,
65
+ }).toString().trim();
66
+ return out || undefined;
67
+ } catch { return undefined; }
68
+ }
69
+
70
+ const fgAccent = (t: string): string => theme.fg("accent", t);
71
+ const fgMuted = (t: string): string => theme.fg("muted", t);
72
+
73
+ function detailFromArgs(argsJson: string | undefined): string {
74
+ if (!argsJson) return "";
75
+ try {
76
+ const args = JSON.parse(argsJson) as Record<string, unknown>;
77
+ if (typeof args.command === "string") return `$ ${args.command}`;
78
+ if (typeof args.pattern === "string") return args.pattern;
79
+ if (typeof args.path === "string") return relativize(args.path);
80
+ if (typeof args.file_path === "string") return relativize(args.file_path);
81
+ if (typeof args.query === "string") return `"${args.query}"`;
82
+ } catch { /* fall through */ }
83
+ return "";
84
+ }
85
+
86
+ /** Recompute the per-tool summary from a saved tool result message. We don't
87
+ * persist resultDisplay, so /resume would otherwise lose "16 entries" / "117
88
+ * lines" etc. Mirrors agent-sh's formatResult logic for the common tools. */
89
+ function inferSummary(toolName: string, content: unknown): string | undefined {
90
+ if (typeof content !== "string" || content.length === 0) return undefined;
91
+ const lines = content.split("\n").filter((l) => l.length > 0);
92
+ switch (toolName) {
93
+ case "ls":
94
+ if (content === "(empty directory)") return "0 entries";
95
+ return `${lines.length} entries`;
96
+ case "glob":
97
+ if (content === "No files matched.") return "0 files";
98
+ return `${lines.length} files`;
99
+ case "grep":
100
+ if (content === "No matches found.") return "0 matches";
101
+ return `${lines.length} lines`;
102
+ case "read_file":
103
+ if (content.startsWith("File unchanged")) return "cached";
104
+ return `${lines.length} lines`;
105
+ default:
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ function relativize(fp: string): string {
111
+ const home = process.env.HOME;
112
+ const cwd = process.cwd();
113
+ if (fp.startsWith(`${cwd}/`)) return fp.slice(cwd.length + 1);
114
+ if (home && fp.startsWith(`${home}/`)) return `~/${fp.slice(home.length + 1)}`;
115
+ return fp;
116
+ }
117
+
118
+ export interface AshiHandle {
119
+ tui: TUI;
120
+ stop: () => void;
121
+ openTreePicker: () => Promise<void>;
122
+ openSessionPicker: () => Promise<void>;
123
+ rebuildChat: () => Promise<void>;
124
+ }
125
+
126
+ export function mountAshi(
127
+ ctx: ExtensionContext,
128
+ getStore: () => MultiSessionStore,
129
+ capture: Capture,
130
+ ): AshiHandle {
131
+ const { bus } = ctx;
132
+ const terminal = new ProcessTerminal();
133
+ const tui = new TUI(terminal);
134
+
135
+ const chat = new Container();
136
+ const footerSlot = new Container();
137
+ const editor = new Editor(tui, editorTheme(), { paddingX: 1 });
138
+ editor.setAutocompleteProvider(new BusAutocompleteProvider(bus));
139
+ editor.onSubmit = (text) => {
140
+ const query = text.trim();
141
+ if (!query) return;
142
+ editor.setText("");
143
+ if (query.startsWith("/")) {
144
+ const sp = query.indexOf(" ");
145
+ const name = sp === -1 ? query : query.slice(0, sp);
146
+ const args = sp === -1 ? "" : query.slice(sp + 1).trim();
147
+ bus.emit("command:execute", { name, args });
148
+ return;
149
+ }
150
+ bus.emit("agent:submit", { query });
151
+ };
152
+
153
+ const statusFooter = new StatusFooter();
154
+ const cwd = ctx.call("cwd") as string;
155
+ statusFooter.update({ cwd, branch: currentGitBranch(cwd) });
156
+ let compactions = 0;
157
+ const refreshFooterStats = (): void => {
158
+ const tokens = ctx.call("conversation:estimate-prompt-tokens") as number | undefined;
159
+ statusFooter.update({ tokens: tokens ?? 0 });
160
+ };
161
+ const refreshBranch = (): void => {
162
+ statusFooter.update({ branch: currentGitBranch(cwd) });
163
+ };
164
+ const refreshThinking = (): void => {
165
+ const { level, supported } = bus.emitPipe("config:get-thinking", {
166
+ level: "off", levels: [] as string[], supported: true,
167
+ });
168
+ statusFooter.update({ thinking: supported ? level : undefined });
169
+ };
170
+
171
+ tui.addChild(chat);
172
+ tui.addChild(footerSlot);
173
+ tui.addChild(editor);
174
+ tui.addChild(statusFooter);
175
+ tui.setFocus(editor);
176
+
177
+ interface ToolPair { call: ToolCallView; result: ToolResultView; startedAt: number }
178
+ type LiveToolEntry = { kind: "pair"; pair: ToolPair } | { kind: "group"; group: ToolGroup };
179
+
180
+ let activeAssistant: AssistantMessage | null = null;
181
+ let activeThinking: ThinkingBlock | null = null;
182
+ const activeTools = new Map<string, LiveToolEntry>();
183
+ /** Per-batch state from agent:tool-batch — the group is created lazily on
184
+ * the first member's tool-started so the chat insertion order is correct. */
185
+ const batchGroups = new Map<string, { total: number; group: ToolGroup | null }>();
186
+ let lastToolResult: ToolResultView | null = null;
187
+ let loader: Loader | null = null;
188
+ let processing = false;
189
+ let hideThinking = false;
190
+
191
+ const renderState = (): { state: Record<string, unknown>; invalidate: () => void } => ({
192
+ state: {},
193
+ invalidate: () => tui.requestRender(),
194
+ });
195
+
196
+ const tools = createToolHookResolver(ctx, renderState);
197
+
198
+ const renderUserMessage = (text: string): Component =>
199
+ ctx.call("ashi:render-user-message", { text, ...renderState() }) as Component;
200
+
201
+ const renderAssistantLive = (): AssistantMessage =>
202
+ ctx.call("ashi:render-assistant", { text: "", ...renderState() }) as AssistantMessage;
203
+
204
+ const renderAssistantFinal = (text: string): Component =>
205
+ ctx.call("ashi:render-assistant", { text, ...renderState() }) as Component;
206
+
207
+ const renderThinkingLive = (): ThinkingBlock =>
208
+ ctx.call("ashi:render-thinking", { text: "", hidden: hideThinking, ...renderState() }) as ThinkingBlock;
209
+
210
+ const renderThinkingFinal = (text: string): Component =>
211
+ ctx.call("ashi:render-thinking", { text, hidden: hideThinking, ...renderState() }) as Component;
212
+
213
+ const renderToolPair = (args: {
214
+ toolCallId: string; name: string; title: string;
215
+ kind?: string; displayDetail?: string; rawInput?: unknown;
216
+ }): ToolPair => {
217
+ const call = tools.call(args);
218
+ const result = tools.result({
219
+ toolCallId: args.toolCallId,
220
+ name: args.name,
221
+ kind: args.kind,
222
+ rawInput: args.rawInput,
223
+ });
224
+ return { call, result, startedAt: Date.now() };
225
+ };
226
+
227
+ const ensureAssistant = (): AssistantMessage => {
228
+ if (!activeAssistant) {
229
+ activeAssistant = renderAssistantLive();
230
+ chat.addChild(activeAssistant);
231
+ }
232
+ return activeAssistant;
233
+ };
234
+
235
+ const finalizeThinking = (): void => {
236
+ if (activeThinking) {
237
+ activeThinking.finalize();
238
+ activeThinking = null;
239
+ }
240
+ };
241
+
242
+ const ensureThinking = (): ThinkingBlock => {
243
+ if (!activeThinking) {
244
+ activeThinking = renderThinkingLive();
245
+ chat.addChild(activeThinking);
246
+ }
247
+ return activeThinking;
248
+ };
249
+
250
+ const startLoader = (): void => {
251
+ if (loader) return;
252
+ loader = new Loader(tui, fgAccent, fgMuted, "thinking…");
253
+ footerSlot.addChild(loader);
254
+ };
255
+ const stopLoader = (): void => {
256
+ if (!loader) return;
257
+ loader.stop();
258
+ footerSlot.removeChild(loader);
259
+ loader = null;
260
+ };
261
+
262
+ type ReplayEntry =
263
+ | { kind: "pair"; pair: ToolPair; name: string }
264
+ | { kind: "group"; group: ToolGroup; name: string };
265
+
266
+ const replayEntry = (entry: SessionEntry, toolMap: Map<string, ReplayEntry>): void => {
267
+ if (entry.type === "session") return;
268
+ if (entry.type === "compaction") {
269
+ chat.addChild(new InfoLine(`▼ compacted (firstKept=${entry.firstKeptId.slice(0, 6)}, ${entry.tokensBefore} tokens)`));
270
+ return;
271
+ }
272
+ const m = entry.message;
273
+ if (m.role === "user") {
274
+ const text = typeof m.content === "string" ? m.content : "";
275
+ if (text.startsWith("[Compacted conversation summary]")) return;
276
+ chat.addChild(renderUserMessage(text));
277
+ } else if (m.role === "assistant") {
278
+ const reasoning = readReasoning(m);
279
+ if (reasoning) {
280
+ chat.addChild(renderThinkingFinal(reasoning));
281
+ }
282
+ const text = typeof m.content === "string" ? m.content : "";
283
+ if (text) {
284
+ chat.addChild(renderAssistantFinal(text));
285
+ }
286
+ if (m.tool_calls) {
287
+ const calls = m.tool_calls;
288
+ let i = 0;
289
+ while (i < calls.length) {
290
+ const startName = calls[i]!.function?.name ?? "";
291
+ const startKind = TOOL_KIND[startName];
292
+ if (startKind && GROUPABLE_KINDS.has(startKind)) {
293
+ let j = i;
294
+ while (j < calls.length && TOOL_KIND[calls[j]!.function?.name ?? ""] === startKind) j++;
295
+ const runLen = j - i;
296
+ if (runLen > 1) {
297
+ const group = new ToolGroup(startKind, runLen);
298
+ chat.addChild(group);
299
+ for (let k = i; k < j; k++) {
300
+ const c = calls[k]!;
301
+ const cid = c.id ?? "";
302
+ const cname = c.function?.name ?? "tool";
303
+ group.addCall(cid, cname, detailFromArgs(c.function?.arguments));
304
+ if (cid) toolMap.set(cid, { kind: "group", group, name: cname });
305
+ }
306
+ i = j;
307
+ continue;
308
+ }
309
+ }
310
+ const tc = calls[i]!;
311
+ const id = tc.id ?? "";
312
+ const name = tc.function?.name ?? "tool";
313
+ const pair = renderToolPair({
314
+ toolCallId: id, name, title: name, kind: undefined,
315
+ displayDetail: detailFromArgs(tc.function?.arguments),
316
+ rawInput: tc.function?.arguments,
317
+ });
318
+ chat.addChild(pair.call);
319
+ chat.addChild(pair.result);
320
+ if (id) toolMap.set(id, { kind: "pair", pair, name });
321
+ lastToolResult = pair.result;
322
+ i++;
323
+ }
324
+ }
325
+ } else if (m.role === "tool") {
326
+ const id = m.tool_call_id ?? "";
327
+ const text = typeof m.content === "string" ? m.content : "";
328
+ const found = id ? toolMap.get(id) : undefined;
329
+ if (!found) {
330
+ chat.addChild(new InfoLine(`tool result (no matching call): ${text.slice(0, 80)}`));
331
+ return;
332
+ }
333
+ const summary = inferSummary(found.name, text);
334
+ if (found.kind === "group") {
335
+ found.group.recordCompletion(id, 0, summary);
336
+ } else {
337
+ if (text) found.pair.result.appendChunk(text);
338
+ found.pair.result.finalize({ exitCode: 0, summary });
339
+ found.pair.call.setStatus({ exitCode: 0, elapsedMs: 0, summary });
340
+ }
341
+ if (id) toolMap.delete(id);
342
+ }
343
+ };
344
+
345
+ const rebuildChat = async (): Promise<void> => {
346
+ activeAssistant = null;
347
+ activeThinking = null;
348
+ activeTools.clear();
349
+ batchGroups.clear();
350
+ lastToolResult = null;
351
+ chat.clear();
352
+ const branch = getStore().current().getBranch();
353
+ const toolMap = new Map<string, ReplayEntry>();
354
+ for (const e of branch) replayEntry(e, toolMap);
355
+ // Match the trailing gap that processing-done adds in live turns, so the
356
+ // editor doesn't sit flush against the last replayed response.
357
+ chat.addChild(new Spacer(1));
358
+ tui.requestRender();
359
+ };
360
+
361
+ // ── Bus wiring ───────────────────────────────────────────────
362
+ bus.on("agent:query", ({ query }) => {
363
+ chat.addChild(renderUserMessage(query));
364
+ activeAssistant = null;
365
+ tui.requestRender();
366
+ });
367
+
368
+ bus.on("agent:processing-start", () => {
369
+ processing = true;
370
+ startLoader();
371
+ tui.requestRender();
372
+ });
373
+
374
+ const imageComponentFromPng = (data: Buffer): Image | null => {
375
+ const base64 = data.toString("base64");
376
+ const dims = getImageDimensions(base64, "image/png");
377
+ if (!dims) return null;
378
+ return new Image(
379
+ base64, "image/png",
380
+ { fallbackColor: (t) => theme.fg("muted", t) },
381
+ { maxWidthCells: 60, maxHeightCells: 20 },
382
+ dims,
383
+ );
384
+ };
385
+
386
+ /** Drop the live assistant message so the image lands as its own block,
387
+ * then subsequent text starts a fresh markdown context below it. */
388
+ const appendImage = (data: Buffer): void => {
389
+ const img = imageComponentFromPng(data);
390
+ if (!img) return;
391
+ if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
392
+ chat.addChild(img);
393
+ };
394
+
395
+ // tui-renderer normally owns render:image, but ashi disables it; provide
396
+ // our own so latex-images and friends reach the chat.
397
+ ctx.define("render:image", (data: Buffer) => {
398
+ appendImage(data);
399
+ tui.requestRender();
400
+ });
401
+
402
+ bus.on("agent:response-chunk", ({ blocks }) => {
403
+ finalizeThinking();
404
+ for (const b of blocks) {
405
+ if (b.type === "text") ensureAssistant().appendText(b.text);
406
+ else if (b.type === "code-block") ensureAssistant().appendCodeBlock(b.language, b.code);
407
+ else if (b.type === "image") appendImage(b.data);
408
+ }
409
+ tui.requestRender();
410
+ });
411
+
412
+ bus.on("agent:thinking-chunk", ({ text }) => {
413
+ if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
414
+ ensureThinking().appendText(text);
415
+ tui.requestRender();
416
+ });
417
+
418
+ bus.on("agent:tool-batch", (e) => {
419
+ batchGroups.clear();
420
+ for (const g of e.groups) {
421
+ batchGroups.set(g.kind, { total: g.tools.length, group: null });
422
+ }
423
+ });
424
+
425
+ bus.on("agent:tool-started", (e) => {
426
+ finalizeThinking();
427
+ if (activeAssistant) {
428
+ activeAssistant.finalize();
429
+ activeAssistant = null;
430
+ }
431
+ const id = e.toolCallId ?? `${e.title}-${Date.now()}`;
432
+ const title = e.title.split(":")[0]!.trim();
433
+ const detail = e.displayDetail || detailFromArgs(
434
+ typeof e.rawInput === "string" ? e.rawInput : JSON.stringify(e.rawInput ?? {})
435
+ );
436
+
437
+ const kind = e.kind ?? "";
438
+ const batchEntry = batchGroups.get(kind);
439
+ const shouldGroup = !!batchEntry && batchEntry.total > 1 && GROUPABLE_KINDS.has(kind);
440
+ if (shouldGroup) {
441
+ if (!batchEntry!.group) {
442
+ batchEntry!.group = new ToolGroup(kind, batchEntry!.total);
443
+ chat.addChild(batchEntry!.group);
444
+ }
445
+ batchEntry!.group.addCall(id, title, detail);
446
+ activeTools.set(id, { kind: "group", group: batchEntry!.group });
447
+ // Grouped tools have no individual result body — Ctrl+O wouldn't have
448
+ // anything to expand, so leave lastToolResult pointing at the prior tool.
449
+ tui.requestRender();
450
+ return;
451
+ }
452
+
453
+ const pair = renderToolPair({
454
+ toolCallId: id, name: title, title, kind: e.kind,
455
+ displayDetail: detail, rawInput: e.rawInput,
456
+ });
457
+ activeTools.set(id, { kind: "pair", pair });
458
+ chat.addChild(pair.call);
459
+ chat.addChild(pair.result);
460
+ lastToolResult = pair.result;
461
+ tui.requestRender();
462
+ });
463
+
464
+ bus.on("agent:tool-output-chunk", ({ chunk }) => {
465
+ for (const entry of [...activeTools.values()].reverse()) {
466
+ if (entry.kind === "pair") {
467
+ entry.pair.result.appendChunk(chunk);
468
+ tui.requestRender();
469
+ return;
470
+ }
471
+ }
472
+ });
473
+
474
+ bus.on("agent:tool-completed", (e) => {
475
+ const id = e.toolCallId;
476
+ if (!id) return;
477
+ const entry = activeTools.get(id);
478
+ if (!entry) return;
479
+ const summary = e.resultDisplay?.summary;
480
+ if (entry.kind === "group") {
481
+ entry.group.recordCompletion(id, e.exitCode, summary);
482
+ activeTools.delete(id);
483
+ tui.requestRender();
484
+ return;
485
+ }
486
+ const pair = entry.pair;
487
+ const body = e.resultDisplay?.body;
488
+ const ok = e.exitCode === null || e.exitCode === 0;
489
+ if (body?.kind === "diff") {
490
+ const diff = body.diff as DiffStats & Parameters<typeof renderDiff>[0];
491
+ if (!diff.isIdentical) {
492
+ const termW = process.stdout.columns ?? 80;
493
+ const boxW = Math.max(40, termW);
494
+ const contentW = Math.max(20, boxW - 4);
495
+ const diffLines = renderDiff(diff, {
496
+ width: contentW,
497
+ filePath: body.filePath,
498
+ trueColor: true,
499
+ maxLines: 30,
500
+ });
501
+ const inner = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
502
+ const framed = renderBoxFrame(inner, {
503
+ width: boxW,
504
+ style: "rounded",
505
+ title: diffFrameTitle(body.filePath, diff),
506
+ bgColor: theme.bgCode(ok ? "toolSuccessBg" : "toolErrorBg"),
507
+ });
508
+ pair.result.setDiff(framed);
509
+ }
510
+ }
511
+ pair.call.setStatus({ exitCode: e.exitCode, elapsedMs: Date.now() - pair.startedAt, summary });
512
+ pair.result.finalize({ exitCode: e.exitCode, summary });
513
+ activeTools.delete(id);
514
+ tui.requestRender();
515
+ });
516
+
517
+ bus.on("agent:processing-done", () => {
518
+ processing = false;
519
+ stopLoader();
520
+ finalizeThinking();
521
+ if (activeAssistant) activeAssistant.finalize();
522
+ chat.addChild(new Spacer(1));
523
+ refreshFooterStats();
524
+ refreshBranch();
525
+ tui.requestRender();
526
+ });
527
+
528
+ bus.on("agent:usage", (u) => {
529
+ if (u.prompt_tokens > 0) {
530
+ statusFooter.update({ tokens: u.prompt_tokens });
531
+ tui.requestRender();
532
+ }
533
+ });
534
+
535
+ bus.on("agent:cancelled", () => {
536
+ processing = false;
537
+ stopLoader();
538
+ chat.addChild(new InfoLine("cancelled"));
539
+ tui.requestRender();
540
+ });
541
+
542
+ bus.on("agent:error", ({ message }) => {
543
+ processing = false;
544
+ stopLoader();
545
+ chat.addChild(new ErrorLine(message));
546
+ tui.requestRender();
547
+ });
548
+
549
+ bus.on("ui:info", ({ message }) => {
550
+ chat.addChild(new InfoLine(message));
551
+ tui.requestRender();
552
+ });
553
+
554
+ bus.on("ui:error", ({ message }) => {
555
+ chat.addChild(new ErrorLine(message));
556
+ tui.requestRender();
557
+ });
558
+
559
+ bus.on("agent:info", (info) => {
560
+ statusFooter.update({
561
+ model: info.model,
562
+ provider: info.provider,
563
+ contextWindow: info.contextWindow,
564
+ });
565
+ refreshThinking();
566
+ tui.requestRender();
567
+ });
568
+
569
+ bus.on("config:changed", () => {
570
+ refreshThinking();
571
+ tui.requestRender();
572
+ });
573
+
574
+ bus.on("conversation:after-compact", () => {
575
+ compactions++;
576
+ statusFooter.update({ compactions });
577
+ refreshFooterStats();
578
+ tui.requestRender();
579
+ });
580
+
581
+ refreshFooterStats();
582
+
583
+ // ── Pickers ────────────────────────────────────────────────────
584
+ let pickerOpen = false;
585
+
586
+ const openTreePicker = async (): Promise<void> => {
587
+ if (pickerOpen) return;
588
+ const branch = getStore().current().getBranch();
589
+ if (branch.length <= 1) {
590
+ bus.emit("ui:info", { message: "tree: nothing to rewind to yet" });
591
+ return;
592
+ }
593
+ const activeId = getStore().current().getActiveLeaf();
594
+ const items: SelectItem[] = branch.map((e) => ({
595
+ value: e.id,
596
+ label: pickerLabel(e, e.id === activeId),
597
+ description: e.parentId ? `← ${e.parentId.slice(0, 6)}` : "root",
598
+ }));
599
+ const picker = new SelectList(items, 15, selectListTheme());
600
+ const activeIdx = items.findIndex((it) => it.value === activeId);
601
+ if (activeIdx >= 0) picker.setSelectedIndex(activeIdx);
602
+
603
+ const close = (): void => {
604
+ pickerOpen = false;
605
+ footerSlot.removeChild(picker);
606
+ tui.setFocus(editor);
607
+ tui.requestRender();
608
+ };
609
+
610
+ picker.onSelect = async (item) => {
611
+ const id = item.value;
612
+ close();
613
+ if (id === activeId) return;
614
+ getStore().current().setActiveLeaf(id);
615
+ applyBranchMessages(ctx, getStore, capture);
616
+ bus.emit("ui:info", { message: `fork: rewound to ${id.slice(0, 6)}` });
617
+ await rebuildChat();
618
+ refreshFooterStats();
619
+ };
620
+ picker.onCancel = close;
621
+
622
+ pickerOpen = true;
623
+ footerSlot.addChild(picker);
624
+ tui.setFocus(picker);
625
+ tui.requestRender();
626
+ };
627
+
628
+ const openSessionPicker = async (): Promise<void> => {
629
+ if (pickerOpen) return;
630
+ const currentId = getStore().current().id;
631
+ const list = getStore().listSessions().filter((s) => s.id !== currentId);
632
+ if (list.length === 0) {
633
+ bus.emit("ui:info", { message: "no past sessions in this cwd" });
634
+ return;
635
+ }
636
+ const items: SelectItem[] = list.map((s) => ({
637
+ value: s.id,
638
+ label: formatSessionRow(s, false),
639
+ }));
640
+ const picker = new SelectList(items, 15, selectListTheme());
641
+
642
+ const close = (): void => {
643
+ pickerOpen = false;
644
+ footerSlot.removeChild(picker);
645
+ tui.setFocus(editor);
646
+ tui.requestRender();
647
+ };
648
+
649
+ picker.onSelect = async (item) => {
650
+ const id = item.value;
651
+ close();
652
+ resumeSession(ctx, getStore, capture, id);
653
+ bus.emit("ui:info", { message: `resumed session ${id}` });
654
+ await rebuildChat();
655
+ refreshFooterStats();
656
+ };
657
+ picker.onCancel = close;
658
+
659
+ pickerOpen = true;
660
+ footerSlot.addChild(picker);
661
+ tui.setFocus(picker);
662
+ tui.requestRender();
663
+ };
664
+
665
+ // ── Keybindings ────────────────────────────────────────────────
666
+ const toggleThinking = (): void => {
667
+ hideThinking = !hideThinking;
668
+ const walk = (node: Container): void => {
669
+ for (const child of node.children) {
670
+ if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
671
+ else if (child instanceof Container) walk(child);
672
+ }
673
+ };
674
+ walk(chat);
675
+ tui.requestRender();
676
+ };
677
+
678
+ tui.addInputListener((data) => {
679
+ if (isKeyRelease(data) || isKeyRepeat(data)) return;
680
+ if (matchesKey(data, "escape") && processing) {
681
+ bus.emit("agent:cancel-request", {});
682
+ return { consume: true };
683
+ }
684
+ if (matchesKey(data, "ctrl+c")) {
685
+ editor.setText("");
686
+ return { consume: true };
687
+ }
688
+ if (matchesKey(data, "ctrl+d") && editor.getText().length === 0) {
689
+ ctx.quit();
690
+ return { consume: true };
691
+ }
692
+ if (matchesKey(data, "ctrl+t")) {
693
+ toggleThinking();
694
+ return { consume: true };
695
+ }
696
+ if (matchesKey(data, "shift+tab")) {
697
+ const { level, levels, supported } = bus.emitPipe("config:get-thinking", {
698
+ level: "off", levels: [] as string[], supported: true,
699
+ });
700
+ if (supported && levels.length > 0) {
701
+ const next = levels[(levels.indexOf(level) + 1) % levels.length];
702
+ bus.emit("config:set-thinking", { level: next });
703
+ }
704
+ return { consume: true };
705
+ }
706
+ if (matchesKey(data, "ctrl+o")) {
707
+ if (lastToolResult) {
708
+ lastToolResult.toggleExpanded();
709
+ tui.requestRender();
710
+ }
711
+ return { consume: true };
712
+ }
713
+ return undefined;
714
+ });
715
+
716
+ tui.start();
717
+
718
+ return {
719
+ tui,
720
+ stop: () => { tui.stop(); },
721
+ openTreePicker,
722
+ openSessionPicker,
723
+ rebuildChat,
724
+ };
725
+ }
726
+
727
+ function pickerLabel(e: SessionEntry, isActive: boolean): string {
728
+ const marker = isActive ? "●" : "│";
729
+ const short = e.id.slice(0, 6);
730
+ if (e.type === "session") return `${marker} ${short} session start`;
731
+ if (e.type === "compaction") return `${marker} ${short} ▼ compacted (firstKept=${e.firstKeptId.slice(0, 6)})`;
732
+ const m = e.message;
733
+ const text = typeof m.content === "string" ? m.content.slice(0, 70).replace(/\n/g, " ") : "";
734
+ return `${marker} ${short} ${m.role}: ${text}`;
735
+ }