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.
- package/README.md +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +212 -89
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/advisor.ts +30 -6
- package/src/changes.ts +12 -20
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +36 -14
- package/src/delegate.ts +187 -23
- package/src/diff-view.ts +2 -2
- package/src/footer.ts +105 -18
- package/src/goal-loop.ts +34 -9
- package/src/goals.ts +13 -2
- package/src/history-ui.ts +2 -1
- package/src/icons.ts +57 -0
- package/src/info-panels.ts +19 -7
- package/src/plan.ts +37 -10
- package/src/prompt-search.ts +27 -8
- package/src/quota.ts +14 -4
- package/src/session-branch.ts +24 -0
- package/src/session-gallery.ts +10 -5
- package/src/session-ui.ts +119 -45
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +77 -99
- package/src/side-model.ts +14 -2
- package/src/status.ts +36 -24
- package/src/suggest.ts +6 -25
- package/src/task-tool.ts +35 -11
- package/src/tasks.ts +24 -7
- package/src/usage.ts +2 -1
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
|
@@ -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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
136
|
-
*
|
|
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);
|
|
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);
|
|
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
|
-
|
|
166
|
-
|
|
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.
|
|
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);
|
package/src/compact-tools.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
30
|
-
|
|
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
|
|
62
|
-
const count =
|
|
63
|
-
return new Text(theme.fg("muted", `${count} line${
|
|
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
|
|
79
|
-
const
|
|
80
|
-
const preview =
|
|
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} · ${
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|