pi-jar 0.1.1 → 0.1.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.
@@ -0,0 +1,252 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import type { DelegateRegistry, DelegateRun, SubagentRecord } from "./delegate.ts";
4
+ import { safeLine } from "./diff-view.ts";
5
+ import { icon, withIcon } from "./icons.ts";
6
+ import { describe, elapsed, type ShellJob, type ShellManager } from "./shells.ts";
7
+ import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
8
+ import { ACTIVE_STATES, cleanText } from "./status.ts";
9
+
10
+ /** What to select when the view opens. */
11
+ export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
12
+ /** A teammate another extension publishes through the role-status contract; listed read-only. */
13
+ export interface ActivityRole { id: string; name: string; state: string; task?: string }
14
+ export interface ActivitySources {
15
+ subagents: DelegateRegistry;
16
+ shells?: ShellManager;
17
+ roles?: () => ActivityRole[];
18
+ }
19
+
20
+ /** Doubles as the icon key for the row glyph. */
21
+ type Status = "running" | "pending" | "success" | "error" | "stopped";
22
+ type Fg = (color: string, text: string) => string;
23
+ type Entry =
24
+ | { kind: "subagent"; id: string; status: Status; label: string; record: SubagentRecord }
25
+ | { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
26
+ | { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
27
+ type Item = Entry | { kind: "header"; label: string };
28
+
29
+ const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
30
+ const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
31
+ const SHELL_TAIL = 400;
32
+ const MAX_TASK_CHARS = 8000;
33
+
34
+ /** Left-pane rows: a header per non-empty section, then its entries. */
35
+ function collect(sources: ActivitySources): Item[] {
36
+ const items: Item[] = [];
37
+ const records = sources.subagents.records();
38
+ if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
39
+ for (const record of records) {
40
+ const { run } = record;
41
+ const status: Status = run.state === "working" ? "running" : run.state === "queued" ? "pending" : run.state === "done" ? "success" : run.error === "stopped" ? "stopped" : "error";
42
+ items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
43
+ }
44
+ const jobs = sources.shells?.summaries() ?? [];
45
+ if (jobs.length) items.push({ kind: "header", label: withIcon("shell", "SHELLS") });
46
+ for (const job of jobs) {
47
+ const status: Status = job.status === "running" ? "running" : job.status === "exited" && job.exitCode === 0 ? "success" : job.status === "killed" ? "stopped" : "error";
48
+ items.push({ kind: "shell", id: job.id, status, label: `${job.id} ${job.name}`, job });
49
+ }
50
+ let roles: ActivityRole[] = [];
51
+ try { roles = sources.roles?.() ?? []; } catch { /* teammates are decoration */ }
52
+ // jar_delegate publishes each run as a role status under its record key: list those once, as subagents.
53
+ roles = roles.filter((role) => !sources.subagents.get(role.id));
54
+ if (roles.length) items.push({ kind: "header", label: withIcon("roles", "ROLES") });
55
+ for (const role of roles) {
56
+ const status: Status = (ACTIVE_STATES as ReadonlySet<string>).has(role.state) ? "running" : role.state === "done" ? "success" : role.state === "failed" ? "error" : "pending";
57
+ items.push({ kind: "role", id: role.id, status, label: cleanText(role.name, 32) || role.id, role });
58
+ }
59
+ return items;
60
+ }
61
+
62
+ /** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
63
+ interface Details { head: string[]; body: string[] }
64
+
65
+ function subagentDetails(run: DelegateRun, status: Status, width: number, fg: Fg): Details {
66
+ const facts = [
67
+ run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
68
+ `${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`
69
+ ].filter(Boolean);
70
+ const head = [
71
+ fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
72
+ fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
73
+ ];
74
+ if (run.activity) head.push(fg("muted", run.activity));
75
+ if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
76
+ const body = ["", fg("dim", "TASK")];
77
+ for (const raw of run.task.slice(0, MAX_TASK_CHARS).split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
78
+ body.push("", fg("dim", "── transcript ──"));
79
+ if (!run.log.length) body.push(fg("dim", "(nothing yet)"));
80
+ for (const entry of run.log) {
81
+ const tone = entry.startsWith("▸ ") ? "accent" : "muted";
82
+ for (const line of wrapTextWithAnsi(entry, width)) body.push(fg(tone, line));
83
+ }
84
+ if (run.output) {
85
+ body.push("", fg("dim", "── report ──"));
86
+ for (const raw of run.output.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
87
+ }
88
+ return { head, body };
89
+ }
90
+
91
+ function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
92
+ const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
93
+ const body = ["", ...output.map((line) => fg("muted", truncateToWidth(safeLine(line), width)))];
94
+ if (!output.length) body.push(fg("dim", "(no output yet)"));
95
+ return { head, body };
96
+ }
97
+
98
+ function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
99
+ const head = [fg("accent", cleanText(role.name, 60) || role.id), fg(COLOR[status], withIcon(status, cleanText(role.state, 24))) + fg("dim", " · read-only")];
100
+ const body = role.task ? ["", fg("dim", "TASK"), ...wrapTextWithAnsi(cleanText(role.task, 400), width).map((line) => fg("text", line))] : [];
101
+ return { head, body };
102
+ }
103
+
104
+ /**
105
+ * Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
106
+ * details on the right, following new output until scrolled up. x stops a subagent or kills a shell.
107
+ */
108
+ export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
109
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
110
+ const opening = collect(sources);
111
+ if (!opening.some((item) => item.kind !== "header")) {
112
+ ctx.ui.notify("pi-jar: nothing running (subagents come from jar_delegate, shells from jar_shell)", "info");
113
+ return;
114
+ }
115
+ await ctx.ui.custom<void>((tui, theme, _keys, done) => {
116
+ let items = opening;
117
+ let at = initial ? items.findIndex((item) => item.kind !== "header" && item.kind === initial.kind && item.id === initial.id) : -1;
118
+ if (at < 0) at = items.findIndex((item) => item.kind !== "header" && item.status === "running");
119
+ if (at < 0) at = items.findIndex((item) => item.kind !== "header");
120
+ let follow = true;
121
+ let scroll = 0;
122
+ let maxScroll = 0;
123
+ let bodyRows = 0;
124
+ let listTop = 0;
125
+ let width = 80;
126
+ let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
127
+ let timer: NodeJS.Timeout | undefined;
128
+ let closed = false;
129
+ const fg: Fg = (color, text) => theme.fg(color as never, text);
130
+ const shells = sources.shells;
131
+ const previous = shells?.onChange;
132
+ const unsubscribe = sources.subagents.subscribe(() => tui.requestRender());
133
+ if (shells) shells.onChange = () => { previous?.(); tui.requestRender(); };
134
+ const cleanup = () => {
135
+ if (closed) return;
136
+ closed = true;
137
+ clearInterval(timer);
138
+ timer = undefined;
139
+ unsubscribe();
140
+ if (shells) shells.onChange = previous;
141
+ };
142
+ const close = () => { cleanup(); done(); };
143
+ // Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
144
+ const refresh = () => {
145
+ const current = items[at];
146
+ items = collect(sources);
147
+ const same = current && current.kind !== "header" ? items.findIndex((item) => item.kind !== "header" && item.kind === current.kind && item.id === current.id) : -1;
148
+ if (same >= 0) { at = same; return; }
149
+ const from = Math.max(0, Math.min(at, items.length - 1));
150
+ at = -1;
151
+ for (let index = from; index < items.length && at < 0; index++) if (items[index]!.kind !== "header") at = index;
152
+ for (let index = from - 1; index >= 0 && at < 0; index--) if (items[index]!.kind !== "header") at = index;
153
+ };
154
+ const move = (delta: number) => {
155
+ for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
156
+ if (items[index]!.kind === "header") continue;
157
+ at = index; follow = true; scroll = 0;
158
+ return;
159
+ }
160
+ };
161
+ const scrollBy = (delta: number) => {
162
+ if (!maxScroll) return;
163
+ scroll = Math.max(0, Math.min(maxScroll, scroll + delta));
164
+ // Scrolling up pauses follow; scrolling back down to the end resumes it.
165
+ follow = delta > 0 && scroll >= maxScroll;
166
+ };
167
+ const stopSelected = () => {
168
+ const item = items[at];
169
+ if (!item || item.kind === "header" || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
170
+ try {
171
+ if (item.kind === "subagent") sources.subagents.stop(item.id);
172
+ else shells?.kill(item.id);
173
+ } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
174
+ };
175
+ const component = {
176
+ invalidate() {},
177
+ dispose: cleanup,
178
+ handleInput(data: string) {
179
+ if (matchesKey(data, Key.escape) || data === "q") return close();
180
+ if (matchesKey(data, Key.up) || data === "k") move(-1);
181
+ else if (matchesKey(data, Key.down) || data === "j") move(1);
182
+ else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
183
+ else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
184
+ else if (data === "f" || data === "G") follow = true;
185
+ else if (matchesKey(data, Key.enter)) follow = !follow;
186
+ else if (data === "x") stopSelected();
187
+ else return;
188
+ tui.requestRender();
189
+ },
190
+ handleMouse(event: TuiMouseEvent) {
191
+ if (event.type === "wheel" && event.wheelDelta) { scrollBy(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
192
+ if (event.type !== "click" || event.button !== "left") return;
193
+ if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
194
+ const row = event.y - layout.top;
195
+ if (layout.leftWidth && row >= 0 && row < layout.rows && event.x < layout.leftWidth + 3) {
196
+ const index = listTop + row;
197
+ if (!items[index] || items[index].kind === "header") return;
198
+ if (index !== at) { at = index; follow = true; scroll = 0; }
199
+ tui.requestRender();
200
+ return { handled: true, focus: true };
201
+ }
202
+ const action = ACTIONS[event.y - layout.footerTop];
203
+ if (action) { component.handleInput(action.key); return { handled: true }; }
204
+ },
205
+ render(available: number): string[] {
206
+ width = Math.max(24, available);
207
+ const rows = contentRows(6 + ACTIONS.length, 6);
208
+ refresh();
209
+ const picked = items[at];
210
+ const item = picked && picked.kind !== "header" ? picked : undefined;
211
+ const listWidth = sidebarWidth(width, 20, 34);
212
+ const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
213
+ const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
214
+ const details = !item ? { head: [fg("dim", "Nothing to show.")], body: [] }
215
+ : item.kind === "subagent" ? subagentDetails(item.record.run, item.status, bodyWidth, fg)
216
+ : item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
217
+ : roleDetails(item.role, item.status, bodyWidth, fg);
218
+ // Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
219
+ const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
220
+ ...details.head].slice(0, rows - 1);
221
+ bodyRows = rows - pinned.length;
222
+ maxScroll = Math.max(0, details.body.length - bodyRows);
223
+ scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
224
+ if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
225
+ if (at >= listTop + rows) listTop = at - rows + 1;
226
+ listTop = Math.max(0, Math.min(listTop, items.length - rows));
227
+ const list = listWidth ? items.slice(listTop, listTop + rows).map((entry, offset) => {
228
+ if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
229
+ const active = listTop + offset === at;
230
+ const glyph = icon(entry.status);
231
+ return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
232
+ + fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
233
+ }) : [];
234
+ const running = entries.filter((entry) => entry.status === "running").length;
235
+ const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
236
+ const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
237
+ "Follow the latest output"], -1, ACTIONS.map((action) => action.key));
238
+ if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
239
+ const footer = [...actions, fg("dim", `↑↓ select · PgUp/PgDn scroll · ${withIcon("enter", "follow")} · ${withIcon("esc", "close")}`)];
240
+ const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list,
241
+ [...pinned, ...details.body.slice(scroll, scroll + bodyRows)], footer, rows, listWidth);
242
+ layout = split.layout;
243
+ // Subagent events and shell exits repaint through the listeners; elapsed times and shell output
244
+ // advance on a 1s tick that exists only while something runs.
245
+ if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
246
+ else if (!running && timer) { clearInterval(timer); timer = undefined; }
247
+ return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
248
+ }
249
+ };
250
+ return component;
251
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
252
+ }
package/src/advisor.ts CHANGED
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
  import type { ModelRoleManager } from "./model-roles.ts";
4
4
  import { askRole, type SideUsage } from "./side-model.ts";
5
5
  import { ROLE_PREFIX } from "./status.ts";
6
+ import { sessionBranch } from "./session-branch.ts";
6
7
 
7
8
  export const ADVISOR_TOOL = "jar_advisor";
8
9
  export const ADVISOR_MESSAGE = "pi-jar.advisor";
@@ -26,10 +27,33 @@ export const ADVISOR_SYSTEM = [
26
27
 
27
28
  type Entry = { type: string; message?: { role?: string; content?: unknown; toolName?: string; isError?: boolean; customType?: string } };
28
29
 
29
- const textOf = (content: unknown): string => typeof content === "string" ? content
30
- : Array.isArray(content) ? content.map((part: { type?: string; text?: string; name?: string; arguments?: unknown }) =>
31
- part.type === "text" ? part.text ?? "" : part.type === "toolCall" ? `[tool ${part.name} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}]` : "").filter(Boolean).join("\n")
32
- : "";
30
+ const toolHint = (args: unknown): string => {
31
+ if (!args || typeof args !== "object") return "";
32
+ const object = args as Record<string, unknown>;
33
+ const keys = ["command", "path", "file_path", "query", "id"];
34
+ for (const key of keys) {
35
+ const value = object[key];
36
+ if (typeof value === "string" && value) return `${key}=${value.slice(0, 240)}`;
37
+ }
38
+ return "";
39
+ };
40
+
41
+ const textOf = (content: unknown, limit = 4_000): string => {
42
+ if (typeof content === "string") return content.slice(0, limit);
43
+ if (!Array.isArray(content)) return "";
44
+ let out = "";
45
+ for (const part of content as Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>) {
46
+ const piece = part.type === "text" ? part.text ?? ""
47
+ : part.type === "toolCall" ? `[tool ${part.name ?? "?"}${toolHint(part.arguments) ? " " + toolHint(part.arguments) : ""}]` : "";
48
+ if (!piece) continue;
49
+ const separator = out ? "\n" : "";
50
+ const room = limit - out.length - separator.length;
51
+ if (room <= 0) break;
52
+ out += separator + piece.slice(0, room);
53
+ if (piece.length > room) break;
54
+ }
55
+ return out;
56
+ };
33
57
 
34
58
  /** The most recent conversation, newest last, capped to `limit` characters. */
35
59
  export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT): string {
@@ -38,7 +62,7 @@ export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT):
38
62
  for (let index = entries.length - 1; index >= 0 && size < limit; index--) {
39
63
  const message = entries[index]!.type === "message" ? entries[index]!.message : undefined;
40
64
  if (!message?.role) continue;
41
- let body = textOf(message.content).trim();
65
+ let body = textOf(message.content, Math.min(4_000, limit - size)).trim();
42
66
  if (!body) continue;
43
67
  if (message.role === "toolResult" && body.length > 1200) body = body.slice(0, 1200) + " …";
44
68
  const label = message.role === "toolResult" ? `tool result (${message.toolName ?? "?"}${message.isError ? ", error" : ""})` : message.role;
@@ -118,7 +142,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
118
142
  busy++;
119
143
  status(ctx, request.trigger ?? request.question ?? "reviewing the current direction");
120
144
  try {
121
- const conversation = transcript(ctx.sessionManager.getBranch() as readonly Entry[]);
145
+ const conversation = transcript(sessionBranch(ctx) as readonly Entry[]);
122
146
  return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx)), signal);
123
147
  } finally { if (--busy === 0) status(ctx); }
124
148
  };
package/src/changes.ts CHANGED
@@ -100,7 +100,6 @@ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
100
100
  export class ChangeTracker {
101
101
  private baselines = new Map<string, string | null>();
102
102
  private dirty = new Set<string>();
103
- private diffCache = new Map<string, FileChange>();
104
103
  private baselineBytes = 0;
105
104
  private readonly cwd: () => string;
106
105
  constructor(cwd: () => string) { this.cwd = cwd; }
@@ -111,7 +110,6 @@ export class ChangeTracker {
111
110
  if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
112
111
  this.baselines.delete(target);
113
112
  this.dirty.delete(target);
114
- this.diffCache.delete(target);
115
113
  }
116
114
 
117
115
  /** Record the pre-change content once; returns false when the file cannot be tracked. */
@@ -132,20 +130,12 @@ export class ChangeTracker {
132
130
  }
133
131
 
134
132
  /**
135
- * Mark one captured file after an edit/write completes. This performs only one cheap file read;
136
- * no line diff is calculated until the review UI is opened.
133
+ * Mark one captured file after a successful edit/write. Do not synchronously re-read the file on
134
+ * every tool result; the review UI reconciles content lazily when /diff is actually opened.
137
135
  */
138
136
  markDirty(path: string): boolean {
139
137
  const target = this.absolute(path);
140
138
  if (!this.baselines.has(target)) return false;
141
- const before = this.baselines.get(target)!;
142
- const now = readText(target);
143
- this.diffCache.delete(target);
144
- if (now === undefined) { this.drop(target); return false; }
145
- if (now === before) {
146
- this.dirty.delete(target);
147
- return false;
148
- }
149
139
  this.dirty.add(target);
150
140
  return true;
151
141
  }
@@ -155,22 +145,24 @@ export class ChangeTracker {
155
145
  const result: FileChange[] = [];
156
146
  for (const path of [...this.dirty]) {
157
147
  const before = this.baselines.get(path);
158
- if (before === undefined) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
148
+ if (before === undefined) { this.dirty.delete(path); continue; }
159
149
  const now = readText(path);
160
150
  if (now === undefined) { this.drop(path); continue; }
161
- if (now === before) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
162
- const cached = this.diffCache.get(path);
163
- if (cached) { result.push(cached); continue; }
151
+ if (now === before) { this.dirty.delete(path); continue; }
164
152
  const ops = lineDiff(before ?? "", now ?? "");
165
- const added = ops ? ops.filter((op) => op.op === "+").length : splitLines(now ?? "").length;
166
- const removed = ops ? ops.filter((op) => op.op === "-").length : splitLines(before ?? "").length;
153
+ let added = 0, removed = 0;
154
+ if (ops) {
155
+ for (const op of ops) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
156
+ } else {
157
+ added = splitLines(now ?? "").length;
158
+ removed = splitLines(before ?? "").length;
159
+ }
167
160
  const rel = relative(this.cwd(), path);
168
161
  const change: FileChange = {
169
162
  path, rel: rel && !rel.startsWith("..") ? rel : path,
170
163
  status: before === null ? "added" : now === null ? "deleted" : "modified",
171
164
  before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
172
165
  };
173
- this.diffCache.set(path, change);
174
166
  result.push(change);
175
167
  }
176
168
  return result.sort((a, b) => a.rel.localeCompare(b.rel));
@@ -181,7 +173,7 @@ export class ChangeTracker {
181
173
  trackedBytes(): number { return this.baselineBytes; }
182
174
  /** Keep the current content: stop tracking the file. */
183
175
  accept(path: string): void { this.drop(this.absolute(path)); }
184
- acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.diffCache.clear(); this.baselineBytes = 0; }
176
+ acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.baselineBytes = 0; }
185
177
  /** Restore the pre-change content (or remove a file the agent created). */
186
178
  revert(path: string): void {
187
179
  const target = this.absolute(path);
@@ -12,12 +12,42 @@ import { Text } from "@earendil-works/pi-tui";
12
12
 
13
13
  type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
14
14
 
15
- const outputText = (result: ToolResult): string => result.content
16
- .filter((item) => item.type === "text" && typeof item.text === "string")
17
- .map((item) => item.text!)
18
- .join("\n");
15
+ const SUMMARY_SCAN_CHARS = 32 * 1024;
16
+ const DIFF_SCAN_CHARS = 64 * 1024;
17
+ const resultSummaryCache = new WeakMap<object, { sample: string; lines: number; truncated: boolean }>();
18
+ const writeCountCache = new WeakMap<object, { lines: number; truncated: boolean }>();
19
+
20
+ function summarizeResult(result: ToolResult): { sample: string; lines: number; truncated: boolean } {
21
+ if (result && typeof result === "object") {
22
+ const cached = resultSummaryCache.get(result as object);
23
+ if (cached) return cached;
24
+ }
25
+ let sample = "";
26
+ let truncated = false;
27
+ let textParts = 0;
28
+ for (const item of result.content) {
29
+ if (item.type !== "text" || typeof item.text !== "string") continue;
30
+ if (textParts++ && sample.length < SUMMARY_SCAN_CHARS) sample += "\n";
31
+ const room = SUMMARY_SCAN_CHARS - sample.length;
32
+ if (room <= 0) { truncated = true; break; }
33
+ sample += item.text.slice(0, room);
34
+ if (item.text.length > room) { truncated = true; break; }
35
+ }
36
+ const value = { sample, lines: sample ? sample.split("\n").length : 0, truncated };
37
+ if (result && typeof result === "object") resultSummaryCache.set(result as object, value);
38
+ return value;
39
+ }
40
+
41
+ function firstNonEmpty(text: string): string {
42
+ for (const line of text.split("\n")) if (line.trim()) return line;
43
+ return "";
44
+ }
45
+
46
+ function boundedLineCount(text: string, limit = SUMMARY_SCAN_CHARS): { lines: number; truncated: boolean } {
47
+ const sample = text.slice(0, limit);
48
+ return { lines: sample ? sample.split("\n").length : 0, truncated: text.length > limit };
49
+ }
19
50
 
20
- const nonEmptyLines = (text: string): string[] => text.split("\n").filter((line) => line.trim().length > 0);
21
51
  const expandHint = " · [ expand ] Ctrl+O";
22
52
 
23
53
  function compactText(text: string, max = 72): string {
@@ -25,12 +55,18 @@ function compactText(text: string, max = 72): string {
25
55
  return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)) + "…";
26
56
  }
27
57
 
58
+ const bashCache = new WeakMap<object, { cwd: string; trusted: boolean; tool: ReturnType<typeof createBashToolDefinition> }>();
28
59
  function bashFor(ctx: ExtensionContext) {
29
- const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: ctx.isProjectTrusted() });
30
- return createBashToolDefinition(ctx.cwd, {
60
+ const trusted = ctx.isProjectTrusted();
61
+ const hit = bashCache.get(ctx as object);
62
+ if (hit && hit.cwd === ctx.cwd && hit.trusted === trusted) return hit.tool;
63
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: trusted });
64
+ const tool = createBashToolDefinition(ctx.cwd, {
31
65
  shellPath: settings.getShellPath(),
32
66
  commandPrefix: settings.getShellCommandPrefix()
33
67
  });
68
+ bashCache.set(ctx as object, { cwd: ctx.cwd, trusted, tool });
69
+ return tool;
34
70
  }
35
71
 
36
72
  /**
@@ -58,9 +94,9 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
58
94
  renderResult(result, options, theme, context) {
59
95
  if (options.expanded && read.renderResult) return read.renderResult(result, options, theme, context);
60
96
  if (options.isPartial) return new Text(theme.fg("warning", "reading…"), 0, 0);
61
- const text = outputText(result);
62
- const count = text ? text.split("\n").length : 0;
63
- return new Text(theme.fg("muted", `${count} line${count === 1 ? "" : "s"}${expandHint}`), 0, 0);
97
+ const summary = summarizeResult(result);
98
+ const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
99
+ return new Text(theme.fg("muted", `${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${expandHint}`), 0, 0);
64
100
  }
65
101
  });
66
102
 
@@ -75,12 +111,13 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
75
111
  },
76
112
  renderResult(result, options, theme, context) {
77
113
  if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
78
- const text = outputText(result);
79
- const lines = nonEmptyLines(text);
80
- const preview = lines[0] ? ` · ${compactText(lines[0], 56)}` : "";
114
+ const summary = summarizeResult(result);
115
+ const first = firstNonEmpty(summary.sample);
116
+ const preview = first ? ` · ${compactText(first, 56)}` : "";
117
+ const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
81
118
  const label = options.isPartial ? "running" : "done";
82
119
  return new Text(theme.fg(options.isPartial ? "warning" : "muted",
83
- `${label} · ${lines.length} line${lines.length === 1 ? "" : "s"}${preview}${expandHint}`), 0, 0);
120
+ `${label} · ${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${preview}${expandHint}`), 0, 0);
84
121
  }
85
122
  });
86
123
 
@@ -96,9 +133,11 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
96
133
  const details = result.details as { diff?: string } | undefined;
97
134
  const diff = details?.diff ?? "";
98
135
  if (!diff) {
99
- const text = outputText(result);
136
+ const summary = summarizeResult(result);
137
+ const text = summary.sample;
100
138
  return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
101
139
  }
140
+ if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
102
141
  let additions = 0;
103
142
  let removals = 0;
104
143
  for (const line of diff.split("\n")) {
@@ -114,14 +153,19 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
114
153
  ...write,
115
154
  renderCall(args, theme, context) {
116
155
  if (context.expanded && write.renderCall) return write.renderCall(args, theme, context);
117
- const count = args.content.split("\n").length;
156
+ let count = args && typeof args === "object" ? writeCountCache.get(args as object) : undefined;
157
+ if (!count) {
158
+ count = boundedLineCount(args.content);
159
+ if (args && typeof args === "object") writeCountCache.set(args as object, count);
160
+ }
118
161
  return new Text(theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", args.path)
119
- + theme.fg("dim", ` · ${count} lines`), 0, 0);
162
+ + theme.fg("dim", ` · ${count.lines}${count.truncated ? "+" : ""} lines`), 0, 0);
120
163
  },
121
164
  renderResult(result, options, theme, context) {
122
165
  if (options.expanded && write.renderResult) return write.renderResult(result, options, theme, context);
123
166
  if (options.isPartial) return new Text(theme.fg("warning", "writing…"), 0, 0);
124
- const text = outputText(result);
167
+ const summary = summarizeResult(result);
168
+ const text = summary.sample;
125
169
  return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "written", 96))
126
170
  + theme.fg("dim", text ? expandHint : ""), 0, 0);
127
171
  }