agent-sh 0.14.2 → 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.
@@ -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,
@@ -26,6 +27,7 @@ import {
26
27
  import type { ToolCallView, ToolResultView } from "./hooks.js";
27
28
  import { createToolHookResolver } from "./hooks.js";
28
29
  import { loadGroupMaxVisible } from "./display-config.js";
30
+ import { classifySubmit, deriveChangeHandlerResult } from "./shell-mode.js";
29
31
 
30
32
  const GROUPABLE_KINDS = new Set(["read", "search"]);
31
33
  const TOOL_KIND: Record<string, string> = {
@@ -35,7 +37,7 @@ const TOOL_KIND: Record<string, string> = {
35
37
  import { BusAutocompleteProvider } from "./autocomplete.js";
36
38
  import { StatusFooter } from "./status-footer.js";
37
39
  import type { MultiSessionStore } from "./multi-session-store.js";
38
- import type { SessionEntry } from "./session-store.js";
40
+ import { stripContextWrappers, type SessionEntry } from "./session-store.js";
39
41
  import { formatSessionRow } from "./session-commands.js";
40
42
  import { resumeSession } from "./session-commands.js";
41
43
  import { applyBranchMessages } from "./commands.js";
@@ -129,9 +131,8 @@ function detailFromArgs(argsJson: string | undefined): string {
129
131
  return "";
130
132
  }
131
133
 
132
- /** Recompute the per-tool summary from a saved tool result message. We don't
133
- * persist resultDisplay, so /resume would otherwise lose "16 entries" / "117
134
- * 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. */
135
136
  function inferSummary(toolName: string, content: unknown): string | undefined {
136
137
  if (typeof content !== "string" || content.length === 0) return undefined;
137
138
  const lines = content.split("\n").filter((l) => l.length > 0);
@@ -182,25 +183,73 @@ export function mountAshi(
182
183
  const footerSlot = new Container();
183
184
  const queueSlot = new Container();
184
185
  const editor = new Editor(tui, editorTheme(), { paddingX: 1 });
185
- 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
+
186
231
  editor.onSubmit = (text) => {
187
- const query = text.trim();
188
- if (!query) return;
232
+ const action = classifySubmit(text, shellMode, pendingPrivate);
233
+ if (action.kind === "noop") return;
189
234
  editor.setText("");
190
- if (query.startsWith("/")) {
191
- const sp = query.indexOf(" ");
192
- const name = sp === -1 ? query : query.slice(0, sp);
193
- const args = sp === -1 ? "" : query.slice(sp + 1).trim();
194
- bus.emit("command:execute", { name, args });
195
- return;
196
- }
197
- if (processing) {
198
- queuedQueries.push(query);
199
- renderQueueSlot();
200
- tui.requestRender();
201
- 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;
202
252
  }
203
- bus.emit("agent:submit", { query });
204
253
  };
205
254
 
206
255
  const statusFooter = new StatusFooter();
@@ -236,10 +285,7 @@ export function mountAshi(
236
285
  const activeTools = new Map<string, LiveToolEntry>();
237
286
  const groupMaxVisible = loadGroupMaxVisible();
238
287
 
239
- /** Find a same-kind ToolGroup at the chat tail. ThinkingBlocks are
240
- * visually-neutral only when hidden — visible thinking is a hard separator,
241
- * so toggling thinking on splits previously-merged groups at iteration
242
- * boundaries. */
288
+ /** Visible thinking acts as a hard separator; hidden thinking is transparent. */
243
289
  const findMergeableGroup = (kind: string): ToolGroup | null => {
244
290
  for (let i = chat.children.length - 1; i >= 0; i--) {
245
291
  const c = chat.children[i]!;
@@ -253,15 +299,36 @@ export function mountAshi(
253
299
  let loader: Loader | null = null;
254
300
  let processing = false;
255
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[] = [];
256
305
 
257
306
  const renderQueueSlot = (): void => {
258
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
+ }
259
314
  for (const q of queuedQueries) {
260
315
  const oneLine = q.replace(/\s+/g, " ");
261
316
  const preview = oneLine.length > 80 ? oneLine.slice(0, 77) + "…" : oneLine;
262
317
  queueSlot.addChild(new InfoLine(`↳ queued: ${preview}`));
263
318
  }
264
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
+ };
265
332
  let hideThinking = true;
266
333
 
267
334
  const renderState = (): { state: Record<string, unknown>; invalidate: () => void } => ({
@@ -345,11 +412,25 @@ export function mountAshi(
345
412
  chat.addChild(new InfoLine(`▼ compacted (firstKept=${entry.firstKeptId.slice(0, 6)}, ${entry.tokensBefore} tokens)`));
346
413
  return;
347
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
+ }
348
429
  const m = entry.message;
349
430
  if (m.role === "user") {
350
- const text = typeof m.content === "string" ? m.content : "";
351
- if (text.startsWith("[Compacted conversation summary]")) return;
352
- 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)));
353
434
  } else if (m.role === "assistant") {
354
435
  const reasoning = readReasoning(m);
355
436
  if (reasoning) {
@@ -416,13 +497,10 @@ export function mountAshi(
416
497
  const branch = getStore().current().getBranch();
417
498
  const toolMap = new Map<string, ReplayEntry>();
418
499
  for (const e of branch) replayEntry(e, toolMap);
419
- // Match the trailing gap that processing-done adds in live turns, so the
420
- // editor doesn't sit flush against the last replayed response.
421
500
  chat.addChild(new Spacer(1));
422
501
  tui.requestRender();
423
502
  };
424
503
 
425
- // ── Bus wiring ───────────────────────────────────────────────
426
504
  bus.on("agent:query", ({ query }) => {
427
505
  chat.addChild(renderUserMessage(query));
428
506
  activeAssistant = null;
@@ -447,8 +525,7 @@ export function mountAshi(
447
525
  );
448
526
  };
449
527
 
450
- /** Drop the live assistant message so the image lands as its own block,
451
- * then subsequent text starts a fresh markdown context below it. */
528
+ /** Drop the live assistant so subsequent text starts fresh markdown below the image. */
452
529
  const appendImage = (data: Buffer): void => {
453
530
  const img = imageComponentFromPng(data);
454
531
  if (!img) return;
@@ -456,8 +533,7 @@ export function mountAshi(
456
533
  chat.addChild(img);
457
534
  };
458
535
 
459
- // tui-renderer normally owns render:image, but ashi disables it; provide
460
- // our own so latex-images and friends reach the chat.
536
+ /** tui-renderer normally owns this hook; ashi disables it and provides its own. */
461
537
  ctx.define("render:image", (data: Buffer) => {
462
538
  appendImage(data);
463
539
  tui.requestRender();
@@ -549,6 +625,48 @@ export function mountAshi(
549
625
  tui.requestRender();
550
626
  });
551
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
+
552
670
  bus.on("agent:processing-done", () => {
553
671
  processing = false;
554
672
  stopLoader();
@@ -557,10 +675,19 @@ export function mountAshi(
557
675
  chat.addChild(new Spacer(1));
558
676
  refreshFooterStats();
559
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
+ }
560
685
  const next = queuedQueries.shift();
561
686
  if (next !== undefined) {
562
687
  renderQueueSlot();
563
688
  bus.emit("agent:submit", { query: next });
689
+ } else {
690
+ renderQueueSlot();
564
691
  }
565
692
  tui.requestRender();
566
693
  });
@@ -620,7 +747,6 @@ export function mountAshi(
620
747
 
621
748
  refreshFooterStats();
622
749
 
623
- // ── Pickers ────────────────────────────────────────────────────
624
750
  let pickerOpen = false;
625
751
  let activeSessionPicker: SelectList | null = null;
626
752
  let activeSessionRepopulate: ((keepIndex?: number) => boolean) | null = null;
@@ -628,20 +754,102 @@ export function mountAshi(
628
754
 
629
755
  const openTreePicker = async (): Promise<void> => {
630
756
  if (pickerOpen) return;
631
- const branch = getStore().current().getBranch();
632
- if (branch.length <= 1) {
633
- 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" });
634
836
  return;
635
837
  }
636
- const activeId = getStore().current().getActiveLeaf();
637
- const items: SelectItem[] = branch.map((e) => ({
638
- value: e.id,
639
- label: pickerLabel(e, e.id === activeId),
640
- description: e.parentId ? `← ${e.parentId.slice(0, 6)}` : "root",
641
- }));
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
+ });
642
850
  const picker = new SelectList(items, 15, selectListTheme());
643
- const activeIdx = items.findIndex((it) => it.value === activeId);
644
- 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);
645
853
 
646
854
  const close = (): void => {
647
855
  pickerOpen = false;
@@ -651,12 +859,25 @@ export function mountAshi(
651
859
  };
652
860
 
653
861
  picker.onSelect = async (item) => {
654
- const id = item.value;
655
862
  close();
656
- if (id === activeId) return;
657
- 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);
658
877
  applyBranchMessages(ctx, getStore, capture);
659
- 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)}` });
660
881
  await rebuildChat();
661
882
  refreshFooterStats();
662
883
  };
@@ -727,13 +948,11 @@ export function mountAshi(
727
948
  tui.requestRender();
728
949
  };
729
950
 
730
- // ── Keybindings ────────────────────────────────────────────────
731
951
  const toggleThinking = (): void => {
732
952
  hideThinking = !hideThinking;
733
953
  if (processing) {
734
- // Mid-turn: in-place show/hide only. Past groups stay as they merged
735
- // under the old flag; future tool calls in this turn respect the new
736
- // flag via findMergeableGroup. Next idle rebuild reflows everything.
954
+ // Mid-turn: only show/hide existing nodes. Group-merging respects the
955
+ // new flag for future calls; next idle rebuild reflows everything.
737
956
  const walk = (node: Container): void => {
738
957
  for (const child of node.children) {
739
958
  if (child instanceof ThinkingBlock) child.setHidden(hideThinking);
@@ -749,9 +968,16 @@ export function mountAshi(
749
968
 
750
969
  tui.addInputListener((data) => {
751
970
  if (isKeyRelease(data) || isKeyRepeat(data)) return;
752
- if (matchesKey(data, "escape") && processing) {
753
- bus.emit("agent:cancel-request", {});
754
- 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
+ }
755
981
  }
756
982
  if (activeSessionPicker && matchesKey(data, "d")) {
757
983
  const selected = activeSessionPicker.getSelectedItem();
@@ -778,6 +1004,13 @@ export function mountAshi(
778
1004
  tui.requestRender();
779
1005
  return { consume: true };
780
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
+ }
781
1014
  if (matchesKey(data, "ctrl+c")) {
782
1015
  editor.setText("");
783
1016
  return { consume: true };
@@ -826,12 +1059,17 @@ export function mountAshi(
826
1059
  };
827
1060
  }
828
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
+
829
1067
  function pickerLabel(e: SessionEntry, isActive: boolean): string {
830
1068
  const marker = isActive ? "●" : "│";
831
1069
  const short = e.id.slice(0, 6);
832
1070
  if (e.type === "session") return `${marker} ${short} session start`;
833
1071
  if (e.type === "compaction") return `${marker} ${short} ▼ compacted (firstKept=${e.firstKeptId.slice(0, 6)})`;
834
- const m = e.message;
835
- const text = typeof m.content === "string" ? m.content.slice(0, 70).replace(/\n/g, " ") : "";
836
- 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}`;
837
1075
  }