pi-jar 0.1.2 → 0.1.4
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 +46 -16
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +7 -3
- package/extensions/index.ts +194 -71
- package/package.json +1 -1
- package/src/activity-view.ts +525 -0
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +69 -21
- package/src/delegate.ts +492 -51
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +131 -26
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/icons.ts +57 -0
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +20 -10
- package/src/row-cache.ts +26 -0
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +112 -42
- package/src/settings-ui.ts +10 -11
- package/src/settings.ts +5 -1
- package/src/shells.ts +8 -84
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -0
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +47 -14
- package/src/working.ts +3 -1
|
@@ -0,0 +1,525 @@
|
|
|
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 { duration, leafTodos, type DelegateRegistry, type DelegateRun, type SubagentRecord, type TranscriptEntry } 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
|
+
import type { Todo } from "./tasks.ts";
|
|
10
|
+
|
|
11
|
+
/** What to select when the view opens. */
|
|
12
|
+
export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
|
|
13
|
+
/** A teammate another extension publishes through the role-status contract; listed read-only. */
|
|
14
|
+
export interface ActivityRole { id: string; name: string; state: string; task?: string }
|
|
15
|
+
export interface ActivitySources {
|
|
16
|
+
subagents: DelegateRegistry;
|
|
17
|
+
shells?: ShellManager;
|
|
18
|
+
roles?: () => ActivityRole[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Doubles as the icon key for the row glyph. */
|
|
22
|
+
type Status = "running" | "pending" | "success" | "error" | "stopped";
|
|
23
|
+
type Fg = (color: string, text: string) => string;
|
|
24
|
+
type Entry =
|
|
25
|
+
| { kind: "subagent"; id: string; status: Status; label: string; record: SubagentRecord }
|
|
26
|
+
| { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
|
|
27
|
+
| { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
|
|
28
|
+
type Item = Entry | { kind: "header"; label: string };
|
|
29
|
+
type Focus = "list" | "transcript" | "input";
|
|
30
|
+
|
|
31
|
+
const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
|
|
32
|
+
const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
|
|
33
|
+
const SHELL_TAIL = 400;
|
|
34
|
+
const MAX_TASK_CHARS = 8000;
|
|
35
|
+
/** Expanded tool output shows at most this many wrapped rows; the rest is summarized. */
|
|
36
|
+
const MAX_OUTPUT_ROWS = 80;
|
|
37
|
+
const MAX_ARGS_ROWS = 12;
|
|
38
|
+
const LIVE_ROWS = 4;
|
|
39
|
+
const MAX_DRAFT = 2000;
|
|
40
|
+
const SPINNER = ["◐", "◓", "◑", "◒"];
|
|
41
|
+
|
|
42
|
+
/** Left-pane rows: a header per non-empty section, then its entries. */
|
|
43
|
+
function collect(sources: ActivitySources): Item[] {
|
|
44
|
+
const items: Item[] = [];
|
|
45
|
+
const records = sources.subagents.records();
|
|
46
|
+
if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
|
|
47
|
+
for (const record of records) {
|
|
48
|
+
const { run } = record;
|
|
49
|
+
const status: Status = run.state === "working" ? "running" : run.state === "queued" ? "pending" : run.state === "done" ? "success" : run.error === "stopped" ? "stopped" : "error";
|
|
50
|
+
items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
|
|
51
|
+
}
|
|
52
|
+
const jobs = sources.shells?.summaries() ?? [];
|
|
53
|
+
if (jobs.length) items.push({ kind: "header", label: withIcon("shell", "SHELLS") });
|
|
54
|
+
for (const job of jobs) {
|
|
55
|
+
const status: Status = job.status === "running" ? "running" : job.status === "exited" && job.exitCode === 0 ? "success" : job.status === "killed" ? "stopped" : "error";
|
|
56
|
+
items.push({ kind: "shell", id: job.id, status, label: `${job.id} ${job.name}`, job });
|
|
57
|
+
}
|
|
58
|
+
let roles: ActivityRole[] = [];
|
|
59
|
+
try { roles = sources.roles?.() ?? []; } catch { /* teammates are decoration */ }
|
|
60
|
+
// jar_delegate publishes each run as a role status under its record key: list those once, as subagents.
|
|
61
|
+
roles = roles.filter((role) => !sources.subagents.get(role.id));
|
|
62
|
+
if (roles.length) items.push({ kind: "header", label: withIcon("roles", "ROLES") });
|
|
63
|
+
for (const role of roles) {
|
|
64
|
+
const status: Status = (ACTIVE_STATES as ReadonlySet<string>).has(role.state) ? "running" : role.state === "done" ? "success" : role.state === "failed" ? "error" : "pending";
|
|
65
|
+
items.push({ kind: "role", id: role.id, status, label: cleanText(role.name, 32) || role.id, role });
|
|
66
|
+
}
|
|
67
|
+
return items;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
|
|
71
|
+
interface Details {
|
|
72
|
+
head: string[];
|
|
73
|
+
body: readonly string[];
|
|
74
|
+
/** Styles one body line; applied to the visible window only, so a 400-line shell tail costs a screenful. */
|
|
75
|
+
format?: (line: string) => string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One collapsible piece of a subagent's details. `key` is stable across renders (the transcript entry
|
|
80
|
+
* itself, or a per-run object), so expansion state and cached rows survive live updates.
|
|
81
|
+
*/
|
|
82
|
+
interface Block {
|
|
83
|
+
key: object;
|
|
84
|
+
/** Changes whenever the block's content does. */
|
|
85
|
+
rev: string;
|
|
86
|
+
expandable: boolean;
|
|
87
|
+
/** Expanded unless the user toggled it. */
|
|
88
|
+
open: boolean;
|
|
89
|
+
/** Rows for the given state, without the two-column gutter. */
|
|
90
|
+
rows(expanded: boolean, width: number): string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const wrap = (text: string, width: number) => {
|
|
94
|
+
const rows: string[] = [];
|
|
95
|
+
for (const raw of text.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), Math.max(1, width))) rows.push(line);
|
|
96
|
+
return rows;
|
|
97
|
+
};
|
|
98
|
+
const firstLine = (text: string) => { const end = text.indexOf("\n"); return end < 0 ? text : text.slice(0, end); };
|
|
99
|
+
const lineCount = (text: string) => { let count = 1; for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) count++; return count; };
|
|
100
|
+
const clipped = (rows: string[], limit: number, fg: Fg) => rows.length <= limit ? rows : [...rows.slice(0, limit), fg("dim", `… ${rows.length - limit} more lines`)];
|
|
101
|
+
|
|
102
|
+
function todoRows(todos: readonly Todo[], width: number, fg: Fg): string[] {
|
|
103
|
+
const counts = new Map<string, { done: number; total: number }>();
|
|
104
|
+
for (const todo of todos) if (todo.parentId) {
|
|
105
|
+
const count = counts.get(todo.parentId) ?? { done: 0, total: 0 };
|
|
106
|
+
count.total++;
|
|
107
|
+
if (todo.done) count.done++;
|
|
108
|
+
counts.set(todo.parentId, count);
|
|
109
|
+
}
|
|
110
|
+
return todos.map((todo) => {
|
|
111
|
+
const indent = todo.parentId ? " " : " ";
|
|
112
|
+
const count = counts.get(todo.id);
|
|
113
|
+
const suffix = count ? fg("dim", ` (${count.done}/${count.total})`) : "";
|
|
114
|
+
const room = Math.max(1, width - indent.length - 2 - (count ? `${count.done}/${count.total}`.length + 3 : 0));
|
|
115
|
+
const title = truncateToWidth(safeLine(todo.title), room);
|
|
116
|
+
if (todo.status === "completed") return fg("success", `${indent}✔ `) + fg("dim", "\x1b[9m" + title + "\x1b[29m") + suffix;
|
|
117
|
+
if (todo.status === "in_progress") return fg("accent", `${indent}◼ ${title}`) + suffix;
|
|
118
|
+
return fg("muted", `${indent}☐ ${title}`) + suffix;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Per-run keys for the blocks that are not transcript entries. */
|
|
123
|
+
const runKeys = new WeakMap<DelegateRun, { task: object; todos: object; live: object; report: object }>();
|
|
124
|
+
const keysOf = (run: DelegateRun) => {
|
|
125
|
+
let keys = runKeys.get(run);
|
|
126
|
+
if (!keys) { keys = { task: {}, todos: {}, live: {}, report: {} }; runKeys.set(run, keys); }
|
|
127
|
+
return keys;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function entryBlock(entry: TranscriptEntry, fg: Fg): Block {
|
|
131
|
+
if (entry.kind === "tool") {
|
|
132
|
+
const glyph = entry.status === "running" ? fg("accent", "●") : entry.status === "error" ? fg("error", "✖") : fg("success", "✔");
|
|
133
|
+
const took = entry.endedAt !== undefined ? fg("dim", ` · ${duration(entry.endedAt - entry.startedAt)}`) : fg("dim", " · running");
|
|
134
|
+
return {
|
|
135
|
+
key: entry, rev: `${entry.rev}`, expandable: true, open: false,
|
|
136
|
+
rows(expanded, width) {
|
|
137
|
+
const head = fg("dim", expanded ? "▾ " : "▸ ") + glyph + " " + fg("accent", entry.name) + (entry.hint ? " " + fg("muted", safeLine(entry.hint)) : "") + took;
|
|
138
|
+
if (!expanded) return [truncateToWidth(head, width)];
|
|
139
|
+
const rows = [truncateToWidth(head, width)];
|
|
140
|
+
if (entry.args && entry.args !== JSON.stringify(entry.hint)) rows.push(...clipped(wrap(entry.args, width - 4).map((line) => fg("dim", " " + line)), MAX_ARGS_ROWS, fg));
|
|
141
|
+
if (entry.output) rows.push(...clipped(wrap(entry.output, width - 4).map((line) => fg(entry.status === "error" ? "error" : "muted", " " + line)), MAX_OUTPUT_ROWS, fg));
|
|
142
|
+
else rows.push(fg("dim", entry.status === "running" ? " (running…)" : " (no output)"));
|
|
143
|
+
return rows;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const multi = entry.text.length > 60 || entry.text.includes("\n");
|
|
148
|
+
const [mark, tone] = entry.kind === "steer" ? ["› you: ", "accent"] : entry.kind === "note" ? ["! ", "warning"] : ["◆ ", "text"];
|
|
149
|
+
return {
|
|
150
|
+
key: entry, rev: `${entry.rev}`, expandable: multi, open: false,
|
|
151
|
+
rows(expanded, width) {
|
|
152
|
+
const chevron = multi ? fg("dim", expanded ? "▾ " : "▸ ") : " ";
|
|
153
|
+
if (!expanded || !multi) {
|
|
154
|
+
const more = lineCount(entry.text) > 1 ? fg("dim", ` +${lineCount(entry.text) - 1} lines`) : "";
|
|
155
|
+
return [truncateToWidth(chevron + fg(tone, mark + safeLine(firstLine(entry.text))), Math.max(1, width - visibleWidth(more))) + more];
|
|
156
|
+
}
|
|
157
|
+
return [chevron + fg(tone, mark.trimEnd()), ...wrap(entry.text, width - 4).map((line) => fg(tone === "text" ? "text" : tone, " " + line))];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Everything under a subagent's pinned head: its task, checklist, transcript, live text and report. */
|
|
163
|
+
function subagentBlocks(run: DelegateRun, fg: Fg): Block[] {
|
|
164
|
+
const keys = keysOf(run);
|
|
165
|
+
const blocks: Block[] = [{
|
|
166
|
+
key: keys.task, rev: "task", expandable: true, open: false,
|
|
167
|
+
rows(expanded, width) {
|
|
168
|
+
if (!expanded) return [truncateToWidth(fg("dim", "▸ TASK ") + fg("text", safeLine(firstLine(run.task))), width)];
|
|
169
|
+
return [fg("dim", "▾ TASK"), ...wrap(run.task.slice(0, MAX_TASK_CHARS), width - 4).map((line) => fg("text", " " + line))];
|
|
170
|
+
}
|
|
171
|
+
}];
|
|
172
|
+
if (run.todos.length) {
|
|
173
|
+
const leaves = leafTodos(run.todos);
|
|
174
|
+
const done = leaves.filter((todo) => todo.done).length;
|
|
175
|
+
blocks.push({
|
|
176
|
+
key: keys.todos, rev: run.todos.map((todo) => todo.id + todo.status).join(), expandable: true, open: true,
|
|
177
|
+
rows(expanded, width) {
|
|
178
|
+
const head = fg("dim", (expanded ? "▾ " : "▸ ") + "TASKS ") + fg(done === leaves.length ? "success" : "accent", `${done}/${leaves.length} done`);
|
|
179
|
+
return expanded ? [head, ...todoRows(run.todos, width - 2, fg).map((row) => " " + row)] : [head];
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (!run.transcript.length && !run.live) blocks.push({ key: keys.live, rev: "empty", expandable: false, open: true, rows: () => [fg("dim", " (nothing yet)")] });
|
|
184
|
+
// Once finished, the last message is the report shown below: list it once.
|
|
185
|
+
const reported = !!run.output && run.state !== "working" && run.state !== "queued";
|
|
186
|
+
let last = -1;
|
|
187
|
+
if (reported) for (let index = run.transcript.length - 1; index >= 0 && last < 0; index--) if (run.transcript[index]!.kind === "text") last = index;
|
|
188
|
+
for (let index = 0; index < run.transcript.length; index++) if (index !== last) blocks.push(entryBlock(run.transcript[index]!, fg));
|
|
189
|
+
if (run.live) {
|
|
190
|
+
const live = run.live;
|
|
191
|
+
blocks.push({
|
|
192
|
+
// The text itself is the revision (it is capped, so its length stops changing); only a tail is wrapped.
|
|
193
|
+
key: keys.live, rev: live, expandable: false, open: true,
|
|
194
|
+
rows: (_expanded, width) => [fg("accent", " ✎ writing…"),
|
|
195
|
+
...wrap(live.slice(-Math.max(200, width * (LIVE_ROWS + 1))), width - 4).slice(-LIVE_ROWS).map((line) => fg("muted", " " + line))]
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (reported) {
|
|
199
|
+
blocks.push({
|
|
200
|
+
key: keys.report, rev: run.output, expandable: true, open: true,
|
|
201
|
+
rows(expanded, width) {
|
|
202
|
+
const head = fg("dim", (expanded ? "▾ " : "▸ ") + "REPORT");
|
|
203
|
+
return expanded ? [head, ...wrap(run.output, width - 4).map((line) => fg("text", " " + line))] : [head];
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return blocks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function subagentHead(run: DelegateRun, status: Status, width: number, fg: Fg): string[] {
|
|
211
|
+
const facts = [
|
|
212
|
+
run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
|
|
213
|
+
`${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`,
|
|
214
|
+
run.filesEdited.length ? `${run.filesEdited.length} edited` : "", run.steered ? `steered ${run.steered}×` : ""
|
|
215
|
+
].filter(Boolean);
|
|
216
|
+
const head = [
|
|
217
|
+
fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
|
|
218
|
+
fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
|
|
219
|
+
];
|
|
220
|
+
// A turning glyph on the 1 s tick: a long tool call or a slow model still looks alive.
|
|
221
|
+
if (run.activity) head.push(fg("muted", (run.state === "working" ? SPINNER[Math.floor(Date.now() / 1000) % SPINNER.length] + " " : "") + run.activity));
|
|
222
|
+
if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
|
|
223
|
+
return head;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
|
|
227
|
+
const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
|
|
228
|
+
if (!output.length) return { head, body: ["", fg("dim", "(no output yet)")] };
|
|
229
|
+
return { head, body: ["", ...output], format: (line) => line && fg("muted", truncateToWidth(safeLine(line), width)) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
|
|
233
|
+
const head = [fg("accent", cleanText(role.name, 60) || role.id), fg(COLOR[status], withIcon(status, cleanText(role.state, 24))) + fg("dim", " · read-only")];
|
|
234
|
+
const body = role.task ? ["", fg("dim", "TASK"), ...wrapTextWithAnsi(cleanText(role.task, 400), width).map((line) => fg("text", line))] : [];
|
|
235
|
+
return { head, body };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
|
|
240
|
+
* details on the right, following new output until scrolled up. For subagents the details are
|
|
241
|
+
* collapsible blocks (task, checklist, each tool call, text, report) and `s` steers the run.
|
|
242
|
+
*/
|
|
243
|
+
export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
|
|
244
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
245
|
+
const opening = collect(sources);
|
|
246
|
+
if (!opening.some((item) => item.kind !== "header")) {
|
|
247
|
+
ctx.ui.notify("pi-jar: nothing running (subagents come from jar_delegate, shells from jar_shell)", "info");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
251
|
+
let items = opening;
|
|
252
|
+
let at = initial ? items.findIndex((item) => item.kind !== "header" && item.kind === initial.kind && item.id === initial.id) : -1;
|
|
253
|
+
if (at < 0) at = items.findIndex((item) => item.kind !== "header" && item.status === "running");
|
|
254
|
+
if (at < 0) at = items.findIndex((item) => item.kind !== "header");
|
|
255
|
+
let follow = true;
|
|
256
|
+
let scroll = 0;
|
|
257
|
+
let maxScroll = 0;
|
|
258
|
+
let bodyRows = 0;
|
|
259
|
+
let listTop = 0;
|
|
260
|
+
let width = 80;
|
|
261
|
+
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
262
|
+
let pinnedRows = 0;
|
|
263
|
+
let timer: NodeJS.Timeout | undefined;
|
|
264
|
+
let closed = false;
|
|
265
|
+
let focus: Focus = "list";
|
|
266
|
+
let draft = "";
|
|
267
|
+
let inputRow = -1;
|
|
268
|
+
// Subagent body layout of the last render: which block starts at which body row.
|
|
269
|
+
let blocks: Block[] = [];
|
|
270
|
+
let offsets: number[] = [];
|
|
271
|
+
const cursors = new Map<string, object>();
|
|
272
|
+
const toggled = new WeakMap<object, boolean>();
|
|
273
|
+
const rowCache = new WeakMap<object, { sig: string; rows: string[] }>();
|
|
274
|
+
const fg: Fg = (color, text) => theme.fg(color as never, text);
|
|
275
|
+
const shells = sources.shells;
|
|
276
|
+
const previous = shells?.onChange;
|
|
277
|
+
const unsubscribe = sources.subagents.subscribe(() => tui.requestRender());
|
|
278
|
+
if (shells) shells.onChange = () => { previous?.(); tui.requestRender(); };
|
|
279
|
+
const cleanup = () => {
|
|
280
|
+
if (closed) return;
|
|
281
|
+
closed = true;
|
|
282
|
+
clearInterval(timer);
|
|
283
|
+
timer = undefined;
|
|
284
|
+
unsubscribe();
|
|
285
|
+
if (shells) shells.onChange = previous;
|
|
286
|
+
};
|
|
287
|
+
const close = () => { cleanup(); done(); };
|
|
288
|
+
const selected = (): Entry | undefined => { const item = items[at]; return item && item.kind !== "header" ? item : undefined; };
|
|
289
|
+
const steerable = () => { const item = selected(); return item?.kind === "subagent" && item.record.run.state === "working"; };
|
|
290
|
+
const isOpen = (block: Block) => block.expandable ? toggled.get(block.key) ?? block.open : true;
|
|
291
|
+
/** Rows of one block, rebuilt only when its content, state or width changed. */
|
|
292
|
+
const rowsOf = (block: Block, bodyWidth: number) => {
|
|
293
|
+
const expanded = isOpen(block);
|
|
294
|
+
const sig = `${block.rev}|${expanded}|${bodyWidth}`;
|
|
295
|
+
const cached = rowCache.get(block.key);
|
|
296
|
+
if (cached?.sig === sig) return cached.rows;
|
|
297
|
+
const rows = block.rows(expanded, bodyWidth);
|
|
298
|
+
rowCache.set(block.key, { sig, rows });
|
|
299
|
+
return rows;
|
|
300
|
+
};
|
|
301
|
+
// Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
|
|
302
|
+
const refresh = () => {
|
|
303
|
+
const current = items[at];
|
|
304
|
+
items = collect(sources);
|
|
305
|
+
const same = current && current.kind !== "header" ? items.findIndex((item) => item.kind !== "header" && item.kind === current.kind && item.id === current.id) : -1;
|
|
306
|
+
if (same >= 0) { at = same; return; }
|
|
307
|
+
const from = Math.max(0, Math.min(at, items.length - 1));
|
|
308
|
+
at = -1;
|
|
309
|
+
for (let index = from; index < items.length && at < 0; index++) if (items[index]!.kind !== "header") at = index;
|
|
310
|
+
for (let index = from - 1; index >= 0 && at < 0; index--) if (items[index]!.kind !== "header") at = index;
|
|
311
|
+
};
|
|
312
|
+
const move = (delta: number) => {
|
|
313
|
+
for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
|
|
314
|
+
if (items[index]!.kind === "header") continue;
|
|
315
|
+
at = index; follow = true; scroll = 0;
|
|
316
|
+
if (focus === "input") focus = "list";
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const scrollBy = (delta: number) => {
|
|
321
|
+
if (!maxScroll) return;
|
|
322
|
+
scroll = Math.max(0, Math.min(maxScroll, scroll + delta));
|
|
323
|
+
// Scrolling up pauses follow; scrolling back down to the end resumes it.
|
|
324
|
+
follow = delta > 0 && scroll >= maxScroll;
|
|
325
|
+
};
|
|
326
|
+
const cursorIndex = () => {
|
|
327
|
+
const item = selected();
|
|
328
|
+
const key = item?.kind === "subagent" ? cursors.get(item.id) : undefined;
|
|
329
|
+
const index = key ? blocks.findIndex((block) => block.key === key) : -1;
|
|
330
|
+
return index >= 0 ? index : blocks.length - 1;
|
|
331
|
+
};
|
|
332
|
+
const setCursor = (index: number) => {
|
|
333
|
+
const item = selected();
|
|
334
|
+
if (item?.kind !== "subagent" || !blocks.length) return;
|
|
335
|
+
const target = Math.max(0, Math.min(blocks.length - 1, index));
|
|
336
|
+
cursors.set(item.id, blocks[target]!.key);
|
|
337
|
+
follow = false;
|
|
338
|
+
// Keep the block's first row in view.
|
|
339
|
+
const row = offsets[target] ?? 0;
|
|
340
|
+
if (row < scroll) scroll = row;
|
|
341
|
+
else if (row >= scroll + bodyRows) scroll = Math.min(maxScroll, row - bodyRows + 1);
|
|
342
|
+
};
|
|
343
|
+
const toggle = (index: number) => {
|
|
344
|
+
const block = blocks[index];
|
|
345
|
+
if (!block?.expandable) return;
|
|
346
|
+
toggled.set(block.key, !isOpen(block));
|
|
347
|
+
setCursor(index);
|
|
348
|
+
};
|
|
349
|
+
const stopSelected = () => {
|
|
350
|
+
const item = selected();
|
|
351
|
+
if (!item || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
|
|
352
|
+
try {
|
|
353
|
+
if (item.kind === "subagent") sources.subagents.stop(item.id);
|
|
354
|
+
else shells?.kill(item.id);
|
|
355
|
+
} catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
356
|
+
};
|
|
357
|
+
const send = () => {
|
|
358
|
+
const item = selected();
|
|
359
|
+
const text = draft.trim();
|
|
360
|
+
if (!text || item?.kind !== "subagent") return;
|
|
361
|
+
if (sources.subagents.steer(item.id, text)) { draft = ""; focus = "transcript"; follow = true; cursors.delete(item.id); }
|
|
362
|
+
else ctx.ui.notify("pi-jar: that subagent is not taking messages (it is not working)", "warning");
|
|
363
|
+
};
|
|
364
|
+
const input = (data: string) => {
|
|
365
|
+
if (matchesKey(data, Key.escape)) { focus = "transcript"; return; }
|
|
366
|
+
if (matchesKey(data, Key.enter)) { send(); return; }
|
|
367
|
+
if (matchesKey(data, Key.backspace) || data === "\x7f" || data === "\b") { draft = draft.slice(0, -1); return; }
|
|
368
|
+
// Typed or pasted text: drop terminal sequences and control characters; line breaks become spaces.
|
|
369
|
+
const text = stripTerminalSequences(data).replace(/[\r\n\t]+/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
|
|
370
|
+
if (text) draft = (draft + text).slice(0, MAX_DRAFT);
|
|
371
|
+
};
|
|
372
|
+
const component = {
|
|
373
|
+
invalidate() {},
|
|
374
|
+
dispose: cleanup,
|
|
375
|
+
handleInput(data: string) {
|
|
376
|
+
if (focus === "input") { input(data); tui.requestRender(); return; }
|
|
377
|
+
const item = selected();
|
|
378
|
+
const subagent = item?.kind === "subagent";
|
|
379
|
+
if (focus === "transcript") {
|
|
380
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab) || matchesKey(data, Key.left) || data === "h") focus = "list";
|
|
381
|
+
else if (data === "q") return close();
|
|
382
|
+
else if (matchesKey(data, Key.up) || data === "k") setCursor(cursorIndex() - 1);
|
|
383
|
+
else if (matchesKey(data, Key.down) || data === "j") setCursor(cursorIndex() + 1);
|
|
384
|
+
else if (matchesKey(data, Key.enter) || data === " ") toggle(cursorIndex());
|
|
385
|
+
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
386
|
+
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
387
|
+
else if (data === "f" || data === "G") follow = true;
|
|
388
|
+
else if (data === "s" && steerable()) focus = "input";
|
|
389
|
+
else if (data === "x") stopSelected();
|
|
390
|
+
else return;
|
|
391
|
+
tui.requestRender();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
395
|
+
if (matchesKey(data, Key.up) || data === "k") move(-1);
|
|
396
|
+
else if (matchesKey(data, Key.down) || data === "j") move(1);
|
|
397
|
+
else if (subagent && (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.enter))) focus = "transcript";
|
|
398
|
+
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
399
|
+
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
400
|
+
else if (data === "f" || data === "G") follow = true;
|
|
401
|
+
else if (matchesKey(data, Key.enter)) follow = !follow;
|
|
402
|
+
else if (data === "s" && steerable()) focus = "input";
|
|
403
|
+
else if (data === "x") stopSelected();
|
|
404
|
+
else return;
|
|
405
|
+
tui.requestRender();
|
|
406
|
+
},
|
|
407
|
+
handleMouse(event: TuiMouseEvent) {
|
|
408
|
+
if (event.type === "wheel" && event.wheelDelta) { scrollBy(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
|
|
409
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
410
|
+
if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
|
|
411
|
+
const row = event.y - layout.top;
|
|
412
|
+
if (layout.leftWidth && row >= 0 && row < layout.rows && event.x < layout.leftWidth + 3) {
|
|
413
|
+
const index = listTop + row;
|
|
414
|
+
if (!items[index] || items[index].kind === "header") return;
|
|
415
|
+
if (index !== at) { at = index; follow = true; scroll = 0; }
|
|
416
|
+
focus = "list";
|
|
417
|
+
tui.requestRender();
|
|
418
|
+
return { handled: true, focus: true };
|
|
419
|
+
}
|
|
420
|
+
if (row >= pinnedRows && row < layout.rows && blocks.length && selected()?.kind === "subagent") {
|
|
421
|
+
// A click on a block's first row expands or collapses it; any other row just points the cursor at it.
|
|
422
|
+
const line = scroll + row - pinnedRows;
|
|
423
|
+
let index = -1;
|
|
424
|
+
for (let block = 0; block < offsets.length && offsets[block]! <= line; block++) index = block;
|
|
425
|
+
if (index < 0) return;
|
|
426
|
+
focus = "transcript";
|
|
427
|
+
if (offsets[index] === line) toggle(index); else setCursor(index);
|
|
428
|
+
tui.requestRender();
|
|
429
|
+
return { handled: true, focus: true };
|
|
430
|
+
}
|
|
431
|
+
if (inputRow >= 0 && event.y === inputRow && steerable()) { focus = "input"; tui.requestRender(); return { handled: true, focus: true }; }
|
|
432
|
+
const action = ACTIONS[event.y - layout.footerTop];
|
|
433
|
+
if (action) { const previousFocus = focus; focus = "list"; component.handleInput(action.key); focus = previousFocus; return { handled: true }; }
|
|
434
|
+
},
|
|
435
|
+
render(available: number): string[] {
|
|
436
|
+
width = Math.max(24, available);
|
|
437
|
+
refresh();
|
|
438
|
+
const item = selected();
|
|
439
|
+
const subagent = item?.kind === "subagent" ? item : undefined;
|
|
440
|
+
if (!subagent && focus !== "list") focus = "list";
|
|
441
|
+
if (focus === "input" && !steerable()) focus = "transcript";
|
|
442
|
+
const listWidth = sidebarWidth(width, 20, 34);
|
|
443
|
+
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
444
|
+
const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
|
|
445
|
+
// Footer: the two actions, the steering input for subagents, then key hints.
|
|
446
|
+
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",
|
|
447
|
+
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
448
|
+
const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
|
|
449
|
+
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
450
|
+
const footer = [...actions];
|
|
451
|
+
if (subagent) {
|
|
452
|
+
const room = Math.max(4, width - 16);
|
|
453
|
+
footer.push(focus === "input"
|
|
454
|
+
? fg("accent", "› steer: ") + fg("text", truncateToWidth(draft.length > room ? "…" + draft.slice(-room + 1) : draft, room)) + fg("accent", "█")
|
|
455
|
+
: fg("dim", steerable() ? "› s steer this subagent (type a message, Enter sends)" : "› steering works while a subagent is running"));
|
|
456
|
+
}
|
|
457
|
+
footer.push(fg("dim", focus === "input" ? `type a message · ${withIcon("enter", "send")} · ${withIcon("esc", "cancel")}`
|
|
458
|
+
: focus === "transcript" ? `↑↓ step · ${withIcon("enter", "expand")} · tab list · s steer · ${withIcon("esc", "back")}`
|
|
459
|
+
: `↑↓ select · ${subagent ? "tab transcript" : "PgUp/PgDn scroll"} · ${withIcon("enter", subagent ? "open" : "follow")} · ${withIcon("esc", "close")}`));
|
|
460
|
+
const rows = contentRows(3 + footer.length, 6);
|
|
461
|
+
const details: Details | undefined = !item || item.kind === "subagent" ? undefined
|
|
462
|
+
: item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
|
|
463
|
+
: roleDetails(item.role, item.status, bodyWidth, fg);
|
|
464
|
+
const head = !item ? [fg("dim", "Nothing to show.")] : item.kind === "subagent" ? subagentHead(item.record.run, item.status, bodyWidth, fg) : details!.head;
|
|
465
|
+
// Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
|
|
466
|
+
const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
|
|
467
|
+
...head].slice(0, rows - 1);
|
|
468
|
+
pinnedRows = pinned.length;
|
|
469
|
+
bodyRows = rows - pinned.length;
|
|
470
|
+
let body: string[];
|
|
471
|
+
if (subagent) {
|
|
472
|
+
blocks = subagentBlocks(subagent.record.run, fg);
|
|
473
|
+
const blockRows = blocks.map((block) => rowsOf(block, bodyWidth - 2));
|
|
474
|
+
offsets = [];
|
|
475
|
+
let total = 0;
|
|
476
|
+
for (const block of blockRows) { offsets.push(total); total += block.length; }
|
|
477
|
+
// A blank row separates the pinned head from the blocks.
|
|
478
|
+
offsets = offsets.map((offset) => offset + 1);
|
|
479
|
+
total += 1;
|
|
480
|
+
maxScroll = Math.max(0, total - bodyRows);
|
|
481
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
482
|
+
// Only the visible window gets its gutter: long transcripts cost nothing off-screen.
|
|
483
|
+
const cursor = focus === "transcript" ? cursorIndex() : -1;
|
|
484
|
+
body = scroll === 0 ? [""] : [];
|
|
485
|
+
for (let index = 0; index < blocks.length && body.length < bodyRows; index++) {
|
|
486
|
+
const start = offsets[index]!;
|
|
487
|
+
const rowsInBlock = blockRows[index]!;
|
|
488
|
+
if (start + rowsInBlock.length <= scroll) continue;
|
|
489
|
+
for (let line = Math.max(0, scroll - start); line < rowsInBlock.length && body.length < bodyRows; line++) {
|
|
490
|
+
body.push((line === 0 && index === cursor ? fg("accent", "❯ ") : " ") + rowsInBlock[line]!);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
blocks = [];
|
|
495
|
+
offsets = [];
|
|
496
|
+
const lines = details?.body ?? [];
|
|
497
|
+
maxScroll = Math.max(0, lines.length - bodyRows);
|
|
498
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
499
|
+
body = lines.slice(scroll, scroll + bodyRows);
|
|
500
|
+
if (details?.format) body = body.map(details.format);
|
|
501
|
+
}
|
|
502
|
+
if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
|
|
503
|
+
if (at >= listTop + rows) listTop = at - rows + 1;
|
|
504
|
+
listTop = Math.max(0, Math.min(listTop, items.length - rows));
|
|
505
|
+
const list = listWidth ? items.slice(listTop, listTop + rows).map((entry, offset) => {
|
|
506
|
+
if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
|
|
507
|
+
const active = listTop + offset === at;
|
|
508
|
+
const glyph = icon(entry.status);
|
|
509
|
+
return fg(active ? "accent" : "muted", active ? (focus === "list" ? "▌" : "▏") : " ") + fg(COLOR[entry.status], glyph + " ")
|
|
510
|
+
+ fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
|
|
511
|
+
}) : [];
|
|
512
|
+
const running = entries.filter((entry) => entry.status === "running").length;
|
|
513
|
+
const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list, [...pinned, ...body], footer, rows, listWidth);
|
|
514
|
+
layout = split.layout;
|
|
515
|
+
inputRow = subagent ? layout.footerTop + ACTIONS.length : -1;
|
|
516
|
+
// Subagent events and shell exits repaint through the listeners; elapsed times and shell output
|
|
517
|
+
// advance on a 1s tick that exists only while something runs.
|
|
518
|
+
if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
|
|
519
|
+
else if (!running && timer) { clearInterval(timer); timer = undefined; }
|
|
520
|
+
return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
return component;
|
|
524
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
525
|
+
}
|
package/src/advisor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
@@ -16,6 +17,8 @@ export const FAILURE_STREAK = 3;
|
|
|
16
17
|
/** Automatic consultations allowed per user prompt. */
|
|
17
18
|
export const GATES_PER_PROMPT = 2;
|
|
18
19
|
const WINDOW = 8;
|
|
20
|
+
/** Serialized inputs up to this length stay verbatim in a loop key; longer ones keep a prefix and a digest. */
|
|
21
|
+
const KEY_INPUT_CHARS = 1024;
|
|
19
22
|
|
|
20
23
|
export const ADVISOR_SYSTEM = [
|
|
21
24
|
"You are the advisor: a senior engineer giving a second opinion to a coding agent (the executor) mid-task.",
|
|
@@ -85,8 +88,11 @@ export function advisorPrompt(request: AdvisorRequest, conversation: string, git
|
|
|
85
88
|
].filter(Boolean).join("\n\n");
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
/** Stable key for loop detection. */
|
|
89
|
-
export
|
|
91
|
+
/** Stable key for loop detection. A `write` of a large file must not pin its whole content in the recent window. */
|
|
92
|
+
export function callKey(tool: string, input: unknown): string {
|
|
93
|
+
const json = String(JSON.stringify(input ?? {}));
|
|
94
|
+
return tool + " " + (json.length <= KEY_INPUT_CHARS ? json : json.slice(0, 200) + "…#" + createHash("sha1").update(json).digest("hex"));
|
|
95
|
+
}
|
|
90
96
|
|
|
91
97
|
/** Tracks repeated calls and failure streaks within one user prompt. */
|
|
92
98
|
export class StuckDetector {
|
|
@@ -181,7 +187,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
181
187
|
const focus = args.trim();
|
|
182
188
|
ctx.ui.notify("Consulting the advisor…", "info");
|
|
183
189
|
try {
|
|
184
|
-
const answer = await consult(ctx, focus ? { question: focus } : {});
|
|
190
|
+
const answer = await consult(ctx, focus ? { question: focus } : {}, ctx.signal);
|
|
185
191
|
deliver(ctx, `◆ Advisor · ${answer.model}${focus ? " · " + focus : ""}`, answer.text);
|
|
186
192
|
} catch (error) { ctx.ui.notify("Advisor failed: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
187
193
|
}
|
package/src/changes.ts
CHANGED
|
@@ -104,7 +104,8 @@ export class ChangeTracker {
|
|
|
104
104
|
private readonly cwd: () => string;
|
|
105
105
|
constructor(cwd: () => string) { this.cwd = cwd; }
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/** Resolve like every other tracker method; baselines are keyed by this absolute path. */
|
|
108
|
+
absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
|
|
108
109
|
private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
|
|
109
110
|
private drop(target: string): void {
|
|
110
111
|
if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
|
|
@@ -112,23 +113,46 @@ export class ChangeTracker {
|
|
|
112
113
|
this.dirty.delete(target);
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Store a baseline when the file is inside the project (plan files and scratch space elsewhere are
|
|
118
|
+
* not reviewed) and within the file/byte caps. `content` is lazy so outside files are never read.
|
|
119
|
+
*/
|
|
120
|
+
private admit(target: string, content: () => string | null | undefined): boolean {
|
|
120
121
|
const rel = relative(this.cwd(), target);
|
|
121
122
|
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
122
123
|
if (this.baselines.size >= MAX_TRACKED_FILES) return false;
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
125
|
-
const bytes = this.bytes(
|
|
126
|
-
if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
|
|
127
|
-
this.baselines.set(target,
|
|
124
|
+
const before = content();
|
|
125
|
+
if (before === undefined) return false;
|
|
126
|
+
const bytes = this.bytes(before);
|
|
127
|
+
if (bytes > MAX_TRACKED_BYTES || this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
|
|
128
|
+
this.baselines.set(target, before);
|
|
128
129
|
this.baselineBytes += bytes;
|
|
129
130
|
return true;
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** Record the pre-change content once; returns false when the file cannot be tracked. */
|
|
134
|
+
capture(path: string): boolean {
|
|
135
|
+
const target = this.absolute(path);
|
|
136
|
+
return this.baselines.has(target) || this.admit(target, () => readText(target));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Captured pre-change content (`null` = file did not exist), or `undefined` when untracked. */
|
|
140
|
+
baseline(path: string): string | null | undefined {
|
|
141
|
+
const target = this.absolute(path);
|
|
142
|
+
return this.baselines.has(target) ? this.baselines.get(target)! : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Take over a baseline captured elsewhere (a subagent's first-edit content) and mark the file
|
|
147
|
+
* dirty. An existing baseline is older and wins, so each absolute path counts once.
|
|
148
|
+
*/
|
|
149
|
+
adopt(path: string, baseline: string | null): boolean {
|
|
150
|
+
const target = this.absolute(path);
|
|
151
|
+
if (!this.baselines.has(target) && !this.admit(target, () => baseline)) return false;
|
|
152
|
+
this.dirty.add(target);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
/**
|
|
133
157
|
* Mark one captured file after a successful edit/write. Do not synchronously re-read the file on
|
|
134
158
|
* every tool result; the review UI reconciles content lazily when /diff is actually opened.
|