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.
- package/dist/shell/events.d.ts +2 -0
- package/dist/shell/output-parser.d.ts +11 -22
- package/dist/shell/output-parser.js +16 -34
- package/dist/shell/shell-context.d.ts +3 -6
- package/dist/shell/shell-context.js +15 -7
- package/dist/shell/shell.js +3 -1
- package/dist/shell/strategies/types.d.ts +6 -0
- package/dist/shell/strategies/zsh.js +7 -0
- package/examples/extensions/ashi/src/cli.ts +32 -4
- package/examples/extensions/ashi/src/commands.ts +2 -20
- package/examples/extensions/ashi/src/default-schema-renderers.ts +28 -25
- package/examples/extensions/ashi/src/frontend.ts +298 -60
- package/examples/extensions/ashi/src/schema.ts +32 -53
- package/examples/extensions/ashi/src/session-store.ts +60 -13
- package/examples/extensions/ashi/src/shell-mode.ts +52 -0
- package/examples/extensions/ashi/src/status-footer.ts +19 -5
- package/examples/extensions/ashi/src/theme.ts +2 -1
- package/package.json +5 -1
|
@@ -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
|
|
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
|
-
/**
|
|
133
|
-
*
|
|
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
|
-
|
|
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
|
|
188
|
-
if (
|
|
232
|
+
const action = classifySubmit(text, shellMode, pendingPrivate);
|
|
233
|
+
if (action.kind === "noop") return;
|
|
189
234
|
editor.setText("");
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
/**
|
|
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
|
|
351
|
-
if (
|
|
352
|
-
chat.addChild(renderUserMessage(
|
|
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
|
|
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
|
-
|
|
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
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
637
|
-
const items: SelectItem[] =
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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 ===
|
|
644
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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:
|
|
735
|
-
//
|
|
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")
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
835
|
-
const text =
|
|
836
|
-
return `${marker} ${short} ${
|
|
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
|
}
|