agent-sh 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/agent/agent-loop.d.ts +1 -1
  2. package/dist/agent/agent-loop.js +42 -31
  3. package/dist/agent/conversation-state.d.ts +3 -2
  4. package/dist/agent/conversation-state.js +20 -3
  5. package/dist/agent/events.d.ts +2 -0
  6. package/dist/agent/host-types.d.ts +3 -0
  7. package/dist/agent/index.js +2 -1
  8. package/dist/agent/subagent.d.ts +1 -1
  9. package/dist/agent/subagent.js +5 -1
  10. package/dist/agent/tool-protocol.d.ts +2 -2
  11. package/dist/agent/tool-protocol.js +5 -4
  12. package/dist/agent/tools/glob.d.ts +1 -1
  13. package/dist/agent/tools/glob.js +4 -2
  14. package/dist/agent/tools/grep.d.ts +1 -1
  15. package/dist/agent/tools/grep.js +4 -2
  16. package/dist/agent/tools/ls.d.ts +1 -1
  17. package/dist/agent/tools/ls.js +4 -2
  18. package/dist/agent/tools/read-file.d.ts +1 -1
  19. package/dist/agent/tools/read-file.js +30 -2
  20. package/dist/agent/types.d.ts +11 -1
  21. package/dist/agent/types.js +6 -1
  22. package/dist/cli/index.js +0 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/settings.d.ts +3 -0
  25. package/dist/core/settings.js +2 -2
  26. package/dist/shell/events.d.ts +2 -0
  27. package/dist/shell/index.d.ts +6 -0
  28. package/dist/shell/index.js +10 -10
  29. package/dist/shell/output-parser.d.ts +11 -22
  30. package/dist/shell/output-parser.js +16 -34
  31. package/dist/shell/shell-context.d.ts +3 -6
  32. package/dist/shell/shell-context.js +15 -7
  33. package/dist/shell/shell.d.ts +4 -0
  34. package/dist/shell/shell.js +18 -30
  35. package/dist/shell/strategies/types.d.ts +6 -0
  36. package/dist/shell/strategies/zsh.js +7 -0
  37. package/dist/shell/terminal.d.ts +33 -0
  38. package/dist/shell/terminal.js +62 -0
  39. package/examples/extensions/ash-scheme/index.ts +2170 -0
  40. package/examples/extensions/ash-scheme/package.json +11 -0
  41. package/examples/extensions/ash-scheme-render.ts +58 -0
  42. package/examples/extensions/ashi/README.md +36 -26
  43. package/examples/extensions/ashi/package.json +9 -1
  44. package/examples/extensions/ashi/src/capture.ts +1 -0
  45. package/examples/extensions/ashi/src/cli.ts +53 -11
  46. package/examples/extensions/ashi/src/commands.ts +2 -20
  47. package/examples/extensions/ashi/src/compaction.ts +25 -96
  48. package/examples/extensions/ashi/src/components.ts +64 -166
  49. package/examples/extensions/ashi/src/default-schema-renderers.ts +232 -0
  50. package/examples/extensions/ashi/src/display-config.ts +21 -22
  51. package/examples/extensions/ashi/src/frontend.ts +355 -118
  52. package/examples/extensions/ashi/src/hooks.ts +47 -63
  53. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  54. package/examples/extensions/ashi/src/schema.ts +386 -0
  55. package/examples/extensions/ashi/src/session-store.ts +115 -17
  56. package/examples/extensions/ashi/src/shell-mode.ts +52 -0
  57. package/examples/extensions/ashi/src/status-footer.ts +41 -6
  58. package/examples/extensions/ashi/src/theme.ts +2 -1
  59. package/examples/extensions/ashi-compact-llm.ts +93 -0
  60. package/examples/extensions/claude-code-bridge/index.ts +2 -0
  61. package/examples/extensions/opencode-bridge/index.ts +3 -0
  62. package/examples/extensions/opencode-provider.ts +252 -0
  63. package/examples/extensions/pi-bridge/index.ts +1 -0
  64. package/package.json +16 -1
  65. package/examples/extensions/ashi/src/default-renderers.ts +0 -171
@@ -6,7 +6,6 @@ import {
6
6
  Text,
7
7
  } from "@earendil-works/pi-tui";
8
8
  import { markdownTheme, theme } from "./theme.js";
9
- import type { ToolResultMode } from "./display-config.js";
10
9
 
11
10
  const OSC133_ZONE_START = "\x1b]133;A\x07";
12
11
  const OSC133_ZONE_END = "\x1b]133;B\x07";
@@ -94,115 +93,6 @@ export class ThinkingBlock extends Container {
94
93
  }
95
94
  }
96
95
 
97
- export class ToolResultBody extends Container {
98
- private outputText: Text;
99
- private bodyText: Text;
100
- private outputBuffer = "";
101
- private diffRenderer: ((width: number) => string[]) | null = null;
102
- private lastDiffWidth = -1;
103
- private mode: ToolResultMode;
104
- private previewLines: number;
105
- private finalized = false;
106
- private expanded = false;
107
- private exitCode: number | null | undefined;
108
-
109
- constructor(mode: ToolResultMode, previewLines: number) {
110
- super();
111
- this.mode = mode;
112
- this.previewLines = previewLines;
113
- this.outputText = new Text("", 1, 0);
114
- this.bodyText = new Text("", 0, 0);
115
- this.addChild(this.outputText);
116
- this.addChild(this.bodyText);
117
- }
118
-
119
- appendChunk(chunk: string): void {
120
- this.outputBuffer += chunk;
121
- this.repaint();
122
- }
123
-
124
- setDiffRenderer(fn: (width: number) => string[]): void {
125
- this.diffRenderer = fn;
126
- this.lastDiffWidth = -1;
127
- this.repaint();
128
- }
129
-
130
- finalize(opts: { exitCode: number | null; summary?: string }): void {
131
- this.finalized = true;
132
- this.exitCode = opts.exitCode;
133
- this.repaint();
134
- }
135
-
136
- toggleExpanded(): void {
137
- this.expanded = !this.expanded;
138
- this.repaint();
139
- }
140
-
141
- override render(width: number): string[] {
142
- if (this.diffRenderer && width !== this.lastDiffWidth) {
143
- this.lastDiffWidth = width;
144
- const showDiff = this.expanded || this.mode === "preview";
145
- this.bodyText.setText(showDiff ? this.diffRenderer(width).join("\n") : "");
146
- }
147
- return super.render(width);
148
- }
149
-
150
- private repaint(): void {
151
- const hasDiff = this.diffRenderer !== null;
152
- const showDiff = hasDiff && (this.expanded || this.mode === "preview");
153
- if (showDiff && this.lastDiffWidth >= 0 && this.diffRenderer) {
154
- this.bodyText.setText(this.diffRenderer(this.lastDiffWidth).join("\n"));
155
- } else if (!showDiff) {
156
- this.bodyText.setText("");
157
- }
158
-
159
- // When a diff exists, the textual output ("Edited /path (+12 -3)") just
160
- // restates the call line — suppress its line-count hint to keep edits quiet.
161
- if (hasDiff && !this.expanded) {
162
- this.outputText.setText("");
163
- return;
164
- }
165
- if (!this.outputBuffer) {
166
- this.outputText.setText("");
167
- return;
168
- }
169
- if (this.expanded) {
170
- this.outputText.setText(theme.fg("toolOutput", this.outputBuffer));
171
- return;
172
- }
173
- if (this.mode === "hidden") {
174
- if (!this.finalized) { this.outputText.setText(""); return; }
175
- this.outputText.setText(lineCountHint(this.outputBuffer, this.exitCode));
176
- return;
177
- }
178
- if (this.mode === "summary") {
179
- if (!this.finalized) {
180
- // Brief tail while streaming; collapses to a line count on finalize.
181
- const tail = this.outputBuffer.split("\n").slice(-2).join("\n");
182
- this.outputText.setText(theme.fg("muted", tail));
183
- return;
184
- }
185
- this.outputText.setText(lineCountHint(this.outputBuffer, this.exitCode));
186
- return;
187
- }
188
- const lines = this.outputBuffer.split("\n");
189
- const trimmed = lines.slice(-this.previewLines).join("\n");
190
- const remaining = Math.max(0, lines.length - this.previewLines);
191
- const overflow = remaining > 0
192
- ? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
193
- : "";
194
- this.outputText.setText(`${theme.fg("toolOutput", trimmed)}${overflow}`);
195
- }
196
- }
197
-
198
- function lineCountHint(buffer: string, exitCode: number | null | undefined): string {
199
- const lines = buffer.split("\n").filter((l) => l.length > 0);
200
- const label = lines.length === 1 ? "1 line" : `${lines.length} lines`;
201
- const ok = exitCode === null || exitCode === 0;
202
- const arrow = ok ? theme.fg("muted", "↳ ") : theme.fg("error", "↳ ");
203
- return `${arrow}${theme.fg("muted", label)}`;
204
- }
205
-
206
96
  export const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
207
97
 
208
98
  interface GroupChild {
@@ -222,85 +112,95 @@ function shortToolName(name: string): string {
222
112
  return SHORT_TOOL_NAMES[name] ?? name;
223
113
  }
224
114
 
225
- /** A batch of parallel same-kind tool calls. Renders one header, per-call
226
- * child branch lines that each carry their own summary on completion, and
227
- * a final aggregate. Mirrors ash's grouping (read_file/ls "read";
228
- * grep/glob → "search"). */
115
+ /** An open-ended run of same-kind tool calls. Grows by tail-merge: the caller
116
+ * extends an existing group when its `kind` matches the next call and the
117
+ * group is still the chat's tail. Trailing child renders with └, others ├.
118
+ *
119
+ * When `maxVisible` is finite and exceeded, the oldest children collapse to
120
+ * a summary line ("⋯ N earlier ✓") and the last (maxVisible − 1) stay
121
+ * visible. `toggleExpanded()` reveals all; `maxVisible = Infinity` disables
122
+ * eviction. */
229
123
  export class ToolGroup extends Container {
230
124
  private headerText: Text;
125
+ private summaryText: Text;
231
126
  private childContainer: Container;
232
- private aggregateText: Text;
233
- private kind: string;
234
- private total: number;
127
+ readonly kind: string;
235
128
  private maxVisible: number;
236
- private visibleChildren = new Map<string, GroupChild>();
237
- private hiddenSummaries: string[] = [];
238
- private addedCount = 0;
239
- private renderedCount = 0;
240
- private completedCount = 0;
241
- private allOk = true;
129
+ private allChildren: GroupChild[] = [];
130
+ private callsById = new Map<string, GroupChild>();
131
+ private expanded = false;
242
132
 
243
- constructor(kind: string, total: number, maxVisible = 5) {
133
+ constructor(kind: string, maxVisible: number = Infinity) {
244
134
  super();
245
135
  this.kind = kind;
246
- this.total = total;
247
136
  this.maxVisible = maxVisible;
248
137
  this.headerText = new Text("", 1, 0);
138
+ this.summaryText = new Text("", 1, 0);
249
139
  this.childContainer = new Container();
250
- this.aggregateText = new Text("", 1, 0);
251
140
  this.addChild(new Spacer(1));
252
141
  this.addChild(this.headerText);
142
+ this.addChild(this.summaryText);
253
143
  this.addChild(this.childContainer);
254
- this.addChild(this.aggregateText);
255
144
  this.repaintHeader();
256
145
  }
257
146
 
258
147
  addCall(toolCallId: string, name: string, detail: string): void {
259
- this.addedCount++;
260
- if (this.renderedCount < this.maxVisible && toolCallId) {
261
- const text = new Text("", 1, 0);
262
- const child: GroupChild = { name: shortToolName(name), detail: detail || "…", text };
263
- this.visibleChildren.set(toolCallId, child);
264
- this.childContainer.addChild(text);
265
- this.renderedCount++;
266
- this.repaintChild(child);
267
- }
148
+ const text = new Text("", 1, 0);
149
+ const child: GroupChild = { name: shortToolName(name), detail: detail || "…", text };
150
+ if (toolCallId) this.callsById.set(toolCallId, child);
151
+ this.allChildren.push(child);
152
+ this.repaint();
268
153
  }
269
154
 
270
155
  recordCompletion(toolCallId: string, exitCode: number | null, summary?: string): void {
271
- this.completedCount++;
272
- if (exitCode !== null && exitCode !== 0) this.allOk = false;
273
- const child = this.visibleChildren.get(toolCallId);
274
- if (child) {
275
- child.status = { exitCode, summary };
276
- this.repaintChild(child);
277
- } else if (summary) {
278
- this.hiddenSummaries.push(summary);
279
- }
280
- if (this.completedCount >= this.total) this.finalize();
156
+ const child = this.callsById.get(toolCallId);
157
+ if (!child) return;
158
+ child.status = { exitCode, summary };
159
+ this.repaint();
281
160
  }
282
161
 
283
- finalize(): void {
284
- const collapsed = this.addedCount - this.renderedCount;
285
- // No overflow ⇒ no aggregate; close the tree by promoting the last
286
- // visible child's ├ to a └.
287
- if (collapsed === 0) {
288
- this.aggregateText.setText("");
289
- const last = [...this.visibleChildren.values()].pop();
290
- if (last) this.repaintChild(last, true);
291
- return;
292
- }
293
- const mark = this.allOk ? theme.fg("success", "✓") : theme.fg("error", "✗");
294
- const more = theme.fg("muted", `+${collapsed} more`);
295
- const sumText = this.hiddenSummaries.length > 0
296
- ? ` ${theme.fg("muted", this.hiddenSummaries.join(", "))}`
297
- : "";
298
- this.aggregateText.setText(`${theme.fg("muted", "└")} ${more} ${mark}${sumText}`);
162
+ toggleExpanded(): void {
163
+ this.expanded = !this.expanded;
164
+ this.repaint();
165
+ }
166
+
167
+ /** How many children at the tail are visible right now. When collapsed and
168
+ * over the cap, this is maxVisible − 1 (one line goes to the summary). */
169
+ private visibleSliceStart(): number {
170
+ if (this.expanded || !Number.isFinite(this.maxVisible)) return 0;
171
+ if (this.allChildren.length <= this.maxVisible) return 0;
172
+ return this.allChildren.length - (this.maxVisible - 1);
299
173
  }
300
174
 
301
- isComplete(): boolean { return this.completedCount >= this.total; }
175
+ private repaint(): void {
176
+ const start = this.visibleSliceStart();
177
+ const evicted = this.allChildren.slice(0, start);
178
+ const visible = this.allChildren.slice(start);
179
+
180
+ if (evicted.length === 0) {
181
+ this.summaryText.setText("");
182
+ } else {
183
+ const allOk = evicted.every(
184
+ (c) => !c.status || c.status.exitCode === null || c.status.exitCode === 0,
185
+ );
186
+ const mark = allOk ? theme.fg("success", "✓") : theme.fg("error", "✗");
187
+ const noun = evicted.length === 1 ? "earlier call" : "earlier calls";
188
+ this.summaryText.setText(
189
+ `${theme.fg("muted", "├")} ${theme.fg("muted", "⋯")} ${theme.fg("muted", `${evicted.length} ${noun}`)} ${mark}`,
190
+ );
191
+ }
192
+
193
+ // Reconcile childContainer to exactly the `visible` slice, in order. We
194
+ // rebuild rather than diff because group sizes are small.
195
+ this.childContainer.clear();
196
+ visible.forEach((child, idx) => {
197
+ const isLast = idx === visible.length - 1;
198
+ this.repaintChild(child, isLast);
199
+ this.childContainer.addChild(child.text);
200
+ });
201
+ }
302
202
 
303
- private repaintChild(child: GroupChild, isLast = false): void {
203
+ private repaintChild(child: GroupChild, isLast: boolean): void {
304
204
  let tail: string;
305
205
  if (!child.status) {
306
206
  tail = ` ${theme.fg("muted", "…")}`;
@@ -311,8 +211,6 @@ export class ToolGroup extends Container {
311
211
  tail = ` ${mark}${sum}`;
312
212
  }
313
213
  const connector = isLast ? "└" : "├";
314
- // Tool name omitted when it duplicates the kind header (e.g. read_file
315
- // children under "◆ read").
316
214
  const namePart = child.name !== this.kind
317
215
  ? `${theme.bold(theme.fg("toolTitle", child.name))} `
318
216
  : "";
@@ -0,0 +1,232 @@
1
+ // Default schema-style renderers shipped with ashi. Each uses only the public
2
+ // "@guanyilun/ashi/render" surface — they could equally well live externally.
3
+
4
+ import type { ExtensionContext } from "agent-sh/types";
5
+ import type { RenderModel, Segment, ToolDisplay, TitleIcon, Color } from "./schema.js";
6
+
7
+ function parseRaw(raw: unknown): Record<string, unknown> {
8
+ if (typeof raw === "string") {
9
+ try { return JSON.parse(raw) as Record<string, unknown>; } catch { return {}; }
10
+ }
11
+ if (raw && typeof raw === "object") return raw as Record<string, unknown>;
12
+ return {};
13
+ }
14
+
15
+ const str = (v: unknown): string | undefined =>
16
+ typeof v === "string" && v.length > 0 ? v : undefined;
17
+ const num = (v: unknown): number | undefined =>
18
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
19
+
20
+ function compact(s: string, max = 80): string {
21
+ const c = s.replace(/\s+/g, " ").trim();
22
+ return c.length > max ? c.slice(0, max - 1) + "…" : c;
23
+ }
24
+
25
+ function relativize(fp: string): string {
26
+ const home = process.env.HOME;
27
+ const cwd = process.cwd();
28
+ if (fp.startsWith(`${cwd}/`)) return fp.slice(cwd.length + 1);
29
+ if (home && fp.startsWith(`${home}/`)) return `~/${fp.slice(home.length + 1)}`;
30
+ return fp;
31
+ }
32
+
33
+ const nameSeg = (text: string): Segment => ({ text, style: { bold: true, color: "toolTitle" } });
34
+ const accentSeg = (text: string): Segment => ({ text, style: { color: "accent" } });
35
+ const mutedSeg = (text: string): Segment => ({ text, style: { color: "muted" } });
36
+ const warnSeg = (text: string): Segment => ({ text, style: { color: "warning" } });
37
+
38
+ interface BashInit { command: string; timeout?: number }
39
+
40
+ const bashModel: RenderModel<BashInit> = {
41
+ initial: ({ rawInput }) => {
42
+ const r = parseRaw(rawInput);
43
+ return { command: str(r.command) ?? "…", timeout: num(r.timeout) };
44
+ },
45
+ view: (s, env): ToolDisplay => {
46
+ const title: Segment[] = [
47
+ nameSeg("$ "),
48
+ { text: env.expanded ? s.command : compact(s.command), highlight: "bash" },
49
+ ];
50
+ if (s.timeout !== undefined) title.push(mutedSeg(` (timeout ${s.timeout}s)`));
51
+ return {
52
+ title,
53
+ status: s.status,
54
+ body: { kind: "stream", text: s.output },
55
+ expandable: true,
56
+ };
57
+ },
58
+ };
59
+
60
+ /** User-typed `!` shell commands. `▸` mirrors the status-footer glyph; the
61
+ * right-aligned tag disambiguates private vs public on scrollback. */
62
+ function makeUserBashModel(opts: { private: boolean }): RenderModel<BashInit> {
63
+ const color: Color = opts.private ? "bashModePrivate" : "bashMode";
64
+ const prefixSeg: Segment = { text: "▸ ", style: { bold: true, color } };
65
+ const tagText = opts.private ? "shell · private" : "shell";
66
+ const tagSeg: Segment = { text: tagText, style: { color, dim: true } };
67
+ return {
68
+ initial: ({ rawInput }) => {
69
+ const r = parseRaw(rawInput);
70
+ return { command: str(r.command) ?? "…", timeout: num(r.timeout) };
71
+ },
72
+ view: (s, env): ToolDisplay => ({
73
+ title: [prefixSeg, { text: env.expanded ? s.command : compact(s.command), highlight: "bash" }],
74
+ titleRight: [tagSeg],
75
+ status: s.status,
76
+ body: { kind: "stream", text: s.output },
77
+ expandable: true,
78
+ }),
79
+ };
80
+ }
81
+
82
+ interface ReadInit { path: string; range?: string }
83
+
84
+ const readModel: RenderModel<ReadInit> = {
85
+ initial: ({ rawInput }) => {
86
+ const r = parseRaw(rawInput);
87
+ const path = str(r.file_path) ?? str(r.path);
88
+ const offset = num(r.offset);
89
+ const limit = num(r.limit);
90
+ let range: string | undefined;
91
+ if (offset !== undefined || limit !== undefined) {
92
+ const from = offset ?? 1;
93
+ const to = limit !== undefined ? from + limit - 1 : undefined;
94
+ range = to ? `:${from}-${to}` : `:${from}`;
95
+ }
96
+ return { path: path ? relativize(path) : "…", range };
97
+ },
98
+ view: (s) => ({
99
+ titleIcon: "read",
100
+ title: [
101
+ nameSeg("read "),
102
+ accentSeg(s.path),
103
+ ...(s.range ? [warnSeg(s.range)] : []),
104
+ ],
105
+ status: s.status,
106
+ body: { kind: "stream", text: s.output },
107
+ expandable: true,
108
+ }),
109
+ };
110
+
111
+ interface GrepInit { pattern: string; scope: string; extras: string }
112
+
113
+ const grepModel: RenderModel<GrepInit> = {
114
+ initial: ({ rawInput }) => {
115
+ const r = parseRaw(rawInput);
116
+ const glob = str(r.glob);
117
+ const limit = num(r.limit);
118
+ const extras = [glob ? `(${glob})` : "", limit !== undefined ? `limit ${limit}` : ""]
119
+ .filter(Boolean).join(" ");
120
+ return {
121
+ pattern: str(r.pattern) ?? "…",
122
+ scope: relativize(str(r.path) ?? "."),
123
+ extras,
124
+ };
125
+ },
126
+ view: (s) => ({
127
+ titleIcon: "search",
128
+ title: [
129
+ nameSeg("grep "),
130
+ accentSeg(`/${s.pattern}/`),
131
+ { text: " " },
132
+ mutedSeg(`in ${s.scope}`),
133
+ ...(s.extras ? [mutedSeg(` ${s.extras}`)] : []),
134
+ ],
135
+ status: s.status,
136
+ body: { kind: "stream", text: s.output },
137
+ expandable: true,
138
+ }),
139
+ };
140
+
141
+ interface PathPatternInit { pattern: string; scope: string }
142
+
143
+ const globModel: RenderModel<PathPatternInit> = {
144
+ initial: ({ rawInput }) => {
145
+ const r = parseRaw(rawInput);
146
+ return { pattern: str(r.pattern) ?? "…", scope: relativize(str(r.path) ?? ".") };
147
+ },
148
+ view: (s) => ({
149
+ titleIcon: "search",
150
+ title: [nameSeg("glob "), accentSeg(s.pattern), { text: " " }, mutedSeg(`in ${s.scope}`)],
151
+ status: s.status,
152
+ body: { kind: "stream", text: s.output },
153
+ expandable: true,
154
+ }),
155
+ };
156
+
157
+ interface LsInit { path: string }
158
+
159
+ const lsModel: RenderModel<LsInit> = {
160
+ initial: ({ rawInput }) => {
161
+ const r = parseRaw(rawInput);
162
+ return { path: relativize(str(r.path) ?? ".") };
163
+ },
164
+ view: (s) => ({
165
+ titleIcon: "read",
166
+ title: [nameSeg("ls "), accentSeg(s.path)],
167
+ status: s.status,
168
+ body: { kind: "stream", text: s.output },
169
+ expandable: true,
170
+ }),
171
+ };
172
+
173
+ interface EditInit { path: string; verb: string }
174
+
175
+ function editLikeModel(verb: string): RenderModel<EditInit> {
176
+ return {
177
+ initial: ({ rawInput }) => {
178
+ const r = parseRaw(rawInput);
179
+ const path = str(r.file_path) ?? str(r.path);
180
+ return { path: path ? relativize(path) : "…", verb };
181
+ },
182
+ view: (s, env) => ({
183
+ titleIcon: "edit",
184
+ title: [nameSeg(`${s.verb} `), accentSeg(s.path)],
185
+ status: s.status,
186
+ // Collapsed shows just the diff (the "Edited /path (+N -M)" stream
187
+ // line would only restate the call); expand adds the stream output.
188
+ body: s.hasDiff
189
+ ? (env.expanded
190
+ ? { kind: "compound", parts: [{ kind: "diff" }, { kind: "stream", text: s.output }] }
191
+ : { kind: "diff" })
192
+ : { kind: "stream", text: s.output },
193
+ expandable: true,
194
+ }),
195
+ };
196
+ }
197
+
198
+ interface DefaultInit { title: string; detail?: string; icon: TitleIcon }
199
+
200
+ const defaultModel: RenderModel<DefaultInit> = {
201
+ initial: ({ title, displayDetail }) => ({
202
+ title,
203
+ detail: displayDetail,
204
+ icon: "generic",
205
+ }),
206
+ view: (s) => ({
207
+ titleIcon: s.icon,
208
+ title: [
209
+ nameSeg(s.title),
210
+ ...(s.detail ? [{ text: " " }, mutedSeg(s.detail)] : []),
211
+ ],
212
+ status: s.status,
213
+ body: { kind: "stream", text: s.output },
214
+ expandable: true,
215
+ }),
216
+ };
217
+
218
+ export function registerDefaultSchemaRenderers(ctx: ExtensionContext): void {
219
+ ctx.define("ashi:render-tool:bash", () => bashModel);
220
+ ctx.define("ashi:render-tool:user_bash", () => makeUserBashModel({ private: false }));
221
+ ctx.define("ashi:render-tool:user_bash_private", () => makeUserBashModel({ private: true }));
222
+ ctx.define("ashi:render-tool:read_file", () => readModel);
223
+ ctx.define("ashi:render-tool:read", () => readModel);
224
+ ctx.define("ashi:render-tool:grep", () => grepModel);
225
+ ctx.define("ashi:render-tool:glob", () => globModel);
226
+ ctx.define("ashi:render-tool:ls", () => lsModel);
227
+ ctx.define("ashi:render-tool:edit_file", () => editLikeModel("edit"));
228
+ ctx.define("ashi:render-tool:edit", () => editLikeModel("edit"));
229
+ ctx.define("ashi:render-tool:write_file", () => editLikeModel("write"));
230
+ ctx.define("ashi:render-tool:write", () => editLikeModel("write"));
231
+ ctx.define("ashi:render-tool:default", () => defaultModel);
232
+ }
@@ -7,12 +7,11 @@ export interface ToolEntryConfig {
7
7
  previewLines: number;
8
8
  }
9
9
 
10
- export interface ToolDisplayConfig {
11
- default: ToolEntryConfig;
12
- [toolName: string]: ToolEntryConfig;
10
+ export interface DisplayResolver {
11
+ resolve(name: string, modelDisplay?: Partial<ToolEntryConfig>): ToolEntryConfig;
13
12
  }
14
13
 
15
- const DEFAULT_ENTRY: ToolEntryConfig = { result: "preview", previewLines: 8 };
14
+ const DEFAULT_ENTRY: ToolEntryConfig = { result: "preview", previewLines: 5 };
16
15
 
17
16
  const BUILTIN_OVERRIDES: Record<string, Partial<ToolEntryConfig>> = {
18
17
  read: { result: "hidden" },
@@ -20,7 +19,7 @@ const BUILTIN_OVERRIDES: Record<string, Partial<ToolEntryConfig>> = {
20
19
  grep: { result: "summary" },
21
20
  find: { result: "summary" },
22
21
  glob: { result: "summary" },
23
- bash: { result: "preview", previewLines: 12 },
22
+ bash: { result: "preview" },
24
23
  edit: { result: "preview" },
25
24
  edit_file: { result: "preview" },
26
25
  write: { result: "preview" },
@@ -29,6 +28,14 @@ const BUILTIN_OVERRIDES: Record<string, Partial<ToolEntryConfig>> = {
29
28
 
30
29
  interface AshiSettings extends Record<string, unknown> {
31
30
  display?: Record<string, Partial<ToolEntryConfig>>;
31
+ groupMaxVisible?: number;
32
+ }
33
+
34
+ export function loadGroupMaxVisible(): number {
35
+ const ashi = getExtensionSettings<AshiSettings>("ashi", {});
36
+ const v = ashi.groupMaxVisible;
37
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 2) return Infinity;
38
+ return Math.floor(v);
32
39
  }
33
40
 
34
41
  function mergeEntry(base: ToolEntryConfig, patch?: Partial<ToolEntryConfig>): ToolEntryConfig {
@@ -39,24 +46,16 @@ function mergeEntry(base: ToolEntryConfig, patch?: Partial<ToolEntryConfig>): To
39
46
  };
40
47
  }
41
48
 
42
- export function loadToolDisplayConfig(): ToolDisplayConfig {
49
+ export function loadDisplayResolver(): DisplayResolver {
43
50
  const ashi = getExtensionSettings<AshiSettings>("ashi", {});
44
51
  const userDisplay = ashi.display ?? {};
45
52
  const userDefault = mergeEntry(DEFAULT_ENTRY, userDisplay.default);
46
- const config: ToolDisplayConfig = { default: userDefault };
47
- const names = new Set([
48
- ...Object.keys(BUILTIN_OVERRIDES),
49
- ...Object.keys(userDisplay).filter((k) => k !== "default"),
50
- ]);
51
- for (const name of names) {
52
- config[name] = mergeEntry(
53
- mergeEntry(userDefault, BUILTIN_OVERRIDES[name]),
54
- userDisplay[name],
55
- );
56
- }
57
- return config;
58
- }
59
-
60
- export function entryFor(config: ToolDisplayConfig, name: string): ToolEntryConfig {
61
- return config[name] ?? config.default;
53
+ return {
54
+ resolve(name, modelDisplay) {
55
+ let entry = mergeEntry(userDefault, BUILTIN_OVERRIDES[name]);
56
+ if (modelDisplay) entry = mergeEntry(entry, modelDisplay);
57
+ if (userDisplay[name]) entry = mergeEntry(entry, userDisplay[name]);
58
+ return entry;
59
+ },
60
+ };
62
61
  }