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
@@ -1,42 +1,29 @@
1
- import {
2
- TUI,
3
- ProcessTerminal,
4
- Container,
5
- Editor,
6
- Image,
7
- Loader,
8
- SelectList,
9
- Spacer,
10
- Text,
11
- type Component,
12
- type SelectItem,
13
- getImageDimensions,
14
- matchesKey,
15
- isKeyRelease,
16
- isKeyRepeat,
17
- } from "@earendil-works/pi-tui";
18
1
  import type { ExtensionContext } from "agent-sh/types";
19
- import { editorTheme, selectListTheme, theme } from "./theme.js";
20
- import {
21
- AssistantMessage,
22
- ErrorLine,
23
- InfoLine,
24
- ThinkingBlock,
25
- ToolGroup,
26
- } from "./components.js";
27
- import type { ToolCallView, ToolResultView } from "./hooks.js";
28
- import { createToolHookResolver } from "./hooks.js";
2
+ import { theme } from "./theme.js";
3
+ import type {
4
+ KeyEvent,
5
+ KeyHandler,
6
+ LoaderView,
7
+ RenderNode,
8
+ Renderer,
9
+ SelectItem,
10
+ SelectView,
11
+ ToolCallView,
12
+ ToolResultView,
13
+ } from "./renderer.js";
14
+ import { ErrorLine, InfoLine } from "./chat/lines.js";
15
+ import { AssistantMessage } from "./chat/assistant.js";
16
+ import { ThinkingBlock } from "./chat/thinking.js";
17
+ import { UserMessage } from "./chat/user-message.js";
18
+ import { ToolGroup } from "./chat/tool-group.js";
19
+ import { createToolHookResolver, type RenderState } from "./hooks.js";
29
20
  import { loadGroupMaxVisible } from "./display-config.js";
30
21
  import { classifySubmit, deriveChangeHandlerResult } from "./shell-mode.js";
31
22
  import { UserShellIntents } from "./user-shell-intents.js";
32
-
33
- const GROUPABLE_KINDS = new Set(["read", "search"]);
34
- const TOOL_KIND: Record<string, string> = {
35
- read_file: "read", ls: "read",
36
- grep: "search", glob: "search",
37
- };
38
23
  import { BusAutocompleteProvider } from "./autocomplete.js";
24
+ import { createAutocompleteController } from "./autocomplete-controller.js";
39
25
  import { StatusFooter } from "./status-footer.js";
26
+ import { applyOutputMode } from "./terminal-mode.js";
40
27
  import type { MultiSessionStore } from "./multi-session-store.js";
41
28
  import { stripContextWrappers, type SessionEntry } from "agent-sh/session-store";
42
29
  import { formatSessionRow } from "./session-commands.js";
@@ -44,16 +31,34 @@ import { resumeSession } from "./session-commands.js";
44
31
  import { applyBranchMessages } from "./commands.js";
45
32
  import type { Capture } from "./capture.js";
46
33
  import { execSync } from "node:child_process";
34
+ import { readClipboardImage } from "./clipboard-image.js";
47
35
  import { renderDiff, detectLanguage, highlightLine } from "agent-sh/utils/diff-renderer.js";
48
36
  import { renderBoxFrame } from "agent-sh/utils/box-frame.js";
49
37
 
38
+ const GROUPABLE_KINDS = new Set(["read", "search"]);
39
+ const TOOL_KIND: Record<string, string> = {
40
+ read_file: "read", ls: "read",
41
+ grep: "search", glob: "search",
42
+ };
43
+
50
44
  interface DiffStats { added: number; removed: number; isNewFile: boolean; isIdentical: boolean }
51
45
 
52
46
  function buildDiffRenderer(
53
47
  diff: DiffStats & Parameters<typeof renderDiff>[0],
54
48
  filePath: string,
49
+ boxed = true,
55
50
  ): (width: number) => string[] {
56
51
  return (width) => {
52
+ if (!boxed) {
53
+ // Drop renderDiff's header (lines[0]); file path is already on the call line.
54
+ const contentW = Math.max(20, width);
55
+ const inner = diff.isNewFile
56
+ ? renderNewFilePreview(diff, 30, filePath, false)
57
+ : renderDiff(diff, {
58
+ width: contentW, filePath, trueColor: true, maxLines: Number.MAX_SAFE_INTEGER, mode: "unified", gutterLine: false,
59
+ }).slice(1);
60
+ return trimBlankEdges(inner);
61
+ }
57
62
  const boxW = Math.max(40, width);
58
63
  const contentW = Math.max(20, boxW - 4);
59
64
  const inner = diff.isNewFile
@@ -72,10 +77,19 @@ function buildDiffRenderer(
72
77
  };
73
78
  }
74
79
 
80
+ function trimBlankEdges(lines: string[]): string[] {
81
+ const blank = (s: string): boolean => s.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
82
+ let a = 0, b = lines.length;
83
+ while (a < b && blank(lines[a])) a++;
84
+ while (b > a && blank(lines[b - 1])) b--;
85
+ return lines.slice(a, b);
86
+ }
87
+
75
88
  function renderNewFilePreview(
76
89
  diff: { hunks?: { lines: { type: string; text: string }[] }[] },
77
90
  maxLines: number,
78
91
  filePath: string,
92
+ gutterLine = true,
79
93
  ): string[] {
80
94
  const lines = diff.hunks?.[0]?.lines.filter((l) => l.type === "added") ?? [];
81
95
  const shown = lines.slice(0, maxLines);
@@ -84,7 +98,8 @@ function renderNewFilePreview(
84
98
  const lang = detectLanguage(filePath);
85
99
  const body = shown.map((l, i) => {
86
100
  const no = String(i + 1).padStart(noW);
87
- return `${theme.fg("muted", `${no} │`)} ${highlightLine(l.text, lang)}`;
101
+ const code = highlightLine(l.text, lang);
102
+ return gutterLine ? `${theme.fg("muted", `${no} │`)} ${code}` : `\x1b[2m${no}\x1b[22m ${code}`;
88
103
  });
89
104
  if (overflow > 0) body.push(theme.fg("muted", `… ${overflow} more lines`));
90
105
  return ["", ...body, ""];
@@ -128,12 +143,11 @@ function detailFromArgs(argsJson: string | undefined): string {
128
143
  const compact = args.source.replace(/\s+/g, " ").trim();
129
144
  return compact.length > 80 ? compact.slice(0, 77) + "…" : compact;
130
145
  }
131
- } catch { /* fall through */ }
146
+ } catch { /* */ }
132
147
  return "";
133
148
  }
134
149
 
135
- /** resultDisplay isn't persisted, so /resume rebuilds the "16 entries" / "117
136
- * lines" hints from saved tool output. */
150
+ /** resultDisplay isn't persisted; /resume rebuilds these hints from saved tool output. */
137
151
  function inferSummary(toolName: string, content: unknown): string | undefined {
138
152
  if (typeof content !== "string" || content.length === 0) return undefined;
139
153
  const lines = content.split("\n").filter((l) => l.length > 0);
@@ -164,7 +178,6 @@ function relativize(fp: string): string {
164
178
  }
165
179
 
166
180
  export interface AshiHandle {
167
- tui: TUI;
168
181
  stop: () => void;
169
182
  openTreePicker: () => Promise<void>;
170
183
  openSessionPicker: () => Promise<void>;
@@ -175,37 +188,35 @@ export function mountAshi(
175
188
  ctx: ExtensionContext,
176
189
  getStore: () => MultiSessionStore,
177
190
  capture: Capture,
191
+ renderer: Renderer,
178
192
  ): AshiHandle {
179
193
  const { bus } = ctx;
180
- const terminal = new ProcessTerminal();
181
- const tui = new TUI(terminal);
194
+ const app = renderer.mount();
195
+ const input = app.input;
182
196
 
183
- const chat = new Container();
184
- const footerSlot = new Container();
185
- const queueSlot = new Container();
186
- const editor = new Editor(tui, editorTheme(), { paddingX: 1 });
197
+ const statusFooter = new StatusFooter(app.status, renderer.measureWidth);
187
198
 
188
199
  let shellMode = false;
189
200
  let pendingPrivate = false;
190
- const baseAutocomplete = new BusAutocompleteProvider(bus);
191
- editor.setAutocompleteProvider({
192
- getSuggestions: async (lines, line, col) =>
193
- shellMode ? null : baseAutocomplete.getSuggestions(lines, line, col),
194
- applyCompletion: baseAutocomplete.applyCompletion.bind(baseAutocomplete),
201
+ const autocomplete = createAutocompleteController({
202
+ app,
203
+ input,
204
+ provider: new BusAutocompleteProvider(bus),
205
+ suppressed: () => shellMode,
195
206
  });
196
207
 
197
- const defaultBorderColor = editor.borderColor;
208
+ const defaultBorderColor = input.defaultBorderColor;
198
209
  const shellBorderColor = (t: string): string => theme.fg("bashMode", t);
199
210
  const privateBorderColor = (t: string): string => theme.fg("bashModePrivate", t);
200
211
  const refreshShellChrome = (): void => {
201
- editor.borderColor = shellMode
212
+ input.setBorderColor(shellMode
202
213
  ? (pendingPrivate ? privateBorderColor : shellBorderColor)
203
- : defaultBorderColor;
204
- editor.invalidate();
214
+ : defaultBorderColor);
215
+ input.invalidate();
205
216
  statusFooter.update({
206
217
  shellMode: shellMode ? (pendingPrivate ? "private" : "on") : "off",
207
218
  });
208
- tui.requestRender();
219
+ app.requestRender();
209
220
  };
210
221
  const setShellMode = (on: boolean): void => {
211
222
  if (shellMode === on) return;
@@ -219,41 +230,41 @@ export function mountAshi(
219
230
  refreshShellChrome();
220
231
  };
221
232
 
222
- editor.onChange = (text) => {
233
+ input.onChange((text) => {
223
234
  const r = deriveChangeHandlerResult(shellMode, pendingPrivate, text);
224
- // Order matters: setText fires onChange synchronously, and the recursive
225
- // call must see the new mode/private values or it re-runs the entry
226
- // transition and clobbers the just-set state.
235
+ // setText fires onChange synchronously; set mode/private before setText or the recursive call clobbers it.
227
236
  if (r.mode !== shellMode) setShellMode(r.mode);
228
237
  setPendingPrivate(r.pendingPrivate);
229
- if (r.replaceText !== undefined) editor.setText(r.replaceText);
230
- };
238
+ if (r.replaceText !== undefined) input.setText(r.replaceText);
239
+ autocomplete.refresh();
240
+ });
231
241
 
232
- editor.onSubmit = (text) => {
242
+ input.onSubmit((text) => {
233
243
  const action = classifySubmit(text, shellMode, pendingPrivate);
234
244
  if (action.kind === "noop") return;
235
- editor.setText("");
245
+ input.setText("");
236
246
  switch (action.kind) {
237
247
  case "shell":
238
248
  submitShell(action.line, { private: action.private });
239
- setPendingPrivate(false);
240
249
  return;
241
250
  case "command":
242
251
  bus.emit("command:execute", { name: action.name, args: action.args });
243
252
  return;
244
- case "agent":
253
+ case "agent": {
254
+ const imgs = pendingImages.filter((p) => action.query.includes(`[Image #${p.id}]`));
255
+ pendingImages = [];
245
256
  if (processing) {
246
- queuedQueries.push(action.query);
257
+ queuedQueries.push({ query: action.query, images: imgs });
247
258
  renderQueueSlot();
248
- tui.requestRender();
259
+ app.requestRender();
249
260
  return;
250
261
  }
251
- bus.emit("agent:submit", { query: action.query });
262
+ bus.emit("agent:submit", { query: action.query, images: imgs.length ? toImageContent(imgs) : undefined });
252
263
  return;
264
+ }
253
265
  }
254
- };
266
+ });
255
267
 
256
- const statusFooter = new StatusFooter();
257
268
  const cwd = ctx.call("cwd") as string;
258
269
  statusFooter.update({ cwd, branch: currentGitBranch(cwd) });
259
270
  let compactions = 0;
@@ -271,12 +282,21 @@ export function mountAshi(
271
282
  statusFooter.update({ thinking: supported ? level : undefined });
272
283
  };
273
284
 
274
- tui.addChild(chat);
275
- tui.addChild(footerSlot);
276
- tui.addChild(queueSlot);
277
- tui.addChild(editor);
278
- tui.addChild(statusFooter);
279
- tui.setFocus(editor);
285
+ type ChatEntry =
286
+ | { t: "group"; group: ToolGroup }
287
+ | { t: "thinking"; ctrl: ThinkingBlock }
288
+ | { t: "assistant"; ctrl: AssistantMessage }
289
+ | { t: "pair"; result: ToolResultView }
290
+ | { t: "plain" };
291
+ const chatEntries: ChatEntry[] = [];
292
+ const appendEntry = (node: RenderNode, entry: ChatEntry): void => {
293
+ app.scrollback.addChild(node);
294
+ chatEntries.push(entry);
295
+ };
296
+ const clearChat = (): void => {
297
+ app.scrollback.clear();
298
+ chatEntries.length = 0;
299
+ };
280
300
 
281
301
  interface ToolPair { call: ToolCallView; result: ToolResultView; startedAt: number }
282
302
  type LiveToolEntry = { kind: "pair"; pair: ToolPair } | { kind: "group"; group: ToolGroup };
@@ -286,35 +306,46 @@ export function mountAshi(
286
306
  const activeTools = new Map<string, LiveToolEntry>();
287
307
  const groupMaxVisible = loadGroupMaxVisible();
288
308
 
309
+ let openGroup: ToolGroup | null = null;
310
+ const sealOpenGroup = (): void => {
311
+ if (openGroup) { openGroup.seal(); openGroup = null; }
312
+ };
313
+
289
314
  /** Visible thinking acts as a hard separator; hidden thinking is transparent. */
290
315
  const findMergeableGroup = (kind: string): ToolGroup | null => {
291
- for (let i = chat.children.length - 1; i >= 0; i--) {
292
- const c = chat.children[i]!;
293
- if (c instanceof ToolGroup) return c.kind === kind ? c : null;
294
- if (c instanceof ThinkingBlock && hideThinking) continue;
295
- if (c instanceof AssistantMessage && !c.hasContent()) continue;
316
+ for (let i = chatEntries.length - 1; i >= 0; i--) {
317
+ const e = chatEntries[i]!;
318
+ if (e.t === "group") return e.group.kind === kind ? e.group : null;
319
+ if (e.t === "thinking" && hideThinking) continue;
320
+ if (e.t === "assistant" && !e.ctrl.hasContent()) continue;
296
321
  return null;
297
322
  }
298
323
  return null;
299
324
  };
300
- let loader: Loader | null = null;
325
+ let loader: LoaderView | null = null;
326
+ let loaderGap: RenderNode | null = null;
301
327
  let processing = false;
302
- const queuedQueries: string[] = [];
328
+ type PendingImage = { id: number; data: string; mimeType: string };
329
+ let pendingImages: PendingImage[] = [];
330
+ let imageCounter = 0;
331
+ const toImageContent = (imgs: PendingImage[]) =>
332
+ imgs.map(({ data, mimeType }) => ({ type: "image" as const, data, mimeType }));
333
+ const queuedQueries: { query: string; images: PendingImage[] }[] = [];
303
334
  const queuedShellLines: { line: string; private: boolean }[] = [];
304
335
  const pendingUserShell = new UserShellIntents();
305
336
 
306
337
  const renderQueueSlot = (): void => {
307
- queueSlot.clear();
338
+ app.queueSlot.clear();
308
339
  for (const item of queuedShellLines) {
309
340
  const oneLine = item.line.replace(/\s+/g, " ");
310
341
  const preview = oneLine.length > 80 ? oneLine.slice(0, 77) + "…" : oneLine;
311
342
  const tag = item.private ? "shell·private" : "shell";
312
- queueSlot.addChild(new InfoLine(`↳ ${tag}: ${preview}`));
343
+ app.queueSlot.addChild(new InfoLine(renderer, `↳ ${tag}: ${preview}`).node);
313
344
  }
314
345
  for (const q of queuedQueries) {
315
- const oneLine = q.replace(/\s+/g, " ");
346
+ const oneLine = q.query.replace(/\s+/g, " ");
316
347
  const preview = oneLine.length > 80 ? oneLine.slice(0, 77) + "…" : oneLine;
317
- queueSlot.addChild(new InfoLine(`↳ queued: ${preview}`));
348
+ app.queueSlot.addChild(new InfoLine(renderer, `↳ queued: ${preview}`).node);
318
349
  }
319
350
  };
320
351
 
@@ -322,7 +353,7 @@ export function mountAshi(
322
353
  if (processing) {
323
354
  queuedShellLines.push({ line, private: !!opts?.private });
324
355
  renderQueueSlot();
325
- tui.requestRender();
356
+ app.requestRender();
326
357
  return;
327
358
  }
328
359
  pendingUserShell.push({ private: !!opts?.private });
@@ -331,27 +362,28 @@ export function mountAshi(
331
362
  };
332
363
  let hideThinking = true;
333
364
 
334
- const renderState = (): { state: Record<string, unknown>; invalidate: () => void } => ({
365
+ const renderState = (): RenderState => ({
335
366
  state: {},
336
- invalidate: () => tui.requestRender(),
367
+ invalidate: () => app.requestRender(),
368
+ nodes: renderer,
337
369
  });
338
370
 
339
- const tools = createToolHookResolver(ctx);
371
+ const tools = createToolHookResolver(ctx, renderer);
340
372
 
341
- const renderUserMessage = (text: string): Component =>
342
- ctx.call("ashi:render-user-message", { text, ...renderState() }) as Component;
373
+ const renderUserMessage = (text: string): RenderNode =>
374
+ (ctx.call("ashi:render-user-message", { text, ...renderState() }) as UserMessage).node;
343
375
 
344
376
  const renderAssistantLive = (): AssistantMessage =>
345
377
  ctx.call("ashi:render-assistant", { text: "", ...renderState() }) as AssistantMessage;
346
378
 
347
- const renderAssistantFinal = (text: string): Component =>
348
- ctx.call("ashi:render-assistant", { text, ...renderState() }) as Component;
379
+ const renderAssistantFinal = (text: string): AssistantMessage =>
380
+ ctx.call("ashi:render-assistant", { text, ...renderState() }) as AssistantMessage;
349
381
 
350
382
  const renderThinkingLive = (): ThinkingBlock =>
351
383
  ctx.call("ashi:render-thinking", { text: "", hidden: hideThinking, ...renderState() }) as ThinkingBlock;
352
384
 
353
- const renderThinkingFinal = (text: string): Component =>
354
- ctx.call("ashi:render-thinking", { text, hidden: hideThinking, ...renderState() }) as Component;
385
+ const renderThinkingFinal = (text: string): ThinkingBlock =>
386
+ ctx.call("ashi:render-thinking", { text, hidden: hideThinking, ...renderState() }) as ThinkingBlock;
355
387
 
356
388
  const renderToolPair = (args: {
357
389
  toolCallId: string; name: string; title: string;
@@ -370,7 +402,7 @@ export function mountAshi(
370
402
  const ensureAssistant = (): AssistantMessage => {
371
403
  if (!activeAssistant) {
372
404
  activeAssistant = renderAssistantLive();
373
- chat.addChild(activeAssistant);
405
+ appendEntry(activeAssistant.node, { t: "assistant", ctrl: activeAssistant });
374
406
  }
375
407
  return activeAssistant;
376
408
  };
@@ -385,20 +417,24 @@ export function mountAshi(
385
417
  const ensureThinking = (): ThinkingBlock => {
386
418
  if (!activeThinking) {
387
419
  activeThinking = renderThinkingLive();
388
- chat.addChild(activeThinking);
420
+ appendEntry(activeThinking.node, { t: "thinking", ctrl: activeThinking });
389
421
  }
390
422
  return activeThinking;
391
423
  };
392
424
 
393
425
  const startLoader = (): void => {
394
426
  if (loader) return;
395
- loader = new Loader(tui, fgAccent, fgMuted, "thinking…");
396
- footerSlot.addChild(loader);
427
+ loaderGap = renderer.spacer(1);
428
+ app.footerSlot.addChild(loaderGap);
429
+ loader = app.createLoader("thinking…", fgAccent, fgMuted);
430
+ app.footerSlot.addChild(loader.node);
397
431
  };
398
432
  const stopLoader = (): void => {
399
433
  if (!loader) return;
400
434
  loader.stop();
401
- footerSlot.removeChild(loader);
435
+ app.footerSlot.removeChild(loader.node);
436
+ if (loaderGap) app.footerSlot.removeChild(loaderGap);
437
+ loaderGap = null;
402
438
  loader = null;
403
439
  };
404
440
 
@@ -409,7 +445,10 @@ export function mountAshi(
409
445
  const replayEntry = (entry: SessionEntry, toolMap: Map<string, ReplayEntry>): void => {
410
446
  if (entry.type === "session") return;
411
447
  if (entry.type === "compaction") {
412
- chat.addChild(new InfoLine(`▼ compacted (firstKept=${entry.firstKeptId.slice(0, 6)}, ${entry.tokensBefore} tokens)`));
448
+ appendEntry(
449
+ new InfoLine(renderer, `▼ compacted (firstKept=${entry.firstKeptId.slice(0, 6)}, ${entry.tokensBefore} tokens)`).node,
450
+ { t: "plain" },
451
+ );
413
452
  return;
414
453
  }
415
454
  if (entry.type === "shell-exchange") {
@@ -418,27 +457,35 @@ export function mountAshi(
418
457
  toolCallId: `user-shell-replay-${entry.id}`, name, title: name,
419
458
  kind: "bash", displayDetail: entry.command, rawInput: { command: entry.command },
420
459
  });
421
- chat.addChild(pair.call);
422
- chat.addChild(pair.result);
460
+ appendEntry(pair.call.node, { t: "plain" });
461
+ appendEntry(pair.result.node, { t: "pair", result: pair.result });
423
462
  if (entry.output) pair.result.appendChunk(entry.output);
424
463
  pair.result.finalize({ exitCode: entry.exitCode });
425
464
  pair.call.setStatus({ exitCode: entry.exitCode, elapsedMs: 0 });
426
- chat.addChild(new Spacer(1));
427
465
  return;
428
466
  }
429
467
  const m = entry.message;
430
468
  if (m.role === "user") {
431
- const raw = typeof m.content === "string" ? m.content : "";
469
+ const raw = typeof m.content === "string"
470
+ ? m.content
471
+ : Array.isArray(m.content)
472
+ ? (m.content as Array<{ type?: string; text?: string }>)
473
+ .filter((p) => p.type === "text")
474
+ .map((p) => p.text ?? "")
475
+ .join("")
476
+ : "";
432
477
  if (raw.startsWith("[Compacted conversation summary]")) return;
433
- chat.addChild(renderUserMessage(stripContextWrappers(raw)));
478
+ appendEntry(renderUserMessage(stripContextWrappers(raw)), { t: "plain" });
434
479
  } else if (m.role === "assistant") {
435
480
  const reasoning = readReasoning(m);
436
481
  if (reasoning) {
437
- chat.addChild(renderThinkingFinal(reasoning));
482
+ const tb = renderThinkingFinal(reasoning);
483
+ appendEntry(tb.node, { t: "thinking", ctrl: tb });
438
484
  }
439
485
  const text = typeof m.content === "string" ? m.content : "";
440
486
  if (text) {
441
- chat.addChild(renderAssistantFinal(text));
487
+ const am = renderAssistantFinal(text);
488
+ appendEntry(am.node, { t: "assistant", ctrl: am });
442
489
  }
443
490
  if (m.tool_calls) {
444
491
  for (const tc of m.tool_calls) {
@@ -446,10 +493,10 @@ export function mountAshi(
446
493
  const id = tc.id ?? "";
447
494
  const name = tc.function.name ?? "tool";
448
495
  const kind = TOOL_KIND[name];
449
- if (kind && GROUPABLE_KINDS.has(kind)) {
496
+ if (kind && GROUPABLE_KINDS.has(kind) && renderer.mountToolGroup) {
450
497
  const mergeable = findMergeableGroup(kind);
451
498
  const group = mergeable
452
- ?? (() => { const g = new ToolGroup(kind, groupMaxVisible); chat.addChild(g); return g; })();
499
+ ?? (() => { const g = new ToolGroup(renderer, kind, groupMaxVisible); appendEntry(g.node, { t: "group", group: g }); return g; })();
453
500
  group.addCall(id, name, detailFromArgs(tc.function.arguments));
454
501
  if (id) toolMap.set(id, { kind: "group", group, name });
455
502
  continue;
@@ -459,8 +506,8 @@ export function mountAshi(
459
506
  displayDetail: detailFromArgs(tc.function.arguments),
460
507
  rawInput: tc.function.arguments,
461
508
  });
462
- chat.addChild(pair.call);
463
- chat.addChild(pair.result);
509
+ appendEntry(pair.call.node, { t: "plain" });
510
+ appendEntry(pair.result.node, { t: "pair", result: pair.result });
464
511
  if (id) toolMap.set(id, { kind: "pair", pair, name });
465
512
  }
466
513
  }
@@ -469,7 +516,7 @@ export function mountAshi(
469
516
  const text = typeof m.content === "string" ? m.content : "";
470
517
  const found = id ? toolMap.get(id) : undefined;
471
518
  if (!found) {
472
- chat.addChild(new InfoLine(`tool result (no matching call): ${text.slice(0, 80)}`));
519
+ appendEntry(new InfoLine(renderer, `tool result (no matching call): ${text.slice(0, 80)}`).node, { t: "plain" });
473
520
  return;
474
521
  }
475
522
  const summary = inferSummary(found.name, text);
@@ -480,7 +527,7 @@ export function mountAshi(
480
527
  if (meta?.diff && typeof meta.filePath === "string") {
481
528
  const diff = meta.diff as DiffStats & Parameters<typeof renderDiff>[0];
482
529
  if (!diff.isIdentical) {
483
- found.pair.result.setDiffRenderer(buildDiffRenderer(diff, meta.filePath));
530
+ found.pair.result.setDiffRenderer(buildDiffRenderer(diff, meta.filePath, renderer.capabilities.diffFrame !== false));
484
531
  }
485
532
  }
486
533
  found.pair.result.finalize({ exitCode: 0, summary });
@@ -494,66 +541,78 @@ export function mountAshi(
494
541
  activeAssistant = null;
495
542
  activeThinking = null;
496
543
  activeTools.clear();
497
- chat.clear();
544
+ openGroup = null;
545
+ clearChat();
498
546
  const branch = getStore().current().getBranch();
499
547
  const toolMap = new Map<string, ReplayEntry>();
500
548
  for (const e of branch) replayEntry(e, toolMap);
501
- chat.addChild(new Spacer(1));
502
- tui.requestRender();
549
+ for (const entry of chatEntries) if (entry.t === "group") entry.group.seal();
550
+ app.commitScrollback?.();
551
+ app.requestRender();
503
552
  };
504
553
 
505
554
  bus.on("agent:query", ({ query }) => {
506
- chat.addChild(renderUserMessage(query));
555
+ app.commitScrollback?.();
556
+ sealOpenGroup();
557
+ appendEntry(renderUserMessage(query), { t: "plain" });
507
558
  activeAssistant = null;
508
- tui.requestRender();
559
+ app.requestRender();
509
560
  });
510
561
 
511
562
  bus.on("agent:processing-start", () => {
512
563
  processing = true;
513
564
  startLoader();
514
- tui.requestRender();
565
+ app.requestRender();
515
566
  });
516
567
 
517
- const imageComponentFromPng = (data: Buffer): Image | null => {
518
- const base64 = data.toString("base64");
519
- const dims = getImageDimensions(base64, "image/png");
520
- if (!dims) return null;
521
- return new Image(
522
- base64, "image/png",
523
- { fallbackColor: (t) => theme.fg("muted", t) },
524
- { maxWidthCells: 60, maxHeightCells: 20 },
525
- dims,
526
- );
527
- };
528
-
529
- /** Drop the live assistant so subsequent text starts fresh markdown below the image. */
530
568
  const appendImage = (data: Buffer): void => {
531
- const img = imageComponentFromPng(data);
569
+ const img = renderer.image(data);
532
570
  if (!img) return;
571
+ sealOpenGroup();
533
572
  if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
534
- chat.addChild(img);
573
+ appendEntry(img, { t: "plain" });
535
574
  };
536
575
 
537
576
  /** tui-renderer normally owns this hook; ashi disables it and provides its own. */
538
577
  ctx.define("render:image", (data: Buffer) => {
539
578
  appendImage(data);
540
- tui.requestRender();
579
+ app.requestRender();
580
+ });
581
+
582
+ // Only [Image #N] markers still in the text at submit are sent — deleting one drops its image.
583
+ const attachImage = (img: { data: string; mimeType: string }): void => {
584
+ const id = ++imageCounter;
585
+ pendingImages.push({ id, data: img.data, mimeType: img.mimeType });
586
+ input.replaceBeforeCursor(0, `[Image #${id}] `);
587
+ app.requestRender();
588
+ };
589
+ // Ctrl+V (wired below) and /paste capture a clipboard image; Cmd+V stays text paste.
590
+ const captureClipboardImage = (): void => {
591
+ void readClipboardImage().then((img) => {
592
+ if (img) attachImage(img);
593
+ else bus.emit("ui:info", { message: "No image found on the clipboard." });
594
+ });
595
+ };
596
+ ctx.registerCommand("paste", "Attach an image from the clipboard to your next message", async () => {
597
+ captureClipboardImage();
541
598
  });
542
599
 
543
600
  bus.on("agent:response-chunk", ({ blocks }) => {
601
+ sealOpenGroup();
544
602
  finalizeThinking();
545
603
  for (const b of blocks) {
546
604
  if (b.type === "text") ensureAssistant().appendText(b.text);
547
605
  else if (b.type === "code-block") ensureAssistant().appendCodeBlock(b.language, b.code);
548
606
  else if (b.type === "image") appendImage(b.data);
549
607
  }
550
- tui.requestRender();
608
+ app.requestRender();
551
609
  });
552
610
 
553
611
  bus.on("agent:thinking-chunk", ({ text }) => {
612
+ if (!hideThinking) sealOpenGroup();
554
613
  if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
555
614
  ensureThinking().appendText(text);
556
- tui.requestRender();
615
+ app.requestRender();
557
616
  });
558
617
 
559
618
  bus.on("agent:tool-started", (e) => {
@@ -570,31 +629,34 @@ export function mountAshi(
570
629
  );
571
630
 
572
631
  const kind = e.kind ?? "";
573
- if (GROUPABLE_KINDS.has(kind)) {
632
+ if (GROUPABLE_KINDS.has(kind) && renderer.mountToolGroup) {
574
633
  const mergeable = findMergeableGroup(kind);
634
+ if (!mergeable) sealOpenGroup();
575
635
  const group = mergeable
576
- ?? (() => { const g = new ToolGroup(kind, groupMaxVisible); chat.addChild(g); return g; })();
636
+ ?? (() => { const g = new ToolGroup(renderer, kind, groupMaxVisible); appendEntry(g.node, { t: "group", group: g }); return g; })();
577
637
  group.addCall(id, lookupName, detail);
638
+ openGroup = group;
578
639
  activeTools.set(id, { kind: "group", group });
579
- tui.requestRender();
640
+ app.requestRender();
580
641
  return;
581
642
  }
582
643
 
644
+ sealOpenGroup();
583
645
  const pair = renderToolPair({
584
646
  toolCallId: id, name: lookupName, title, kind: e.kind,
585
647
  displayDetail: detail, rawInput: e.rawInput,
586
648
  });
587
649
  activeTools.set(id, { kind: "pair", pair });
588
- chat.addChild(pair.call);
589
- chat.addChild(pair.result);
590
- tui.requestRender();
650
+ appendEntry(pair.call.node, { t: "plain" });
651
+ appendEntry(pair.result.node, { t: "pair", result: pair.result });
652
+ app.requestRender();
591
653
  });
592
654
 
593
655
  bus.on("agent:tool-output-chunk", ({ chunk }) => {
594
656
  for (const entry of [...activeTools.values()].reverse()) {
595
657
  if (entry.kind === "pair") {
596
658
  entry.pair.result.appendChunk(chunk);
597
- tui.requestRender();
659
+ app.requestRender();
598
660
  return;
599
661
  }
600
662
  }
@@ -609,7 +671,7 @@ export function mountAshi(
609
671
  if (entry.kind === "group") {
610
672
  entry.group.recordCompletion(id, e.exitCode, summary);
611
673
  activeTools.delete(id);
612
- tui.requestRender();
674
+ app.requestRender();
613
675
  return;
614
676
  }
615
677
  const pair = entry.pair;
@@ -617,17 +679,16 @@ export function mountAshi(
617
679
  if (body?.kind === "diff") {
618
680
  const diff = body.diff as DiffStats & Parameters<typeof renderDiff>[0];
619
681
  if (!diff.isIdentical) {
620
- pair.result.setDiffRenderer(buildDiffRenderer(diff, body.filePath));
682
+ pair.result.setDiffRenderer(buildDiffRenderer(diff, body.filePath, renderer.capabilities.diffFrame !== false));
621
683
  }
622
684
  }
623
685
  pair.call.setStatus({ exitCode: e.exitCode, elapsedMs: Date.now() - pair.startedAt, summary });
624
686
  pair.result.finalize({ exitCode: e.exitCode, summary });
625
687
  activeTools.delete(id);
626
- tui.requestRender();
688
+ app.requestRender();
627
689
  });
628
690
 
629
- // agent:tool-* listeners already render agent-issued bash; the shell:* path
630
- // is only for user-issued `!` commands.
691
+ // shell:* path is only for user-issued `!` commands; agent bash renders via agent:tool-*.
631
692
  let agentShellActive = false;
632
693
  let shellForegroundBusy = false;
633
694
  bus.on("shell:agent-exec-start", () => { agentShellActive = true; });
@@ -648,9 +709,9 @@ export function mountAshi(
648
709
  kind: "bash", displayDetail: command, rawInput: { command },
649
710
  });
650
711
  activeUserShell = { pair, command, isPrivate };
651
- chat.addChild(pair.call);
652
- chat.addChild(pair.result);
653
- tui.requestRender();
712
+ appendEntry(pair.call.node, { t: "plain" });
713
+ appendEntry(pair.result.node, { t: "pair", result: pair.result });
714
+ app.requestRender();
654
715
  });
655
716
 
656
717
  bus.on("shell:command-done", ({ output, cwd, exitCode }) => {
@@ -661,8 +722,7 @@ export function mountAshi(
661
722
  pair.call.setStatus({ exitCode, elapsedMs: Date.now() - pair.startedAt });
662
723
  pair.result.finalize({ exitCode });
663
724
  activeUserShell = null;
664
- chat.addChild(new Spacer(1));
665
- tui.requestRender();
725
+ app.requestRender();
666
726
  void getStore().current().appendShellExchange({
667
727
  command, output: output ?? "", exitCode, cwd,
668
728
  ...(isPrivate ? { private: true } : {}),
@@ -673,12 +733,12 @@ export function mountAshi(
673
733
  bus.on("agent:processing-done", () => {
674
734
  processing = false;
675
735
  stopLoader();
736
+ sealOpenGroup();
676
737
  finalizeThinking();
677
738
  if (activeAssistant) activeAssistant.finalize();
678
- chat.addChild(new Spacer(1));
679
739
  refreshFooterStats();
680
740
  refreshBranch();
681
- // Shell queue drains first so its output lands in the next turn's <shell_events>.
741
+ // Drain shell queue before queries so its output lands in the next turn's <shell_events>.
682
742
  while (queuedShellLines.length > 0) {
683
743
  const item = queuedShellLines.shift()!;
684
744
  pendingUserShell.push({ private: item.private });
@@ -688,42 +748,44 @@ export function mountAshi(
688
748
  const next = queuedQueries.shift();
689
749
  if (next !== undefined) {
690
750
  renderQueueSlot();
691
- bus.emit("agent:submit", { query: next });
751
+ bus.emit("agent:submit", { query: next.query, images: next.images.length ? toImageContent(next.images) : undefined });
692
752
  } else {
693
753
  renderQueueSlot();
694
754
  }
695
- tui.requestRender();
755
+ app.requestRender();
696
756
  });
697
757
 
698
758
  bus.on("agent:usage", (u) => {
699
759
  if (u.prompt_tokens > 0) {
700
760
  statusFooter.update({ tokens: u.prompt_tokens });
701
- tui.requestRender();
761
+ app.requestRender();
702
762
  }
703
763
  });
704
764
 
705
765
  bus.on("agent:cancelled", () => {
706
766
  processing = false;
707
767
  stopLoader();
708
- chat.addChild(new InfoLine("cancelled"));
709
- tui.requestRender();
768
+ sealOpenGroup();
769
+ appendEntry(new InfoLine(renderer, "cancelled").node, { t: "plain" });
770
+ app.requestRender();
710
771
  });
711
772
 
712
773
  bus.on("agent:error", ({ message }) => {
713
774
  processing = false;
714
775
  stopLoader();
715
- chat.addChild(new ErrorLine(message));
716
- tui.requestRender();
776
+ sealOpenGroup();
777
+ appendEntry(new ErrorLine(renderer, message).node, { t: "plain" });
778
+ app.requestRender();
717
779
  });
718
780
 
719
781
  bus.on("ui:info", ({ message }) => {
720
- chat.addChild(new InfoLine(message));
721
- tui.requestRender();
782
+ appendEntry(new InfoLine(renderer, message).node, { t: "plain" });
783
+ app.requestRender();
722
784
  });
723
785
 
724
786
  bus.on("ui:error", ({ message }) => {
725
- chat.addChild(new ErrorLine(message));
726
- tui.requestRender();
787
+ appendEntry(new ErrorLine(renderer, message).node, { t: "plain" });
788
+ app.requestRender();
727
789
  });
728
790
 
729
791
  bus.on("agent:info", (info) => {
@@ -733,25 +795,25 @@ export function mountAshi(
733
795
  contextWindow: info.contextWindow,
734
796
  });
735
797
  refreshThinking();
736
- tui.requestRender();
798
+ app.requestRender();
737
799
  });
738
800
 
739
801
  bus.on("config:changed", () => {
740
802
  refreshThinking();
741
- tui.requestRender();
803
+ app.requestRender();
742
804
  });
743
805
 
744
806
  bus.on("conversation:after-compact", () => {
745
807
  compactions++;
746
808
  statusFooter.update({ compactions });
747
809
  refreshFooterStats();
748
- tui.requestRender();
810
+ app.requestRender();
749
811
  });
750
812
 
751
813
  refreshFooterStats();
752
814
 
753
815
  let pickerOpen = false;
754
- let activeSessionPicker: SelectList | null = null;
816
+ let activeSessionPicker: SelectView | null = null;
755
817
  let activeSessionRepopulate: ((keepIndex?: number) => boolean) | null = null;
756
818
  let activeSessionClose: (() => void) | null = null;
757
819
 
@@ -806,8 +868,6 @@ export function mountAshi(
806
868
  const only = byId.get(kids[0]!);
807
869
  const isTip = !!only && !(only.type === "message" && only.message.role === "user");
808
870
  if (isTip) {
809
- // Render the tip as a branch-child so it gets a `└` connector at
810
- // a deeper indent, visually "the next node in this branch."
811
871
  walk(kids[0]!, [...lineage, " "], true);
812
872
  } else {
813
873
  walk(kids[0]!, lineage, false);
@@ -850,18 +910,18 @@ export function mountAshi(
850
910
  const label = r.id === activeLeaf ? "● current" : "leaf";
851
911
  return { value: `tip:${r.id}`, label: `${treePrefix}${label}` };
852
912
  });
853
- const picker = new SelectList(items, 15, selectListTheme());
913
+ const picker = app.createSelectList(items, { visibleRows: 15 });
854
914
  const activeIdx = items.findIndex((it) => it.value === `tip:${activeLeaf}`);
855
915
  picker.setSelectedIndex(activeIdx >= 0 ? activeIdx : items.length - 1);
856
916
 
857
917
  const close = (): void => {
858
918
  pickerOpen = false;
859
- footerSlot.removeChild(picker);
860
- tui.setFocus(editor);
861
- tui.requestRender();
919
+ app.footerSlot.removeChild(picker.node);
920
+ app.focusInput();
921
+ app.requestRender();
862
922
  };
863
923
 
864
- picker.onSelect = async (item) => {
924
+ picker.onSelect(async (item) => {
865
925
  close();
866
926
  const [kind, id] = item.value.split(":") as ["msg" | "tip", string];
867
927
  if (kind === "tip") {
@@ -879,37 +939,37 @@ export function mountAshi(
879
939
  store.setActiveLeaf(targetLeaf);
880
940
  applyBranchMessages(ctx, getStore, capture);
881
941
  const raw = typeof entry.message.content === "string" ? entry.message.content : "";
882
- editor.setText(stripContextWrappers(raw));
942
+ input.setText(stripContextWrappers(raw));
883
943
  bus.emit("ui:info", { message: `fork: rewound to ${targetLeaf.slice(0, 6)}` });
884
944
  await rebuildChat();
885
945
  refreshFooterStats();
886
- };
887
- picker.onCancel = close;
946
+ });
947
+ picker.onCancel(close);
888
948
 
889
949
  pickerOpen = true;
890
- footerSlot.addChild(picker);
891
- tui.setFocus(picker);
892
- tui.requestRender();
950
+ app.footerSlot.addChild(picker.node);
951
+ app.setFocus(picker.node);
952
+ app.requestRender();
893
953
  };
894
954
 
895
955
  const openSessionPicker = async (): Promise<void> => {
896
956
  if (pickerOpen) return;
897
957
 
898
- const hint = new InfoLine("↑↓ move · enter: resume · d: delete · esc: cancel");
958
+ const hint = new InfoLine(renderer, "↑↓ move · enter: resume · d: delete · esc: cancel");
899
959
 
900
960
  const close = (): void => {
901
- if (activeSessionPicker) footerSlot.removeChild(activeSessionPicker);
902
- footerSlot.removeChild(hint);
961
+ if (activeSessionPicker) app.footerSlot.removeChild(activeSessionPicker.node);
962
+ app.footerSlot.removeChild(hint.node);
903
963
  activeSessionPicker = null;
904
964
  activeSessionRepopulate = null;
905
965
  activeSessionClose = null;
906
966
  pickerOpen = false;
907
- tui.setFocus(editor);
908
- tui.requestRender();
967
+ app.focusInput();
968
+ app.requestRender();
909
969
  };
910
970
 
911
971
  const populate = (keepIndex?: number): boolean => {
912
- if (activeSessionPicker) footerSlot.removeChild(activeSessionPicker);
972
+ if (activeSessionPicker) app.footerSlot.removeChild(activeSessionPicker.node);
913
973
  const currentId = getStore().current().id;
914
974
  const list = getStore().listSessions().filter((s) => s.id !== currentId);
915
975
  if (list.length === 0) {
@@ -920,69 +980,103 @@ export function mountAshi(
920
980
  value: s.id,
921
981
  label: formatSessionRow(s, false),
922
982
  }));
923
- const picker = new SelectList(items, 15, selectListTheme());
983
+ const picker = app.createSelectList(items, { visibleRows: 15 });
924
984
  if (keepIndex !== undefined) {
925
985
  picker.setSelectedIndex(Math.min(keepIndex, items.length - 1));
926
986
  }
927
- picker.onSelect = async (item) => {
987
+ picker.onSelect(async (item) => {
928
988
  const id = item.value;
929
989
  close();
930
990
  resumeSession(ctx, getStore, capture, id);
931
991
  bus.emit("ui:info", { message: `resumed session ${id}` });
932
992
  await rebuildChat();
933
993
  refreshFooterStats();
934
- };
935
- picker.onCancel = close;
994
+ });
995
+ picker.onCancel(close);
936
996
  activeSessionPicker = picker;
937
- footerSlot.addChild(picker);
938
- tui.setFocus(picker);
997
+ app.footerSlot.addChild(picker.node);
998
+ app.setFocus(picker.node);
939
999
  return true;
940
1000
  };
941
1001
 
942
- footerSlot.addChild(hint);
1002
+ app.footerSlot.addChild(hint.node);
943
1003
  if (!populate()) {
944
- footerSlot.removeChild(hint);
1004
+ app.footerSlot.removeChild(hint.node);
945
1005
  bus.emit("ui:info", { message: "no past sessions in this cwd" });
946
1006
  return;
947
1007
  }
948
1008
  pickerOpen = true;
949
1009
  activeSessionRepopulate = populate;
950
1010
  activeSessionClose = close;
951
- tui.requestRender();
1011
+ app.requestRender();
952
1012
  };
953
1013
 
954
1014
  const toggleThinking = (): void => {
955
1015
  hideThinking = !hideThinking;
956
- if (processing) {
957
- // Mid-turn: only show/hide existing nodes. Group-merging respects the
958
- // new flag for future calls; next idle rebuild reflows everything.
959
- const walk = (node: Container): void => {
960
- for (const child of node.children) {
961
- if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
962
- else if (child instanceof Container) walk(child);
963
- }
964
- };
965
- walk(chat);
966
- tui.requestRender();
967
- return;
1016
+ // Reasoning isn't persisted; toggle live controllers instead of rebuilding.
1017
+ for (const e of chatEntries) {
1018
+ if (e.t === "thinking") e.ctrl.setHidden(hideThinking);
968
1019
  }
969
- void rebuildChat();
1020
+ app.requestRender();
970
1021
  };
971
1022
 
972
- tui.addInputListener((data) => {
973
- if (isKeyRelease(data) || isKeyRepeat(data)) return;
974
- if (matchesKey(data, "escape")) {
1023
+ const jobControl = process.platform !== "win32";
1024
+ let suspended = false;
1025
+ let terminalYielded = false;
1026
+ const resumeFromSuspend = (): void => {
1027
+ if (!suspended) return;
1028
+ suspended = false;
1029
+ applyOutputMode(renderer.capabilities.rawOutput);
1030
+ app.start();
1031
+ app.requestRender(true);
1032
+ };
1033
+ const suspendToShell = (): void => {
1034
+ if (suspended || terminalYielded) return;
1035
+ suspended = true;
1036
+ app.stop();
1037
+ process.kill(process.pid, "SIGSTOP");
1038
+ };
1039
+ if (jobControl) process.on("SIGCONT", resumeFromSuspend);
1040
+
1041
+ ctx.define("ashi:terminal:yield", async (run: () => unknown | Promise<unknown>) => {
1042
+ if (terminalYielded || suspended) return;
1043
+ terminalYielded = true;
1044
+ const wasRaw = process.stdin.isRaw;
1045
+ app.stop();
1046
+ applyOutputMode(true);
1047
+ process.stdin.setRawMode?.(true);
1048
+ process.stdin.resume();
1049
+ try {
1050
+ return await run();
1051
+ } finally {
1052
+ process.stdin.setRawMode?.(wasRaw);
1053
+ applyOutputMode(renderer.capabilities.rawOutput);
1054
+ app.start();
1055
+ app.requestRender(true);
1056
+ terminalYielded = false;
1057
+ }
1058
+ });
1059
+
1060
+ ctx.define("ashi:on-key", (handler: KeyHandler) => app.onKey(handler));
1061
+
1062
+ app.onKey((key: KeyEvent) => {
1063
+ if (key.isRelease() || key.isRepeat()) return;
1064
+ if (key.matches("ctrl+v")) {
1065
+ captureClipboardImage();
1066
+ return { consume: true };
1067
+ }
1068
+ if (key.matches("escape")) {
975
1069
  if (processing) {
976
1070
  bus.emit("agent:cancel-request", {});
977
1071
  return { consume: true };
978
1072
  }
979
1073
  if (shellForegroundBusy) {
980
- // Real ^C byte; PTY translates it to SIGINT for the foreground process.
1074
+ // Literal ^C byte; PTY translates to SIGINT for the foreground process.
981
1075
  bus.emit("shell:pty-write", { data: "\x03" });
982
1076
  return { consume: true };
983
1077
  }
984
1078
  }
985
- if (activeSessionPicker && matchesKey(data, "d")) {
1079
+ if (activeSessionPicker && key.matches("d")) {
986
1080
  const selected = activeSessionPicker.getSelectedItem();
987
1081
  if (selected) {
988
1082
  const currentId = getStore().current().id;
@@ -996,37 +1090,41 @@ export function mountAshi(
996
1090
  return { consume: true };
997
1091
  }
998
1092
  if (!activeSessionRepopulate?.(idx)) activeSessionClose?.();
999
- tui.requestRender();
1093
+ app.requestRender();
1000
1094
  }
1001
1095
  return { consume: true };
1002
1096
  }
1003
- if (matchesKey(data, "up") && queuedQueries.length > 0 && editor.getText().length === 0) {
1097
+ if (key.matches("up") && queuedQueries.length > 0 && input.getText().length === 0) {
1004
1098
  const last = queuedQueries.pop()!;
1005
1099
  renderQueueSlot();
1006
- editor.setText(last);
1007
- tui.requestRender();
1100
+ input.setText(last.query);
1101
+ pendingImages = last.images;
1102
+ app.requestRender();
1008
1103
  return { consume: true };
1009
1104
  }
1010
- if (matchesKey(data, "backspace") && shellMode && editor.getText().length === 0) {
1011
- // Editor swallows backspace-on-empty; two-step exit: first clears the
1012
- // private signal, second exits shell mode entirely.
1105
+ if (key.matches("backspace") && shellMode && input.getText().length === 0) {
1106
+ // Two-step exit: first backspace clears the private signal, second exits shell mode.
1013
1107
  if (pendingPrivate) setPendingPrivate(false);
1014
1108
  else setShellMode(false);
1015
1109
  return { consume: true };
1016
1110
  }
1017
- if (matchesKey(data, "ctrl+c")) {
1018
- editor.setText("");
1111
+ if (key.matches("ctrl+c")) {
1112
+ input.setText("");
1019
1113
  return { consume: true };
1020
1114
  }
1021
- if (matchesKey(data, "ctrl+d") && editor.getText().length === 0) {
1115
+ if (key.matches("ctrl+d") && input.getText().length === 0) {
1022
1116
  ctx.quit();
1023
1117
  return { consume: true };
1024
1118
  }
1025
- if (matchesKey(data, "ctrl+t")) {
1119
+ if (jobControl && key.matches("ctrl+z")) {
1120
+ suspendToShell();
1121
+ return { consume: true };
1122
+ }
1123
+ if (key.matches("ctrl+t")) {
1026
1124
  toggleThinking();
1027
1125
  return { consume: true };
1028
1126
  }
1029
- if (matchesKey(data, "shift+tab")) {
1127
+ if (key.matches("shift+tab")) {
1030
1128
  const { level, levels, supported } = bus.emitPipe("config:get-thinking", {
1031
1129
  level: "off", levels: [] as string[], supported: true,
1032
1130
  });
@@ -1036,43 +1134,26 @@ export function mountAshi(
1036
1134
  }
1037
1135
  return { consume: true };
1038
1136
  }
1039
- if (matchesKey(data, "ctrl+o")) {
1040
- const toggle = (node: Container): void => {
1041
- for (const child of node.children) {
1042
- const fn = (child as unknown as { toggleExpanded?: () => void }).toggleExpanded;
1043
- if (typeof fn === "function") fn.call(child);
1044
- if (child instanceof Container) toggle(child);
1045
- }
1046
- };
1047
- toggle(chat);
1048
- tui.requestRender();
1137
+ if (key.matches("ctrl+o")) {
1138
+ for (const e of chatEntries) {
1139
+ if (e.t === "group") e.group.toggleExpanded();
1140
+ else if (e.t === "pair") e.result.toggleExpanded();
1141
+ }
1142
+ app.requestRender();
1049
1143
  return { consume: true };
1050
1144
  }
1051
1145
  return undefined;
1052
1146
  });
1053
1147
 
1054
- tui.start();
1148
+ app.start();
1055
1149
 
1056
1150
  return {
1057
- tui,
1058
- stop: () => { tui.stop(); },
1151
+ stop: () => {
1152
+ process.off("SIGCONT", resumeFromSuspend);
1153
+ app.stop();
1154
+ },
1059
1155
  openTreePicker,
1060
1156
  openSessionPicker,
1061
1157
  rebuildChat,
1062
1158
  };
1063
1159
  }
1064
-
1065
- function isForkAnchor(e: SessionEntry): boolean {
1066
- if (e.type === "session" || e.type === "compaction") return true;
1067
- return e.type === "message" && e.message.role === "user";
1068
- }
1069
-
1070
- function pickerLabel(e: SessionEntry, isActive: boolean): string {
1071
- const marker = isActive ? "●" : "│";
1072
- const short = e.id.slice(0, 6);
1073
- if (e.type === "session") return `${marker} ${short} session start`;
1074
- if (e.type === "compaction") return `${marker} ${short} ▼ compacted (firstKept=${e.firstKeptId.slice(0, 6)})`;
1075
- const raw = e.type === "message" && typeof e.message.content === "string" ? e.message.content : "";
1076
- const text = stripContextWrappers(raw).slice(0, 70).replace(/\n/g, " ");
1077
- return `${marker} ${short} ${text}`;
1078
- }