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.
- package/dist/agent/agent-loop.d.ts +1 -1
- package/dist/agent/agent-loop.js +42 -31
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +20 -3
- package/dist/agent/events.d.ts +2 -0
- package/dist/agent/host-types.d.ts +3 -0
- package/dist/agent/index.js +2 -1
- package/dist/agent/subagent.d.ts +1 -1
- package/dist/agent/subagent.js +5 -1
- package/dist/agent/tool-protocol.d.ts +2 -2
- package/dist/agent/tool-protocol.js +5 -4
- package/dist/agent/tools/glob.d.ts +1 -1
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.d.ts +1 -1
- package/dist/agent/tools/grep.js +4 -2
- package/dist/agent/tools/ls.d.ts +1 -1
- package/dist/agent/tools/ls.js +4 -2
- package/dist/agent/tools/read-file.d.ts +1 -1
- package/dist/agent/tools/read-file.js +30 -2
- package/dist/agent/types.d.ts +11 -1
- package/dist/agent/types.js +6 -1
- package/dist/cli/index.js +0 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/settings.d.ts +3 -0
- package/dist/core/settings.js +2 -2
- package/dist/shell/events.d.ts +2 -0
- package/dist/shell/index.d.ts +6 -0
- package/dist/shell/index.js +10 -10
- 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.d.ts +4 -0
- package/dist/shell/shell.js +18 -30
- package/dist/shell/strategies/types.d.ts +6 -0
- package/dist/shell/strategies/zsh.js +7 -0
- package/dist/shell/terminal.d.ts +33 -0
- package/dist/shell/terminal.js +62 -0
- package/examples/extensions/ash-scheme/index.ts +2170 -0
- package/examples/extensions/ash-scheme/package.json +11 -0
- package/examples/extensions/ash-scheme-render.ts +58 -0
- package/examples/extensions/ashi/README.md +36 -26
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +1 -0
- package/examples/extensions/ashi/src/cli.ts +53 -11
- package/examples/extensions/ashi/src/commands.ts +2 -20
- package/examples/extensions/ashi/src/compaction.ts +25 -96
- package/examples/extensions/ashi/src/components.ts +64 -166
- package/examples/extensions/ashi/src/default-schema-renderers.ts +232 -0
- package/examples/extensions/ashi/src/display-config.ts +21 -22
- package/examples/extensions/ashi/src/frontend.ts +355 -118
- package/examples/extensions/ashi/src/hooks.ts +47 -63
- package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
- package/examples/extensions/ashi/src/schema.ts +386 -0
- package/examples/extensions/ashi/src/session-store.ts +115 -17
- package/examples/extensions/ashi/src/shell-mode.ts +52 -0
- package/examples/extensions/ashi/src/status-footer.ts +41 -6
- package/examples/extensions/ashi/src/theme.ts +2 -1
- package/examples/extensions/ashi-compact-llm.ts +93 -0
- package/examples/extensions/claude-code-bridge/index.ts +2 -0
- package/examples/extensions/opencode-bridge/index.ts +3 -0
- package/examples/extensions/opencode-provider.ts +252 -0
- package/examples/extensions/pi-bridge/index.ts +1 -0
- package/package.json +16 -1
- 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
|
|
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
|
-
/**
|
|
128
|
-
*
|
|
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
|
-
|
|
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
|
|
183
|
-
if (
|
|
232
|
+
const action = classifySubmit(text, shellMode, pendingPrivate);
|
|
233
|
+
if (action.kind === "noop") return;
|
|
184
234
|
editor.setText("");
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
|
334
|
-
if (
|
|
335
|
-
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)));
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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:
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
650
|
-
const items: SelectItem[] =
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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 ===
|
|
657
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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")
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
|
836
|
-
const text =
|
|
837
|
-
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}`;
|
|
838
1075
|
}
|