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
@@ -1,12 +1,8 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
2
  import type { ExtensionContext } from "agent-sh/types";
3
- import {
4
- AssistantMessage,
5
- ThinkingBlock,
6
- ToolResultBody,
7
- UserMessage,
8
- } from "./components.js";
9
- import { entryFor, loadToolDisplayConfig, type ToolResultMode } from "./display-config.js";
3
+ import { AssistantMessage, ThinkingBlock, UserMessage } from "./components.js";
4
+ import { loadDisplayResolver, type ToolResultMode } from "./display-config.js";
5
+ import { isRenderModel, mountCall, mountResult, type RenderModel } from "./schema.js";
10
6
 
11
7
  export interface RenderState {
12
8
  state: Record<string, unknown>;
@@ -14,41 +10,19 @@ export interface RenderState {
14
10
  }
15
11
 
16
12
  export interface UserMessageArgs extends RenderState { text: string }
17
-
18
13
  export interface AssistantArgs extends RenderState { text: string }
19
-
20
14
  export interface ThinkingArgs extends RenderState { text: string; hidden: boolean }
21
15
 
22
- export interface ToolCallArgs extends RenderState {
23
- toolCallId: string;
24
- name: string;
25
- title: string;
26
- kind?: string;
27
- displayDetail?: string;
28
- rawInput?: unknown;
29
- }
30
-
31
- export interface ToolResultArgs extends RenderState {
32
- toolCallId: string;
33
- name: string;
34
- kind?: string;
35
- rawInput?: unknown;
36
- /** Resolved from ashi.display.{name} (or .default) in settings.json. */
37
- mode: ToolResultMode;
38
- previewLines: number;
39
- }
40
-
41
- /** Mutated by ashi when the tool completes. Renderers may ignore setStatus
42
- * if they encode status differently (e.g. a sigil in the call line). */
16
+ /** The contract ashi's frontend mutates on the call-side component. Schema
17
+ * renderers satisfy this via SchemaCallComponent — extension authors don't
18
+ * implement it directly. */
43
19
  export interface ToolCallView extends Component {
44
20
  setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
21
+ toggleExpanded?(): void;
45
22
  }
46
23
 
47
- /** Mutated by ashi as output streams in and when the tool completes.
48
- * setDiffRenderer is optional behavior renderers may no-op if they don't
49
- * show diffs. The renderer is called on each terminal-width change so diffs
50
- * reflow on resize. toggleExpanded flips the view's internal expansion state
51
- * (Ctrl+O). */
24
+ /** Result-side counterpart. setDiffRenderer receives the width-aware diff
25
+ * closure produced by the edit/write tool at finalize. */
52
26
  export interface ToolResultView extends Component {
53
27
  appendChunk(chunk: string): void;
54
28
  setDiffRenderer(fn: (width: number) => string[]): void;
@@ -56,12 +30,8 @@ export interface ToolResultView extends Component {
56
30
  toggleExpanded(): void;
57
31
  }
58
32
 
59
- const CALL_PREFIX = "ashi:render-tool-call:";
60
- const RESULT_PREFIX = "ashi:render-tool-result:";
33
+ const SCHEMA_PREFIX = "ashi:render-tool:";
61
34
 
62
- /** Register the default render-* handlers. Per-tool overrides are advised by
63
- * name (e.g. `ashi:render-tool-call:bash`); unknown tools fall back to
64
- * `:default`. */
65
35
  export function registerRenderDefaults(ctx: ExtensionContext): void {
66
36
  ctx.define("ashi:render-user-message", (args: UserMessageArgs): Component => {
67
37
  return new UserMessage(args.text);
@@ -85,54 +55,68 @@ export function registerRenderDefaults(ctx: ExtensionContext): void {
85
55
  tb.setHidden(args.hidden);
86
56
  return tb;
87
57
  });
58
+ }
88
59
 
89
- ctx.define(`${RESULT_PREFIX}default`, (args: ToolResultArgs): ToolResultView => {
90
- return new ToolResultBody(args.mode, args.previewLines);
91
- });
60
+ export interface ToolCallResolveArgs {
61
+ toolCallId: string;
62
+ name: string;
63
+ title: string;
64
+ kind?: string;
65
+ displayDetail?: string;
66
+ rawInput?: unknown;
67
+ }
68
+
69
+ export interface ToolResultResolveArgs {
70
+ toolCallId: string;
71
+ name: string;
72
+ kind?: string;
73
+ rawInput?: unknown;
92
74
  }
93
75
 
94
76
  export interface ToolHookResolver {
95
- call: (args: Omit<ToolCallArgs, "state" | "invalidate"> & Partial<RenderState>) => ToolCallView;
96
- result: (args: Omit<ToolResultArgs, "mode" | "previewLines" | "state" | "invalidate"> & Partial<RenderState>) => ToolResultView;
77
+ call: (args: ToolCallResolveArgs) => ToolCallView;
78
+ result: (args: ToolResultResolveArgs) => ToolResultView;
97
79
  modeFor: (name: string) => { mode: ToolResultMode; previewLines: number };
98
80
  }
99
81
 
100
- /** Resolves :{name} :default for tool render hooks and looks up each tool's
101
- * display mode from ashi.display. Cache the registered-handler set; callers
102
- * can `refresh()` after extensions register new tool-specific renderers. */
82
+ /** Resolves a tool name to a schema RenderModel :{name} first, then :default.
83
+ * Cache the registered-handler set; callers can `refresh()` after extensions
84
+ * register new tool-specific renderers. */
103
85
  export function createToolHookResolver(
104
86
  ctx: ExtensionContext,
105
- renderState: () => RenderState,
106
87
  ): ToolHookResolver & { refresh: () => void } {
107
- const config = loadToolDisplayConfig();
88
+ const resolver = loadDisplayResolver();
108
89
  let registered = new Set(ctx.list());
109
90
 
110
- const pick = (prefix: string, name: string): string => {
111
- const specific = `${prefix}${name}`;
112
- return registered.has(specific) ? specific : `${prefix}default`;
91
+ const schemaModel = (name: string): RenderModel<unknown> => {
92
+ for (const candidate of [`${SCHEMA_PREFIX}${name}`, `${SCHEMA_PREFIX}default`]) {
93
+ if (!registered.has(candidate)) continue;
94
+ const v = ctx.call(candidate, {}) as unknown;
95
+ if (isRenderModel(v)) return v;
96
+ }
97
+ throw new Error(`no render model for tool "${name}" and no ${SCHEMA_PREFIX}default registered`);
113
98
  };
114
99
 
100
+ // SchemaResultComponent.render(width) corrects env.width on the first frame.
101
+ const initialWidth = (): number => process.stdout.columns ?? 80;
102
+
115
103
  return {
116
104
  refresh(): void {
117
105
  registered = new Set(ctx.list());
118
106
  },
119
107
  modeFor(name: string) {
120
- const e = entryFor(config, name);
108
+ const e = resolver.resolve(name, schemaModel(name).display);
121
109
  return { mode: e.result, previewLines: e.previewLines };
122
110
  },
123
111
  call(args) {
124
- const handler = pick(CALL_PREFIX, args.name);
125
- return ctx.call(handler, { ...renderState(), ...args }) as ToolCallView;
112
+ const { mode, previewLines } = this.modeFor(args.name);
113
+ return mountCall(schemaModel(args.name), args,
114
+ { width: initialWidth(), mode, previewLines }) as ToolCallView;
126
115
  },
127
116
  result(args) {
128
117
  const { mode, previewLines } = this.modeFor(args.name);
129
- const handler = pick(RESULT_PREFIX, args.name);
130
- return ctx.call(handler, {
131
- ...renderState(),
132
- ...args,
133
- mode,
134
- previewLines,
135
- }) as ToolResultView;
118
+ return mountResult(schemaModel(args.name), { ...args, title: args.name },
119
+ { width: initialWidth(), mode, previewLines }) as ToolResultView;
136
120
  },
137
121
  };
138
122
  }
@@ -13,21 +13,62 @@ export interface SessionInfo {
13
13
  }
14
14
 
15
15
  /** Many sessions per cwd. Each is one .jsonl file under `dir/`. Constructor
16
- * always opens a fresh session; /resume callers can `openSession(id)` to
17
- * swap the current store to a past session file. */
16
+ * always opens a fresh session unless `opts.resumeSessionId` is given and
17
+ * points to an existing session file. /resume callers can `openSession(id)`
18
+ * to swap the current store to a past session file. */
18
19
  export class MultiSessionStore {
19
20
  private dir: string;
20
21
  private cwd: string;
21
22
  private currentStore: SessionStore;
22
23
 
23
- constructor(dir: string, cwd: string) {
24
+ constructor(dir: string, cwd: string, opts?: { resumeSessionId?: string }) {
24
25
  this.dir = dir;
25
26
  this.cwd = cwd;
26
27
  fs.mkdirSync(dir, { recursive: true });
27
28
  this.migrateLegacy();
29
+ if (opts?.resumeSessionId) {
30
+ const filePath = this.sessionFile(opts.resumeSessionId);
31
+ if (fs.existsSync(filePath)) {
32
+ this.currentStore = new SessionStore(filePath);
33
+ return;
34
+ }
35
+ }
28
36
  this.currentStore = this.createFreshSession();
29
37
  }
30
38
 
39
+ markLastSession(): void {
40
+ const lastFile = path.join(this.dir, ".last-session");
41
+ fs.writeFileSync(lastFile, this.currentStore.id);
42
+ }
43
+
44
+ static readLastSessionId(dir: string, opts?: { fallbackToLatest?: boolean }): string | undefined {
45
+ const lastFile = path.join(dir, ".last-session");
46
+ try {
47
+ const id = fs.readFileSync(lastFile, "utf-8").trim();
48
+ if (id && fs.existsSync(path.join(dir, `${id}.jsonl`))) return id;
49
+ } catch { /* no .last-session file yet */ }
50
+
51
+ if (opts?.fallbackToLatest) {
52
+ let best: { id: string; createdAt: number } | undefined;
53
+ let names: string[];
54
+ try { names = fs.readdirSync(dir); } catch { return undefined; }
55
+ for (const name of names) {
56
+ if (!name.endsWith(".jsonl")) continue;
57
+ const id = name.slice(0, -".jsonl".length);
58
+ try {
59
+ const raw = fs.readFileSync(path.join(dir, `${id}.jsonl.meta`), "utf-8");
60
+ const meta = JSON.parse(raw) as { createdAt?: number };
61
+ if (typeof meta.createdAt === "number" && (!best || meta.createdAt > best.createdAt)) {
62
+ best = { id, createdAt: meta.createdAt };
63
+ }
64
+ } catch { /* skip unreadable meta */ }
65
+ }
66
+ if (best) return best.id;
67
+ }
68
+
69
+ return undefined;
70
+ }
71
+
31
72
  /** One-time import from the previous storage format (sessions stored as
32
73
  * directories with tree.jsonl + snapshots/). Each old session is replayed
33
74
  * from its most recent snapshot into a new flat `.jsonl` file, then the
@@ -0,0 +1,386 @@
1
+ // Declarative render schema for tool-call hooks. External renderers register
2
+ // `ctx.define("ashi:render-tool:<name>", () => ({ initial, reducers, view }))`.
3
+
4
+ import { Container, Spacer, Text, visibleWidth } from "@earendil-works/pi-tui";
5
+ import type { Component } from "@earendil-works/pi-tui";
6
+ import type { ThemeColor } from "./theme.js";
7
+ import type { ToolEntryConfig } from "./display-config.js";
8
+
9
+ export type { ToolEntryConfig, ToolResultMode } from "./display-config.js";
10
+
11
+ export type Color = ThemeColor;
12
+
13
+ export interface StyleHint {
14
+ color?: Color;
15
+ bold?: boolean;
16
+ dim?: boolean;
17
+ italic?: boolean;
18
+ }
19
+
20
+ export type Segment = string | { text: string; style?: StyleHint; highlight?: string };
21
+
22
+ export type Body =
23
+ | { kind: "text"; segments: Segment[] }
24
+ | { kind: "code"; lang?: string; text: string }
25
+ /** Width-aware renderer is supplied via setDiffRenderer; view() opts in by
26
+ * returning { kind: "diff" } and gating on hasDiff in state. */
27
+ | { kind: "diff" }
28
+ | { kind: "stream"; text: string }
29
+ | { kind: "lines"; lines: Segment[][] }
30
+ | { kind: "compound"; parts: Body[] };
31
+
32
+ export interface DisplayStatus {
33
+ exitCode: number | null;
34
+ elapsedMs: number;
35
+ summary?: string;
36
+ }
37
+
38
+ /** Renderers pick a category; ashi picks the glyph. Falls back to generic. */
39
+ export type TitleIcon = "read" | "search" | "edit" | "shell" | "generic" | "scheme";
40
+
41
+ export interface ToolDisplay {
42
+ titleIcon?: TitleIcon;
43
+ title: Segment[];
44
+ /** Right-aligned on the title line; framework handles padding and reserves
45
+ * space for the status suffix so renderers don't compute widths. */
46
+ titleRight?: Segment[];
47
+ status?: DisplayStatus;
48
+ body?: Body;
49
+ expandable?: boolean;
50
+ defaultExpanded?: boolean;
51
+ }
52
+
53
+ /** `mode` and `previewLines` come from ashi.display.{name} (display-config.ts). */
54
+ export interface Env {
55
+ width: number;
56
+ expanded: boolean;
57
+ finalized: boolean;
58
+ mode: "preview" | "summary" | "hidden";
59
+ previewLines: number;
60
+ }
61
+
62
+ export type Reducer<S, P = unknown> = (state: S, payload: P) => S;
63
+
64
+ /** Framework tracks `output`, `status`, `hasDiff` — renderers don't wire
65
+ * `chunk` / `status` / `diff` reducers themselves. */
66
+ export type ViewState<S> = S & {
67
+ output: string;
68
+ status?: DisplayStatus;
69
+ hasDiff: boolean;
70
+ };
71
+
72
+ export interface RenderInitArgs {
73
+ rawInput?: unknown;
74
+ name: string;
75
+ title: string;
76
+ kind?: string;
77
+ displayDetail?: string;
78
+ }
79
+
80
+ export interface RenderModel<S = Record<string, never>> {
81
+ initial: (args: RenderInitArgs) => S;
82
+ /** Only for tool-specific state transitions; `status`/`chunk` are framework-tracked. */
83
+ reducers?: Record<string, Reducer<ViewState<S>, never>>;
84
+ view: (state: ViewState<S>, env: Env) => ToolDisplay;
85
+ display?: Partial<ToolEntryConfig>;
86
+ }
87
+
88
+ export function isRenderModel(v: unknown): v is RenderModel<unknown> {
89
+ if (!v || typeof v !== "object") return false;
90
+ const o = v as Record<string, unknown>;
91
+ return typeof o.initial === "function" && typeof o.view === "function";
92
+ }
93
+
94
+ // Call-side and result-side components share one state cell, so chunks from
95
+ // the result side can repaint the call line (e.g. for in-title progress).
96
+
97
+ import { theme } from "./theme.js";
98
+
99
+ interface DiffSlot {
100
+ fn?: (width: number) => string[];
101
+ lastWidth: number;
102
+ cached: string[];
103
+ }
104
+
105
+ interface SharedCell<S> {
106
+ state: S;
107
+ env: Env;
108
+ diff: DiffSlot;
109
+ callView?: SchemaCallComponent;
110
+ resultView?: SchemaResultComponent;
111
+ }
112
+
113
+ interface RenderHandle<S> {
114
+ cell: SharedCell<S>;
115
+ model: RenderModel<S>;
116
+ toolCallId: string;
117
+ dispatch: (action: string, payload?: unknown) => void;
118
+ }
119
+
120
+ /** Lets the result-side mount find the call-side cell. Cleared on finalize. */
121
+ const HANDLES = new Map<string, RenderHandle<unknown>>();
122
+
123
+ export interface MountArgs {
124
+ toolCallId: string;
125
+ name: string;
126
+ title: string;
127
+ kind?: string;
128
+ displayDetail?: string;
129
+ rawInput?: unknown;
130
+ }
131
+
132
+ function handleFor<S>(
133
+ args: MountArgs,
134
+ model: RenderModel<S>,
135
+ envInit: { width: number; mode: Env["mode"]; previewLines: number },
136
+ ): RenderHandle<S> {
137
+ const existing = HANDLES.get(args.toolCallId) as RenderHandle<S> | undefined;
138
+ if (existing) return existing;
139
+ const userInitial = model.initial({
140
+ rawInput: args.rawInput,
141
+ name: args.name,
142
+ title: args.title,
143
+ kind: args.kind,
144
+ displayDetail: args.displayDetail,
145
+ });
146
+ const cell: SharedCell<ViewState<S>> = {
147
+ state: { ...(userInitial as object), output: "", hasDiff: false } as ViewState<S>,
148
+ env: { ...envInit, expanded: false, finalized: false },
149
+ diff: { lastWidth: -1, cached: [] },
150
+ };
151
+ const reducers = model.reducers ?? {};
152
+ const handle: RenderHandle<ViewState<S>> = {
153
+ cell,
154
+ model: model as unknown as RenderModel<ViewState<S>>,
155
+ toolCallId: args.toolCallId,
156
+ dispatch(action, payload) {
157
+ if (action === "status") {
158
+ cell.state = { ...cell.state, status: payload as DisplayStatus };
159
+ } else if (action === "chunk") {
160
+ cell.state = { ...cell.state, output: cell.state.output + (payload as string) };
161
+ } else if (action === "diff") {
162
+ cell.diff = { fn: payload as (w: number) => string[], lastWidth: -1, cached: [] };
163
+ cell.state = { ...cell.state, hasDiff: true };
164
+ } else {
165
+ const reducer = reducers[action];
166
+ if (!reducer) return;
167
+ cell.state = (reducer as Reducer<ViewState<S>, unknown>)(cell.state, payload);
168
+ }
169
+ cell.callView?.repaint();
170
+ cell.resultView?.repaint();
171
+ },
172
+ };
173
+ HANDLES.set(args.toolCallId, handle as unknown as RenderHandle<unknown>);
174
+ return handle as unknown as RenderHandle<S>;
175
+ }
176
+
177
+ // Sole place that knows about theme colors + highlighting; renderers stay pure-data.
178
+
179
+ import { highlight, supportsLanguage } from "cli-highlight";
180
+
181
+ function styleSegment(seg: Segment): string {
182
+ if (typeof seg === "string") return seg;
183
+ let text = seg.text;
184
+ if (seg.highlight && supportsLanguage(seg.highlight)) {
185
+ try { text = highlight(text, { language: seg.highlight, ignoreIllegals: true }); }
186
+ catch { /* fall through */ }
187
+ }
188
+ const s = seg.style;
189
+ if (!s) return text;
190
+ if (s.color) text = theme.fg(s.color, text);
191
+ if (s.bold) text = theme.bold(text);
192
+ if (s.italic) text = theme.italic(text);
193
+ if (s.dim) text = theme.fg("dim", text);
194
+ return text;
195
+ }
196
+
197
+ function segmentsToString(segs: Segment[]): string {
198
+ return segs.map(styleSegment).join("");
199
+ }
200
+
201
+ const TITLE_ICON_GLYPH: Record<TitleIcon, string> = {
202
+ read: "◆", search: "⌕", edit: "✎", shell: "$", scheme: "λ", generic: "⚙",
203
+ };
204
+
205
+ function iconString(icon?: TitleIcon): string {
206
+ if (!icon) return "";
207
+ return `${theme.fg("warning", TITLE_ICON_GLYPH[icon])} `;
208
+ }
209
+
210
+ function fmtElapsed(ms: number): string {
211
+ if (ms < 1000) return `${ms}ms`;
212
+ if (ms < 10_000) return `${(ms / 1000).toFixed(2)}s`;
213
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
214
+ const totalSeconds = Math.floor(ms / 1000);
215
+ return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
216
+ }
217
+
218
+ function statusSuffix(s?: DisplayStatus): string {
219
+ if (!s) return ` ${theme.fg("muted", "…")}`;
220
+ const ok = s.exitCode === null || s.exitCode === 0;
221
+ const mark = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
222
+ const elapsed = s.elapsedMs > 0 ? ` ${theme.fg("muted", fmtElapsed(s.elapsedMs))}` : "";
223
+ const sum = s.summary ? ` ${theme.fg("muted", s.summary)}` : "";
224
+ return ` ${mark}${elapsed}${sum}`;
225
+ }
226
+
227
+ function renderBody(body: Body, env: Env, diff: DiffSlot, exitCode?: number | null): string {
228
+ switch (body.kind) {
229
+ case "text":
230
+ return segmentsToString(body.segments);
231
+ case "code": {
232
+ if (body.lang && supportsLanguage(body.lang)) {
233
+ try { return highlight(body.text, { language: body.lang, ignoreIllegals: true }); }
234
+ catch { /* fall through */ }
235
+ }
236
+ return body.text;
237
+ }
238
+ case "stream":
239
+ return renderStream(body.text, env, exitCode);
240
+ case "lines":
241
+ return body.lines.map(segmentsToString).join("\n");
242
+ case "diff": {
243
+ if (!diff.fn) return "";
244
+ if (diff.lastWidth !== env.width) {
245
+ diff.cached = diff.fn(env.width);
246
+ diff.lastWidth = env.width;
247
+ }
248
+ return diff.cached.join("\n");
249
+ }
250
+ case "compound":
251
+ return body.parts.map((p) => renderBody(p, env, diff, exitCode)).join("\n\n");
252
+ }
253
+ }
254
+
255
+ // Host-wide preview/summary/hidden policy inherited by every kind:"stream" body.
256
+ function renderStream(buffer: string, env: Env, exitCode: number | null | undefined): string {
257
+ const display = buffer.replace(/\n+$/, "");
258
+ if (env.expanded) return theme.fg("toolOutput", display);
259
+ if (env.mode === "hidden") {
260
+ if (!env.finalized) return "";
261
+ return lineCountHint(buffer, exitCode);
262
+ }
263
+ if (env.mode === "summary") {
264
+ if (!env.finalized) {
265
+ const tail = display.split("\n").slice(-2).join("\n");
266
+ return theme.fg("muted", tail);
267
+ }
268
+ return lineCountHint(buffer, exitCode);
269
+ }
270
+ if (!display) return "";
271
+ const lines = display.split("\n");
272
+ const trimmed = lines.slice(-env.previewLines).join("\n");
273
+ const remaining = Math.max(0, lines.length - env.previewLines);
274
+ const overflow = remaining > 0
275
+ ? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
276
+ : "";
277
+ return `${theme.fg("toolOutput", trimmed)}${overflow}`;
278
+ }
279
+
280
+ function lineCountHint(buffer: string, exitCode: number | null | undefined): string {
281
+ const lines = buffer.split("\n").filter((l) => l.length > 0);
282
+ const label = lines.length === 1 ? "1 line" : `${lines.length} lines`;
283
+ const ok = exitCode === null || exitCode === 0;
284
+ const arrow = ok ? theme.fg("muted", "↳ ") : theme.fg("error", "↳ ");
285
+ return `${arrow}${theme.fg("muted", label)}`;
286
+ }
287
+
288
+ // Components that satisfy the legacy ToolCallView / ToolResultView contracts.
289
+
290
+ class SchemaCallComponent extends Container {
291
+ private line: Text;
292
+ constructor(private handle: RenderHandle<unknown>) {
293
+ super();
294
+ this.line = new Text("", 1, 0);
295
+ this.addChild(new Spacer(1));
296
+ this.addChild(this.line);
297
+ handle.cell.callView = this;
298
+ this.repaint();
299
+ }
300
+
301
+ setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void {
302
+ this.handle.dispatch("status", opts);
303
+ }
304
+
305
+ repaint(): void {
306
+ const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, this.handle.cell.env);
307
+ const icon = iconString(display.titleIcon);
308
+ const title = segmentsToString(display.title);
309
+ const status = statusSuffix(display.status);
310
+ if (display.titleRight && display.titleRight.length > 0) {
311
+ const right = segmentsToString(display.titleRight);
312
+ // env.width − 2 accounts for Text's paddingX=1 on each side.
313
+ const used = visibleWidth(icon) + visibleWidth(title) + visibleWidth(status) + visibleWidth(right);
314
+ const pad = " ".repeat(Math.max(2, this.handle.cell.env.width - 2 - used));
315
+ this.line.setText(`${icon}${title}${status}${pad}${right}`);
316
+ } else {
317
+ this.line.setText(`${icon}${title}${status}`);
318
+ }
319
+ }
320
+ }
321
+
322
+ class SchemaResultComponent extends Container {
323
+ private body: Text;
324
+ constructor(private handle: RenderHandle<unknown>) {
325
+ super();
326
+ this.body = new Text("", 0, 0);
327
+ this.addChild(this.body);
328
+ handle.cell.resultView = this;
329
+ this.repaint();
330
+ }
331
+
332
+ appendChunk(chunk: string): void { this.handle.dispatch("chunk", chunk); }
333
+ setDiffRenderer(fn: (width: number) => string[]): void { this.handle.dispatch("diff", fn); }
334
+ finalize(opts: { exitCode: number | null; summary?: string }): void {
335
+ this.handle.cell.env = { ...this.handle.cell.env, finalized: true };
336
+ this.handle.dispatch("status", { ...opts, elapsedMs: 0 });
337
+ HANDLES.delete(this.handle.toolCallId);
338
+ }
339
+ toggleExpanded(): void {
340
+ this.handle.cell.env = { ...this.handle.cell.env, expanded: !this.handle.cell.env.expanded };
341
+ this.repaint();
342
+ this.handle.cell.callView?.repaint();
343
+ }
344
+
345
+ override render(width: number): string[] {
346
+ if (this.handle.cell.env.width !== width) {
347
+ this.handle.cell.env = { ...this.handle.cell.env, width };
348
+ this.repaint();
349
+ }
350
+ return super.render(width);
351
+ }
352
+
353
+ repaint(): void {
354
+ const env = this.handle.cell.env;
355
+ const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, env);
356
+ if (!display.body) { this.body.setText(""); return; }
357
+ // stream embeds the preview/summary/hidden policy; diff shows in preview
358
+ // or when expanded; other kinds show only when expanded/defaultExpanded.
359
+ if (display.body.kind === "diff" && !env.expanded && env.mode !== "preview") {
360
+ this.body.setText("");
361
+ return;
362
+ }
363
+ const policied = display.body.kind === "stream" || display.body.kind === "diff";
364
+ if (!policied && !env.expanded && !display.defaultExpanded) {
365
+ this.body.setText("");
366
+ return;
367
+ }
368
+ this.body.setText(renderBody(display.body, env, this.handle.cell.diff, display.status?.exitCode));
369
+ }
370
+ }
371
+
372
+ export interface MountEnv {
373
+ width: number;
374
+ mode: Env["mode"];
375
+ previewLines: number;
376
+ }
377
+
378
+ export function mountCall<S>(model: RenderModel<S>, args: MountArgs, env: MountEnv): Component {
379
+ const handle = handleFor(args, model, env);
380
+ return new SchemaCallComponent(handle as RenderHandle<unknown>);
381
+ }
382
+
383
+ export function mountResult<S>(model: RenderModel<S>, args: MountArgs, env: MountEnv): Component {
384
+ const handle = handleFor(args, model, env);
385
+ return new SchemaResultComponent(handle as RenderHandle<unknown>);
386
+ }