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