agent-sh 0.14.1 → 0.14.3

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 (65) hide show
  1. package/dist/agent/agent-loop.d.ts +1 -1
  2. package/dist/agent/agent-loop.js +42 -31
  3. package/dist/agent/conversation-state.d.ts +3 -2
  4. package/dist/agent/conversation-state.js +20 -3
  5. package/dist/agent/events.d.ts +2 -0
  6. package/dist/agent/host-types.d.ts +3 -0
  7. package/dist/agent/index.js +2 -1
  8. package/dist/agent/subagent.d.ts +1 -1
  9. package/dist/agent/subagent.js +5 -1
  10. package/dist/agent/tool-protocol.d.ts +2 -2
  11. package/dist/agent/tool-protocol.js +5 -4
  12. package/dist/agent/tools/glob.d.ts +1 -1
  13. package/dist/agent/tools/glob.js +4 -2
  14. package/dist/agent/tools/grep.d.ts +1 -1
  15. package/dist/agent/tools/grep.js +4 -2
  16. package/dist/agent/tools/ls.d.ts +1 -1
  17. package/dist/agent/tools/ls.js +4 -2
  18. package/dist/agent/tools/read-file.d.ts +1 -1
  19. package/dist/agent/tools/read-file.js +30 -2
  20. package/dist/agent/types.d.ts +11 -1
  21. package/dist/agent/types.js +6 -1
  22. package/dist/cli/index.js +0 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/settings.d.ts +3 -0
  25. package/dist/core/settings.js +2 -2
  26. package/dist/shell/events.d.ts +2 -0
  27. package/dist/shell/index.d.ts +6 -0
  28. package/dist/shell/index.js +10 -10
  29. package/dist/shell/output-parser.d.ts +11 -22
  30. package/dist/shell/output-parser.js +16 -34
  31. package/dist/shell/shell-context.d.ts +3 -6
  32. package/dist/shell/shell-context.js +15 -7
  33. package/dist/shell/shell.d.ts +4 -0
  34. package/dist/shell/shell.js +18 -30
  35. package/dist/shell/strategies/types.d.ts +6 -0
  36. package/dist/shell/strategies/zsh.js +7 -0
  37. package/dist/shell/terminal.d.ts +33 -0
  38. package/dist/shell/terminal.js +62 -0
  39. package/examples/extensions/ash-scheme/index.ts +2170 -0
  40. package/examples/extensions/ash-scheme/package.json +11 -0
  41. package/examples/extensions/ash-scheme-render.ts +58 -0
  42. package/examples/extensions/ashi/README.md +36 -26
  43. package/examples/extensions/ashi/package.json +9 -1
  44. package/examples/extensions/ashi/src/capture.ts +1 -0
  45. package/examples/extensions/ashi/src/cli.ts +53 -11
  46. package/examples/extensions/ashi/src/commands.ts +2 -20
  47. package/examples/extensions/ashi/src/compaction.ts +25 -96
  48. package/examples/extensions/ashi/src/components.ts +64 -166
  49. package/examples/extensions/ashi/src/default-schema-renderers.ts +232 -0
  50. package/examples/extensions/ashi/src/display-config.ts +21 -22
  51. package/examples/extensions/ashi/src/frontend.ts +355 -118
  52. package/examples/extensions/ashi/src/hooks.ts +47 -63
  53. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  54. package/examples/extensions/ashi/src/schema.ts +386 -0
  55. package/examples/extensions/ashi/src/session-store.ts +115 -17
  56. package/examples/extensions/ashi/src/shell-mode.ts +52 -0
  57. package/examples/extensions/ashi/src/status-footer.ts +41 -6
  58. package/examples/extensions/ashi/src/theme.ts +2 -1
  59. package/examples/extensions/ashi-compact-llm.ts +93 -0
  60. package/examples/extensions/claude-code-bridge/index.ts +2 -0
  61. package/examples/extensions/opencode-bridge/index.ts +3 -0
  62. package/examples/extensions/opencode-provider.ts +252 -0
  63. package/examples/extensions/pi-bridge/index.ts +1 -0
  64. package/package.json +16 -1
  65. package/examples/extensions/ashi/src/default-renderers.ts +0 -171
@@ -7,6 +7,7 @@ import {
7
7
  Loader,
8
8
  SelectList,
9
9
  Spacer,
10
+ Text,
10
11
  type Component,
11
12
  type SelectItem,
12
13
  getImageDimensions,
@@ -25,6 +26,8 @@ import {
25
26
  } from "./components.js";
26
27
  import type { ToolCallView, ToolResultView } from "./hooks.js";
27
28
  import { createToolHookResolver } from "./hooks.js";
29
+ import { loadGroupMaxVisible } from "./display-config.js";
30
+ import { classifySubmit, deriveChangeHandlerResult } from "./shell-mode.js";
28
31
 
29
32
  const GROUPABLE_KINDS = new Set(["read", "search"]);
30
33
  const TOOL_KIND: Record<string, string> = {
@@ -34,7 +37,7 @@ const TOOL_KIND: Record<string, string> = {
34
37
  import { BusAutocompleteProvider } from "./autocomplete.js";
35
38
  import { StatusFooter } from "./status-footer.js";
36
39
  import type { MultiSessionStore } from "./multi-session-store.js";
37
- import type { SessionEntry } from "./session-store.js";
40
+ import { stripContextWrappers, type SessionEntry } from "./session-store.js";
38
41
  import { formatSessionRow } from "./session-commands.js";
39
42
  import { resumeSession } from "./session-commands.js";
40
43
  import { applyBranchMessages } from "./commands.js";
@@ -120,13 +123,16 @@ function detailFromArgs(argsJson: string | undefined): string {
120
123
  if (typeof args.path === "string") return relativize(args.path);
121
124
  if (typeof args.file_path === "string") return relativize(args.file_path);
122
125
  if (typeof args.query === "string") return `"${args.query}"`;
126
+ if (typeof args.source === "string") {
127
+ const compact = args.source.replace(/\s+/g, " ").trim();
128
+ return compact.length > 80 ? compact.slice(0, 77) + "…" : compact;
129
+ }
123
130
  } catch { /* fall through */ }
124
131
  return "";
125
132
  }
126
133
 
127
- /** Recompute the per-tool summary from a saved tool result message. We don't
128
- * persist resultDisplay, so /resume would otherwise lose "16 entries" / "117
129
- * lines" etc. Mirrors agent-sh's formatResult logic for the common tools. */
134
+ /** resultDisplay isn't persisted, so /resume rebuilds the "16 entries" / "117
135
+ * lines" hints from saved tool output. */
130
136
  function inferSummary(toolName: string, content: unknown): string | undefined {
131
137
  if (typeof content !== "string" || content.length === 0) return undefined;
132
138
  const lines = content.split("\n").filter((l) => l.length > 0);
@@ -177,25 +183,73 @@ export function mountAshi(
177
183
  const footerSlot = new Container();
178
184
  const queueSlot = new Container();
179
185
  const editor = new Editor(tui, editorTheme(), { paddingX: 1 });
180
- editor.setAutocompleteProvider(new BusAutocompleteProvider(bus));
186
+
187
+ let shellMode = false;
188
+ let pendingPrivate = false;
189
+ const baseAutocomplete = new BusAutocompleteProvider(bus);
190
+ editor.setAutocompleteProvider({
191
+ getSuggestions: async (lines, line, col) =>
192
+ shellMode ? null : baseAutocomplete.getSuggestions(lines, line, col),
193
+ applyCompletion: baseAutocomplete.applyCompletion.bind(baseAutocomplete),
194
+ });
195
+
196
+ const defaultBorderColor = editor.borderColor;
197
+ const shellBorderColor = (t: string): string => theme.fg("bashMode", t);
198
+ const privateBorderColor = (t: string): string => theme.fg("bashModePrivate", t);
199
+ const refreshShellChrome = (): void => {
200
+ editor.borderColor = shellMode
201
+ ? (pendingPrivate ? privateBorderColor : shellBorderColor)
202
+ : defaultBorderColor;
203
+ editor.invalidate();
204
+ statusFooter.update({
205
+ shellMode: shellMode ? (pendingPrivate ? "private" : "on") : "off",
206
+ });
207
+ tui.requestRender();
208
+ };
209
+ const setShellMode = (on: boolean): void => {
210
+ if (shellMode === on) return;
211
+ shellMode = on;
212
+ if (!on) pendingPrivate = false;
213
+ refreshShellChrome();
214
+ };
215
+ const setPendingPrivate = (on: boolean): void => {
216
+ if (pendingPrivate === on) return;
217
+ pendingPrivate = on;
218
+ refreshShellChrome();
219
+ };
220
+
221
+ editor.onChange = (text) => {
222
+ const r = deriveChangeHandlerResult(shellMode, pendingPrivate, text);
223
+ // Order matters: setText fires onChange synchronously, and the recursive
224
+ // call must see the new mode/private values or it re-runs the entry
225
+ // transition and clobbers the just-set state.
226
+ if (r.mode !== shellMode) setShellMode(r.mode);
227
+ setPendingPrivate(r.pendingPrivate);
228
+ if (r.replaceText !== undefined) editor.setText(r.replaceText);
229
+ };
230
+
181
231
  editor.onSubmit = (text) => {
182
- const query = text.trim();
183
- if (!query) return;
232
+ const action = classifySubmit(text, shellMode, pendingPrivate);
233
+ if (action.kind === "noop") return;
184
234
  editor.setText("");
185
- if (query.startsWith("/")) {
186
- const sp = query.indexOf(" ");
187
- const name = sp === -1 ? query : query.slice(0, sp);
188
- const args = sp === -1 ? "" : query.slice(sp + 1).trim();
189
- bus.emit("command:execute", { name, args });
190
- return;
191
- }
192
- if (processing) {
193
- queuedQueries.push(query);
194
- renderQueueSlot();
195
- tui.requestRender();
196
- return;
235
+ switch (action.kind) {
236
+ case "shell":
237
+ submitShell(action.line, { private: action.private });
238
+ setPendingPrivate(false);
239
+ return;
240
+ case "command":
241
+ bus.emit("command:execute", { name: action.name, args: action.args });
242
+ return;
243
+ case "agent":
244
+ if (processing) {
245
+ queuedQueries.push(action.query);
246
+ renderQueueSlot();
247
+ tui.requestRender();
248
+ return;
249
+ }
250
+ bus.emit("agent:submit", { query: action.query });
251
+ return;
197
252
  }
198
- bus.emit("agent:submit", { query });
199
253
  };
200
254
 
201
255
  const statusFooter = new StatusFooter();
@@ -229,22 +283,52 @@ export function mountAshi(
229
283
  let activeAssistant: AssistantMessage | null = null;
230
284
  let activeThinking: ThinkingBlock | null = null;
231
285
  const activeTools = new Map<string, LiveToolEntry>();
232
- /** Per-batch state from agent:tool-batch — the group is created lazily on
233
- * the first member's tool-started so the chat insertion order is correct. */
234
- const batchGroups = new Map<string, { total: number; group: ToolGroup | null }>();
235
- let lastToolResult: ToolResultView | null = null;
286
+ const groupMaxVisible = loadGroupMaxVisible();
287
+
288
+ /** Visible thinking acts as a hard separator; hidden thinking is transparent. */
289
+ const findMergeableGroup = (kind: string): ToolGroup | null => {
290
+ for (let i = chat.children.length - 1; i >= 0; i--) {
291
+ const c = chat.children[i]!;
292
+ if (c instanceof ToolGroup) return c.kind === kind ? c : null;
293
+ if (c instanceof ThinkingBlock && hideThinking) continue;
294
+ if (c instanceof AssistantMessage && !c.hasContent()) continue;
295
+ return null;
296
+ }
297
+ return null;
298
+ };
236
299
  let loader: Loader | null = null;
237
300
  let processing = false;
238
301
  const queuedQueries: string[] = [];
302
+ const queuedShellLines: { line: string; private: boolean }[] = [];
303
+ /** FIFO matching pty-writes to their shell:command-start events. */
304
+ const pendingUserBlockPrivacy: boolean[] = [];
239
305
 
240
306
  const renderQueueSlot = (): void => {
241
307
  queueSlot.clear();
308
+ for (const item of queuedShellLines) {
309
+ const oneLine = item.line.replace(/\s+/g, " ");
310
+ const preview = oneLine.length > 80 ? oneLine.slice(0, 77) + "…" : oneLine;
311
+ const tag = item.private ? "shell·private" : "shell";
312
+ queueSlot.addChild(new InfoLine(`↳ ${tag}: ${preview}`));
313
+ }
242
314
  for (const q of queuedQueries) {
243
315
  const oneLine = q.replace(/\s+/g, " ");
244
316
  const preview = oneLine.length > 80 ? oneLine.slice(0, 77) + "…" : oneLine;
245
317
  queueSlot.addChild(new InfoLine(`↳ queued: ${preview}`));
246
318
  }
247
319
  };
320
+
321
+ const submitShell = (line: string, opts?: { private?: boolean }): void => {
322
+ if (processing) {
323
+ queuedShellLines.push({ line, private: !!opts?.private });
324
+ renderQueueSlot();
325
+ tui.requestRender();
326
+ return;
327
+ }
328
+ pendingUserBlockPrivacy.push(!!opts?.private);
329
+ if (opts?.private) bus.emit("shell:user-exec-exclude-next", {});
330
+ bus.emit("shell:pty-write", { data: line + "\n" });
331
+ };
248
332
  let hideThinking = true;
249
333
 
250
334
  const renderState = (): { state: Record<string, unknown>; invalidate: () => void } => ({
@@ -252,7 +336,7 @@ export function mountAshi(
252
336
  invalidate: () => tui.requestRender(),
253
337
  });
254
338
 
255
- const tools = createToolHookResolver(ctx, renderState);
339
+ const tools = createToolHookResolver(ctx);
256
340
 
257
341
  const renderUserMessage = (text: string): Component =>
258
342
  ctx.call("ashi:render-user-message", { text, ...renderState() }) as Component;
@@ -328,11 +412,25 @@ export function mountAshi(
328
412
  chat.addChild(new InfoLine(`▼ compacted (firstKept=${entry.firstKeptId.slice(0, 6)}, ${entry.tokensBefore} tokens)`));
329
413
  return;
330
414
  }
415
+ if (entry.type === "shell-exchange") {
416
+ const name = entry.private ? "user_bash_private" : "user_bash";
417
+ const pair = renderToolPair({
418
+ toolCallId: `user-shell-replay-${entry.id}`, name, title: name,
419
+ kind: "bash", displayDetail: entry.command, rawInput: { command: entry.command },
420
+ });
421
+ chat.addChild(pair.call);
422
+ chat.addChild(pair.result);
423
+ if (entry.output) pair.result.appendChunk(entry.output);
424
+ pair.result.finalize({ exitCode: entry.exitCode });
425
+ pair.call.setStatus({ exitCode: entry.exitCode, elapsedMs: 0 });
426
+ chat.addChild(new Spacer(1));
427
+ return;
428
+ }
331
429
  const m = entry.message;
332
430
  if (m.role === "user") {
333
- const text = typeof m.content === "string" ? m.content : "";
334
- if (text.startsWith("[Compacted conversation summary]")) return;
335
- chat.addChild(renderUserMessage(text));
431
+ const raw = typeof m.content === "string" ? m.content : "";
432
+ if (raw.startsWith("[Compacted conversation summary]")) return;
433
+ chat.addChild(renderUserMessage(stripContextWrappers(raw)));
336
434
  } else if (m.role === "assistant") {
337
435
  const reasoning = readReasoning(m);
338
436
  if (reasoning) {
@@ -343,32 +441,18 @@ export function mountAshi(
343
441
  chat.addChild(renderAssistantFinal(text));
344
442
  }
345
443
  if (m.tool_calls) {
346
- const calls = m.tool_calls;
347
- let i = 0;
348
- while (i < calls.length) {
349
- const startName = calls[i]!.function?.name ?? "";
350
- const startKind = TOOL_KIND[startName];
351
- if (startKind && GROUPABLE_KINDS.has(startKind)) {
352
- let j = i;
353
- while (j < calls.length && TOOL_KIND[calls[j]!.function?.name ?? ""] === startKind) j++;
354
- const runLen = j - i;
355
- if (runLen > 1) {
356
- const group = new ToolGroup(startKind, runLen);
357
- chat.addChild(group);
358
- for (let k = i; k < j; k++) {
359
- const c = calls[k]!;
360
- const cid = c.id ?? "";
361
- const cname = c.function?.name ?? "tool";
362
- group.addCall(cid, cname, detailFromArgs(c.function?.arguments));
363
- if (cid) toolMap.set(cid, { kind: "group", group, name: cname });
364
- }
365
- i = j;
366
- continue;
367
- }
368
- }
369
- const tc = calls[i]!;
444
+ for (const tc of m.tool_calls) {
370
445
  const id = tc.id ?? "";
371
446
  const name = tc.function?.name ?? "tool";
447
+ const kind = TOOL_KIND[name];
448
+ if (kind && GROUPABLE_KINDS.has(kind)) {
449
+ const mergeable = findMergeableGroup(kind);
450
+ const group = mergeable
451
+ ?? (() => { const g = new ToolGroup(kind, groupMaxVisible); chat.addChild(g); return g; })();
452
+ group.addCall(id, name, detailFromArgs(tc.function?.arguments));
453
+ if (id) toolMap.set(id, { kind: "group", group, name });
454
+ continue;
455
+ }
372
456
  const pair = renderToolPair({
373
457
  toolCallId: id, name, title: name, kind: undefined,
374
458
  displayDetail: detailFromArgs(tc.function?.arguments),
@@ -377,8 +461,6 @@ export function mountAshi(
377
461
  chat.addChild(pair.call);
378
462
  chat.addChild(pair.result);
379
463
  if (id) toolMap.set(id, { kind: "pair", pair, name });
380
- lastToolResult = pair.result;
381
- i++;
382
464
  }
383
465
  }
384
466
  } else if (m.role === "tool") {
@@ -411,19 +493,14 @@ export function mountAshi(
411
493
  activeAssistant = null;
412
494
  activeThinking = null;
413
495
  activeTools.clear();
414
- batchGroups.clear();
415
- lastToolResult = null;
416
496
  chat.clear();
417
497
  const branch = getStore().current().getBranch();
418
498
  const toolMap = new Map<string, ReplayEntry>();
419
499
  for (const e of branch) replayEntry(e, toolMap);
420
- // Match the trailing gap that processing-done adds in live turns, so the
421
- // editor doesn't sit flush against the last replayed response.
422
500
  chat.addChild(new Spacer(1));
423
501
  tui.requestRender();
424
502
  };
425
503
 
426
- // ── Bus wiring ───────────────────────────────────────────────
427
504
  bus.on("agent:query", ({ query }) => {
428
505
  chat.addChild(renderUserMessage(query));
429
506
  activeAssistant = null;
@@ -448,8 +525,7 @@ export function mountAshi(
448
525
  );
449
526
  };
450
527
 
451
- /** Drop the live assistant message so the image lands as its own block,
452
- * then subsequent text starts a fresh markdown context below it. */
528
+ /** Drop the live assistant so subsequent text starts fresh markdown below the image. */
453
529
  const appendImage = (data: Buffer): void => {
454
530
  const img = imageComponentFromPng(data);
455
531
  if (!img) return;
@@ -457,8 +533,7 @@ export function mountAshi(
457
533
  chat.addChild(img);
458
534
  };
459
535
 
460
- // tui-renderer normally owns render:image, but ashi disables it; provide
461
- // our own so latex-images and friends reach the chat.
536
+ /** tui-renderer normally owns this hook; ashi disables it and provides its own. */
462
537
  ctx.define("render:image", (data: Buffer) => {
463
538
  appendImage(data);
464
539
  tui.requestRender();
@@ -480,13 +555,6 @@ export function mountAshi(
480
555
  tui.requestRender();
481
556
  });
482
557
 
483
- bus.on("agent:tool-batch", (e) => {
484
- batchGroups.clear();
485
- for (const g of e.groups) {
486
- batchGroups.set(g.kind, { total: g.tools.length, group: null });
487
- }
488
- });
489
-
490
558
  bus.on("agent:tool-started", (e) => {
491
559
  finalizeThinking();
492
560
  if (activeAssistant) {
@@ -495,34 +563,29 @@ export function mountAshi(
495
563
  }
496
564
  const id = e.toolCallId ?? `${e.title}-${Date.now()}`;
497
565
  const title = e.title.split(":")[0]!.trim();
566
+ const lookupName = e.name ?? title;
498
567
  const detail = e.displayDetail || detailFromArgs(
499
568
  typeof e.rawInput === "string" ? e.rawInput : JSON.stringify(e.rawInput ?? {})
500
569
  );
501
570
 
502
571
  const kind = e.kind ?? "";
503
- const batchEntry = batchGroups.get(kind);
504
- const shouldGroup = !!batchEntry && batchEntry.total > 1 && GROUPABLE_KINDS.has(kind);
505
- if (shouldGroup) {
506
- if (!batchEntry!.group) {
507
- batchEntry!.group = new ToolGroup(kind, batchEntry!.total);
508
- chat.addChild(batchEntry!.group);
509
- }
510
- batchEntry!.group.addCall(id, title, detail);
511
- activeTools.set(id, { kind: "group", group: batchEntry!.group });
512
- // Grouped tools have no individual result body — Ctrl+O wouldn't have
513
- // anything to expand, so leave lastToolResult pointing at the prior tool.
572
+ if (GROUPABLE_KINDS.has(kind)) {
573
+ const mergeable = findMergeableGroup(kind);
574
+ const group = mergeable
575
+ ?? (() => { const g = new ToolGroup(kind, groupMaxVisible); chat.addChild(g); return g; })();
576
+ group.addCall(id, lookupName, detail);
577
+ activeTools.set(id, { kind: "group", group });
514
578
  tui.requestRender();
515
579
  return;
516
580
  }
517
581
 
518
582
  const pair = renderToolPair({
519
- toolCallId: id, name: title, title, kind: e.kind,
583
+ toolCallId: id, name: lookupName, title, kind: e.kind,
520
584
  displayDetail: detail, rawInput: e.rawInput,
521
585
  });
522
586
  activeTools.set(id, { kind: "pair", pair });
523
587
  chat.addChild(pair.call);
524
588
  chat.addChild(pair.result);
525
- lastToolResult = pair.result;
526
589
  tui.requestRender();
527
590
  });
528
591
 
@@ -562,6 +625,48 @@ export function mountAshi(
562
625
  tui.requestRender();
563
626
  });
564
627
 
628
+ // agent:tool-* listeners already render agent-issued bash; the shell:* path
629
+ // is only for user-issued `!` commands.
630
+ let agentShellActive = false;
631
+ let shellForegroundBusy = false;
632
+ bus.on("shell:agent-exec-start", () => { agentShellActive = true; });
633
+ bus.on("shell:agent-exec-done", () => { agentShellActive = false; });
634
+ bus.on("shell:foreground-busy", ({ busy }) => { shellForegroundBusy = busy; });
635
+
636
+ let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
637
+ bus.on("shell:command-start", ({ command }) => {
638
+ if (agentShellActive) return;
639
+ finalizeThinking();
640
+ if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
641
+ const isPrivate = pendingUserBlockPrivacy.shift() ?? false;
642
+ const name = isPrivate ? "user_bash_private" : "user_bash";
643
+ const pair = renderToolPair({
644
+ toolCallId: `user-shell-${Date.now()}`, name, title: name,
645
+ kind: "bash", displayDetail: command, rawInput: { command },
646
+ });
647
+ activeUserShell = { pair, command, isPrivate };
648
+ chat.addChild(pair.call);
649
+ chat.addChild(pair.result);
650
+ tui.requestRender();
651
+ });
652
+
653
+ bus.on("shell:command-done", ({ output, cwd, exitCode }) => {
654
+ const active = activeUserShell;
655
+ if (!active) return;
656
+ const { pair, command, isPrivate } = active;
657
+ if (output) pair.result.appendChunk(output);
658
+ pair.call.setStatus({ exitCode, elapsedMs: Date.now() - pair.startedAt });
659
+ pair.result.finalize({ exitCode });
660
+ activeUserShell = null;
661
+ chat.addChild(new Spacer(1));
662
+ tui.requestRender();
663
+ void getStore().current().appendShellExchange({
664
+ command, output: output ?? "", exitCode, cwd,
665
+ ...(isPrivate ? { private: true } : {}),
666
+ });
667
+ getStore().markLastSession();
668
+ });
669
+
565
670
  bus.on("agent:processing-done", () => {
566
671
  processing = false;
567
672
  stopLoader();
@@ -570,10 +675,19 @@ export function mountAshi(
570
675
  chat.addChild(new Spacer(1));
571
676
  refreshFooterStats();
572
677
  refreshBranch();
678
+ // Shell queue drains first so its output lands in the next turn's <shell_events>.
679
+ while (queuedShellLines.length > 0) {
680
+ const item = queuedShellLines.shift()!;
681
+ pendingUserBlockPrivacy.push(item.private);
682
+ if (item.private) bus.emit("shell:user-exec-exclude-next", {});
683
+ bus.emit("shell:pty-write", { data: item.line + "\n" });
684
+ }
573
685
  const next = queuedQueries.shift();
574
686
  if (next !== undefined) {
575
687
  renderQueueSlot();
576
688
  bus.emit("agent:submit", { query: next });
689
+ } else {
690
+ renderQueueSlot();
577
691
  }
578
692
  tui.requestRender();
579
693
  });
@@ -633,7 +747,6 @@ export function mountAshi(
633
747
 
634
748
  refreshFooterStats();
635
749
 
636
- // ── Pickers ────────────────────────────────────────────────────
637
750
  let pickerOpen = false;
638
751
  let activeSessionPicker: SelectList | null = null;
639
752
  let activeSessionRepopulate: ((keepIndex?: number) => boolean) | null = null;
@@ -641,20 +754,102 @@ export function mountAshi(
641
754
 
642
755
  const openTreePicker = async (): Promise<void> => {
643
756
  if (pickerOpen) return;
644
- const branch = getStore().current().getBranch();
645
- if (branch.length <= 1) {
646
- bus.emit("ui:info", { message: "tree: nothing to rewind to yet" });
757
+ const store = getStore().current();
758
+ const all = store.getAllEntries();
759
+ const byId = new Map(all.map((e) => [e.id, e]));
760
+ const rawChildren = new Map<string, string[]>();
761
+ for (const e of all) {
762
+ if (!e.parentId) continue;
763
+ const kids = rawChildren.get(e.parentId) ?? [];
764
+ kids.push(e.id);
765
+ rawChildren.set(e.parentId, kids);
766
+ }
767
+ for (const ids of rawChildren.values()) {
768
+ ids.sort((a, b) => (byId.get(a)?.timestamp ?? 0) - (byId.get(b)?.timestamp ?? 0));
769
+ }
770
+
771
+ const isVisible = (e: SessionEntry): boolean => {
772
+ if (e.type === "message" && e.message.role === "user") return true;
773
+ return (rawChildren.get(e.id)?.length ?? 0) === 0;
774
+ };
775
+ const visibleChildren = (id: string): string[] => {
776
+ const out: string[] = [];
777
+ const stack = [...(rawChildren.get(id) ?? [])];
778
+ while (stack.length > 0) {
779
+ const cid = stack.shift()!;
780
+ const e = byId.get(cid);
781
+ if (e && isVisible(e)) out.push(cid);
782
+ else stack.unshift(...(rawChildren.get(cid) ?? []));
783
+ }
784
+ return out;
785
+ };
786
+
787
+ const activeLeaf = store.getActiveLeaf();
788
+ type Row = { id: string; entry: SessionEntry; prefix: string; kind: "msg" | "tip" };
789
+ const rows: Row[] = [];
790
+ const walk = (id: string, lineage: string[], isBranchChild: boolean): void => {
791
+ const e = byId.get(id);
792
+ if (!e) return;
793
+ if (isVisible(e)) {
794
+ const cols = isBranchChild
795
+ ? [...lineage.slice(0, -1), lineage[lineage.length - 1] === "│" ? "├" : "└"]
796
+ : lineage;
797
+ const isUserMsg = e.type === "message" && e.message.role === "user";
798
+ rows.push({ id: e.id, entry: e, prefix: cols.join(" "), kind: isUserMsg ? "msg" : "tip" });
799
+ }
800
+ const kids = visibleChildren(id);
801
+ if (kids.length === 0) return;
802
+ if (kids.length === 1) {
803
+ const only = byId.get(kids[0]!);
804
+ const isTip = !!only && !(only.type === "message" && only.message.role === "user");
805
+ if (isTip) {
806
+ // Render the tip as a branch-child so it gets a `└` connector at
807
+ // a deeper indent, visually "the next node in this branch."
808
+ walk(kids[0]!, [...lineage, " "], true);
809
+ } else {
810
+ walk(kids[0]!, lineage, false);
811
+ }
812
+ } else {
813
+ for (let i = 0; i < kids.length; i++) {
814
+ const last = i === kids.length - 1;
815
+ walk(kids[i]!, [...lineage, last ? " " : "│"], true);
816
+ }
817
+ }
818
+ };
819
+ const rootId = store.getRootId();
820
+ const rootEntry = byId.get(rootId);
821
+ if (rootEntry && isVisible(rootEntry)) {
822
+ rows.push({ id: rootId, entry: rootEntry, prefix: "", kind: "tip" });
823
+ }
824
+ const rootKids = visibleChildren(rootId);
825
+ if (rootKids.length === 1) {
826
+ walk(rootKids[0]!, [], false);
827
+ } else {
828
+ for (let i = 0; i < rootKids.length; i++) {
829
+ const last = i === rootKids.length - 1;
830
+ walk(rootKids[i]!, [last ? " " : "│"], true);
831
+ }
832
+ }
833
+
834
+ if (rows.length === 0) {
835
+ bus.emit("ui:info", { message: "fork: no past prompts yet" });
647
836
  return;
648
837
  }
649
- const activeId = getStore().current().getActiveLeaf();
650
- const items: SelectItem[] = branch.map((e) => ({
651
- value: e.id,
652
- label: pickerLabel(e, e.id === activeId),
653
- description: e.parentId ? `← ${e.parentId.slice(0, 6)}` : "root",
654
- }));
838
+
839
+ const items: SelectItem[] = rows.map((r) => {
840
+ const treePrefix = r.prefix ? `${r.prefix} ` : "";
841
+ if (r.kind === "msg") {
842
+ const raw = r.entry.type === "message" && typeof r.entry.message.content === "string"
843
+ ? r.entry.message.content : "";
844
+ const text = stripContextWrappers(raw).slice(0, 70).replace(/\n/g, " ");
845
+ return { value: `msg:${r.id}`, label: `${treePrefix}${text}` };
846
+ }
847
+ const label = r.id === activeLeaf ? "● current" : "leaf";
848
+ return { value: `tip:${r.id}`, label: `${treePrefix}${label}` };
849
+ });
655
850
  const picker = new SelectList(items, 15, selectListTheme());
656
- const activeIdx = items.findIndex((it) => it.value === activeId);
657
- if (activeIdx >= 0) picker.setSelectedIndex(activeIdx);
851
+ const activeIdx = items.findIndex((it) => it.value === `tip:${activeLeaf}`);
852
+ picker.setSelectedIndex(activeIdx >= 0 ? activeIdx : items.length - 1);
658
853
 
659
854
  const close = (): void => {
660
855
  pickerOpen = false;
@@ -664,12 +859,25 @@ export function mountAshi(
664
859
  };
665
860
 
666
861
  picker.onSelect = async (item) => {
667
- const id = item.value;
668
862
  close();
669
- if (id === activeId) return;
670
- getStore().current().setActiveLeaf(id);
863
+ const [kind, id] = item.value.split(":") as ["msg" | "tip", string];
864
+ if (kind === "tip") {
865
+ if (id === store.getActiveLeaf()) return;
866
+ store.setActiveLeaf(id);
867
+ applyBranchMessages(ctx, getStore, capture);
868
+ bus.emit("ui:info", { message: `fork: switched to branch tip ${id.slice(0, 6)}` });
869
+ await rebuildChat();
870
+ refreshFooterStats();
871
+ return;
872
+ }
873
+ const entry = byId.get(id);
874
+ if (!entry || entry.type !== "message") return;
875
+ const targetLeaf = entry.parentId;
876
+ store.setActiveLeaf(targetLeaf);
671
877
  applyBranchMessages(ctx, getStore, capture);
672
- bus.emit("ui:info", { message: `fork: rewound to ${id.slice(0, 6)}` });
878
+ const raw = typeof entry.message.content === "string" ? entry.message.content : "";
879
+ editor.setText(stripContextWrappers(raw));
880
+ bus.emit("ui:info", { message: `fork: rewound to ${targetLeaf.slice(0, 6)}` });
673
881
  await rebuildChat();
674
882
  refreshFooterStats();
675
883
  };
@@ -740,24 +948,36 @@ export function mountAshi(
740
948
  tui.requestRender();
741
949
  };
742
950
 
743
- // ── Keybindings ────────────────────────────────────────────────
744
951
  const toggleThinking = (): void => {
745
952
  hideThinking = !hideThinking;
746
- const walk = (node: Container): void => {
747
- for (const child of node.children) {
748
- if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
749
- else if (child instanceof Container) walk(child);
750
- }
751
- };
752
- walk(chat);
753
- tui.requestRender();
953
+ if (processing) {
954
+ // Mid-turn: only show/hide existing nodes. Group-merging respects the
955
+ // new flag for future calls; next idle rebuild reflows everything.
956
+ const walk = (node: Container): void => {
957
+ for (const child of node.children) {
958
+ if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
959
+ else if (child instanceof Container) walk(child);
960
+ }
961
+ };
962
+ walk(chat);
963
+ tui.requestRender();
964
+ return;
965
+ }
966
+ void rebuildChat();
754
967
  };
755
968
 
756
969
  tui.addInputListener((data) => {
757
970
  if (isKeyRelease(data) || isKeyRepeat(data)) return;
758
- if (matchesKey(data, "escape") && processing) {
759
- bus.emit("agent:cancel-request", {});
760
- return { consume: true };
971
+ if (matchesKey(data, "escape")) {
972
+ if (processing) {
973
+ bus.emit("agent:cancel-request", {});
974
+ return { consume: true };
975
+ }
976
+ if (shellForegroundBusy) {
977
+ // Real ^C byte; PTY translates it to SIGINT for the foreground process.
978
+ bus.emit("shell:pty-write", { data: "\x03" });
979
+ return { consume: true };
980
+ }
761
981
  }
762
982
  if (activeSessionPicker && matchesKey(data, "d")) {
763
983
  const selected = activeSessionPicker.getSelectedItem();
@@ -784,6 +1004,13 @@ export function mountAshi(
784
1004
  tui.requestRender();
785
1005
  return { consume: true };
786
1006
  }
1007
+ if (matchesKey(data, "backspace") && shellMode && editor.getText().length === 0) {
1008
+ // Editor swallows backspace-on-empty; two-step exit: first clears the
1009
+ // private signal, second exits shell mode entirely.
1010
+ if (pendingPrivate) setPendingPrivate(false);
1011
+ else setShellMode(false);
1012
+ return { consume: true };
1013
+ }
787
1014
  if (matchesKey(data, "ctrl+c")) {
788
1015
  editor.setText("");
789
1016
  return { consume: true };
@@ -807,10 +1034,15 @@ export function mountAshi(
807
1034
  return { consume: true };
808
1035
  }
809
1036
  if (matchesKey(data, "ctrl+o")) {
810
- if (lastToolResult) {
811
- lastToolResult.toggleExpanded();
812
- tui.requestRender();
813
- }
1037
+ const toggle = (node: Container): void => {
1038
+ for (const child of node.children) {
1039
+ const fn = (child as unknown as { toggleExpanded?: () => void }).toggleExpanded;
1040
+ if (typeof fn === "function") fn.call(child);
1041
+ if (child instanceof Container) toggle(child);
1042
+ }
1043
+ };
1044
+ toggle(chat);
1045
+ tui.requestRender();
814
1046
  return { consume: true };
815
1047
  }
816
1048
  return undefined;
@@ -827,12 +1059,17 @@ export function mountAshi(
827
1059
  };
828
1060
  }
829
1061
 
1062
+ function isForkAnchor(e: SessionEntry): boolean {
1063
+ if (e.type === "session" || e.type === "compaction") return true;
1064
+ return e.type === "message" && e.message.role === "user";
1065
+ }
1066
+
830
1067
  function pickerLabel(e: SessionEntry, isActive: boolean): string {
831
1068
  const marker = isActive ? "●" : "│";
832
1069
  const short = e.id.slice(0, 6);
833
1070
  if (e.type === "session") return `${marker} ${short} session start`;
834
1071
  if (e.type === "compaction") return `${marker} ${short} ▼ compacted (firstKept=${e.firstKeptId.slice(0, 6)})`;
835
- const m = e.message;
836
- const text = typeof m.content === "string" ? m.content.slice(0, 70).replace(/\n/g, " ") : "";
837
- return `${marker} ${short} ${m.role}: ${text}`;
1072
+ const raw = e.type === "message" && typeof e.message.content === "string" ? e.message.content : "";
1073
+ const text = stripContextWrappers(raw).slice(0, 70).replace(/\n/g, " ");
1074
+ return `${marker} ${short} ${text}`;
838
1075
  }