pi-jar 0.1.0
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/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
package/src/delegate.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import type { ModelRoleManager } from "./model-roles.ts";
|
|
8
|
+
import { ROLE_PREFIX } from "./status.ts";
|
|
9
|
+
import { cleanText } from "./status.ts";
|
|
10
|
+
|
|
11
|
+
export const DELEGATE_TOOL = "jar_delegate";
|
|
12
|
+
/** Set in child processes so a subagent never delegates again. */
|
|
13
|
+
export const CHILD_ENV = "PI_JAR_CHILD";
|
|
14
|
+
export const MAX_DELEGATES = 4;
|
|
15
|
+
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
16
|
+
const MAX_OUTPUT_CHARS = 12_000;
|
|
17
|
+
const TIMEOUT_MS = 20 * 60_000;
|
|
18
|
+
const STATUS_REFRESH_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
export type DelegateState = "queued" | "working" | "done" | "failed";
|
|
21
|
+
export interface DelegateRun {
|
|
22
|
+
index: number;
|
|
23
|
+
name: string;
|
|
24
|
+
task: string;
|
|
25
|
+
role: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
state: DelegateState;
|
|
28
|
+
activity?: string;
|
|
29
|
+
tools: number;
|
|
30
|
+
turns: number;
|
|
31
|
+
cost: number;
|
|
32
|
+
output: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
|
|
37
|
+
|
|
38
|
+
/** How to run Pi again: the current script under the current runtime, or `pi` on PATH. */
|
|
39
|
+
export function piInvocation(args: string[], argv = process.argv, execPath = process.execPath): { command: string; args: string[] } {
|
|
40
|
+
const script = argv[1];
|
|
41
|
+
if (script && !script.startsWith("/$bunfs/") && existsSync(script)) return { command: execPath, args: [script, ...args] };
|
|
42
|
+
if (!/^(node|bun)(\.exe)?$/i.test(basename(execPath))) return { command: execPath, args };
|
|
43
|
+
return { command: "pi", args };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** CLI arguments for one subagent: JSON events, one-shot, no session, read-only tools unless `write`. */
|
|
47
|
+
export function delegateArgs(task: string, model: string | undefined, thinking: string | undefined, write: boolean): string[] {
|
|
48
|
+
const args = ["--mode", "json", "-p", "--no-session"];
|
|
49
|
+
if (model) args.push("--model", model);
|
|
50
|
+
if (thinking) args.push("--thinking", thinking);
|
|
51
|
+
if (!write) args.push("--tools", READ_ONLY_TOOLS.join(","));
|
|
52
|
+
const rules = write
|
|
53
|
+
? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
|
|
54
|
+
: "You are read-only: investigate and report, do not attempt to modify anything.";
|
|
55
|
+
args.push(`You are a focused subagent working for another agent. ${rules} Finish with a concise, self-contained report of your findings or changes (include file paths).\n\nTask: ${task}`);
|
|
56
|
+
return args;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const textOf = (message: { content?: unknown }) => Array.isArray(message.content)
|
|
60
|
+
? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n")
|
|
61
|
+
: typeof message.content === "string" ? message.content : "";
|
|
62
|
+
|
|
63
|
+
/** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
|
|
64
|
+
export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const invocation = piInvocation(args);
|
|
67
|
+
let child: ChildProcess;
|
|
68
|
+
try {
|
|
69
|
+
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, [CHILD_ENV]: "1" } });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); update(); resolve(); return;
|
|
72
|
+
}
|
|
73
|
+
run.state = "working";
|
|
74
|
+
run.activity = "starting";
|
|
75
|
+
update();
|
|
76
|
+
let buffer = "";
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let finished = false;
|
|
79
|
+
const finish = (state: DelegateState, error?: string) => {
|
|
80
|
+
if (finished) return;
|
|
81
|
+
finished = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
signal?.removeEventListener("abort", abort);
|
|
84
|
+
run.state = state;
|
|
85
|
+
if (error) run.error = error;
|
|
86
|
+
run.activity = undefined;
|
|
87
|
+
update();
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
|
|
91
|
+
const abort = () => { stop(); finish("failed", "aborted"); };
|
|
92
|
+
const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
if (signal?.aborted) { abort(); return; }
|
|
95
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
96
|
+
const line = (raw: string) => {
|
|
97
|
+
if (!raw.trim()) return;
|
|
98
|
+
let event: any;
|
|
99
|
+
try { event = JSON.parse(raw); } catch { return; }
|
|
100
|
+
if (event.type === "tool_execution_start") { run.tools++; run.activity = cleanText(String(event.toolName ?? "tool"), 24); update(); }
|
|
101
|
+
else if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
102
|
+
run.turns++;
|
|
103
|
+
run.cost += Number(event.message.usage?.cost?.total) || 0;
|
|
104
|
+
const text = textOf(event.message).trim();
|
|
105
|
+
if (text) run.output = text.slice(0, MAX_OUTPUT_CHARS);
|
|
106
|
+
if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
|
|
107
|
+
run.activity = "thinking";
|
|
108
|
+
update();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
child.stdout?.on("data", (chunk) => {
|
|
112
|
+
buffer += String(chunk);
|
|
113
|
+
const lines = buffer.split("\n");
|
|
114
|
+
buffer = lines.pop() ?? "";
|
|
115
|
+
for (const item of lines) line(item);
|
|
116
|
+
});
|
|
117
|
+
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
|
|
118
|
+
child.on("error", (error) => finish("failed", error.message));
|
|
119
|
+
child.on("close", (code) => {
|
|
120
|
+
if (buffer) line(buffer);
|
|
121
|
+
if (code === 0 && !run.error) finish("done");
|
|
122
|
+
else finish("failed", run.error ?? (cleanText(stderr, 300) || `exited ${code}`));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const Parameters = Type.Object({
|
|
128
|
+
tasks: Type.Array(Type.Object({
|
|
129
|
+
task: Type.String({ description: "A complete, self-contained instruction: the subagent sees nothing else." }),
|
|
130
|
+
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
131
|
+
role: Type.Optional(Type.String({ description: "pi-jar role for the model (default \"task\", falls back to the current model)." }))
|
|
132
|
+
}), { minItems: 1, maxItems: MAX_DELEGATES }),
|
|
133
|
+
write: Type.Optional(Type.Boolean({ description: "Allow file edits (default false: read-only tools)." }))
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
|
|
137
|
+
const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
|
|
138
|
+
|
|
139
|
+
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spawnProcess?: Spawn): void {
|
|
140
|
+
if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
141
|
+
let batch = 0;
|
|
142
|
+
pi.registerTool({
|
|
143
|
+
name: DELEGATE_TOOL,
|
|
144
|
+
label: "delegate",
|
|
145
|
+
description: `Run up to ${MAX_DELEGATES} subagents in parallel, each an isolated Pi with a fresh context, on a pi-jar model role (default "task"). Read-only unless write is true. Returns each subagent's report.`,
|
|
146
|
+
promptSnippet: "Use jar_delegate to fan out independent investigations to parallel subagents.",
|
|
147
|
+
promptGuidelines: [
|
|
148
|
+
"Use jar_delegate for independent, parallelizable investigation (scouting several areas, reviewing, researching); give each task complete context because subagents see nothing else.",
|
|
149
|
+
"Keep delegated work read-only by default. Only set write for clearly separated edits that cannot conflict; never have two subagents edit the same files."
|
|
150
|
+
],
|
|
151
|
+
parameters: Parameters,
|
|
152
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
153
|
+
const id = ++batch;
|
|
154
|
+
const write = params.write === true;
|
|
155
|
+
const runs: DelegateRun[] = params.tasks.slice(0, MAX_DELEGATES).map((item, index) => {
|
|
156
|
+
const role = cleanText(item.role ?? "task", 32) || "task";
|
|
157
|
+
const resolved = roles.resolve(role) ?? roles.resolve("default");
|
|
158
|
+
const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
159
|
+
return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
|
|
160
|
+
state: "queued", tools: 0, turns: 0, cost: 0, output: "" };
|
|
161
|
+
});
|
|
162
|
+
const thinking = (role: string) => (roles.resolve(role) ?? roles.resolve("default"))?.thinking;
|
|
163
|
+
// Live teammates: the welcome TEAM row and the footer read this public status contract.
|
|
164
|
+
const publish = () => {
|
|
165
|
+
if (!ctx.hasUI) return;
|
|
166
|
+
for (const run of runs) {
|
|
167
|
+
try {
|
|
168
|
+
ctx.ui.setStatus(`${ROLE_PREFIX}delegate-${id}-${run.index}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
|
|
169
|
+
name: run.name, label: run.name.slice(0, 12), state: run.state === "queued" ? "waiting" : "working", task: cleanText(run.task, 60), expiresAt: Date.now() + 25_000
|
|
170
|
+
}));
|
|
171
|
+
} catch { /* status is decoration */ }
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const details = () => ({ runs: runs.map((run) => ({ ...run })), write });
|
|
175
|
+
const update = () => {
|
|
176
|
+
publish();
|
|
177
|
+
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details() });
|
|
178
|
+
};
|
|
179
|
+
const refresh = setInterval(publish, STATUS_REFRESH_MS);
|
|
180
|
+
refresh.unref?.();
|
|
181
|
+
try {
|
|
182
|
+
update();
|
|
183
|
+
await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
|
|
184
|
+
} finally {
|
|
185
|
+
clearInterval(refresh);
|
|
186
|
+
for (const run of runs) if (run.state !== "done") run.state = "failed";
|
|
187
|
+
publish();
|
|
188
|
+
}
|
|
189
|
+
const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
|
|
190
|
+
return { content: [{ type: "text", text: report }], details: details() };
|
|
191
|
+
},
|
|
192
|
+
renderCall(args, theme) {
|
|
193
|
+
const count = Array.isArray(args.tasks) ? args.tasks.length : 0;
|
|
194
|
+
return new Text(theme.fg("toolTitle", theme.bold("delegate")) + " " + theme.fg("accent", `${count} subagent${count === 1 ? "" : "s"}`)
|
|
195
|
+
+ theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
|
|
196
|
+
},
|
|
197
|
+
renderResult(result, { expanded }, theme) {
|
|
198
|
+
const runs = (result.details as { runs?: DelegateRun[] } | undefined)?.runs ?? [];
|
|
199
|
+
const rows = runs.map((run) => {
|
|
200
|
+
const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
|
|
201
|
+
let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
|
|
202
|
+
if (expanded && run.output) row += "\n" + run.output.split("\n").map((line) => theme.fg("muted", " " + line)).join("\n");
|
|
203
|
+
return row;
|
|
204
|
+
});
|
|
205
|
+
return new Text(rows.join("\n") || theme.fg("dim", "No subagents."), 0, 0);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
package/src/dialogs.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { todoMark, type Todo } from "./tasks.ts";
|
|
4
|
+
import { cleanText } from "./status.ts";
|
|
5
|
+
|
|
6
|
+
const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
|
|
7
|
+
|
|
8
|
+
/** Pi-jar owns these dialogs; Pi's normal and other extensions' questions are untouched. */
|
|
9
|
+
export async function promptText(ctx: ExtensionContext, title: string, question: string, initial = ""): Promise<string | undefined> {
|
|
10
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
11
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
12
|
+
const input = new Input({ prompt: "❯ ", placeholder: "Type your answer" });
|
|
13
|
+
input.focused = true;
|
|
14
|
+
input.setValue(initial);
|
|
15
|
+
input.onSubmit = (value) => done(value.trim() || undefined);
|
|
16
|
+
input.onEscape = () => done(undefined);
|
|
17
|
+
let footerRow = 0;
|
|
18
|
+
return {
|
|
19
|
+
invalidate() { input.invalidate(); },
|
|
20
|
+
handleInput(data: string) { input.handleInput(data); tui.requestRender(); },
|
|
21
|
+
handleMouse(event: TuiMouseEvent) {
|
|
22
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
23
|
+
if (event.y === footerRow) { done(input.getValue().trim() || undefined); return { handled: true }; }
|
|
24
|
+
},
|
|
25
|
+
render(width: number): string[] {
|
|
26
|
+
const lines = [
|
|
27
|
+
fit(theme.fg("accent", `╭─ ${cleanText(title, 56)} ─`), width),
|
|
28
|
+
...wrapTextWithAnsi(cleanText(question, 160), Math.max(1, width - 2)).map((line) => fit(theme.fg("muted", `│ ${line}`), width)),
|
|
29
|
+
...input.render(Math.max(1, width - 2)).map((line) => fit("│ " + line, width)),
|
|
30
|
+
fit(theme.fg("dim", "╰─ Enter: confirm · Esc: cancel"), width)
|
|
31
|
+
];
|
|
32
|
+
footerRow = lines.length - 1;
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function promptChoice(ctx: ExtensionContext, title: string, question: string, choices: readonly string[], initial = 0): Promise<number | undefined> {
|
|
40
|
+
if (!ctx.hasUI || ctx.mode !== "tui" || !choices.length) return undefined;
|
|
41
|
+
return ctx.ui.custom<number | undefined>((tui, theme, _keys, done) => {
|
|
42
|
+
let selected = Math.max(0, Math.min(choices.length - 1, initial));
|
|
43
|
+
let firstChoiceRow = 0;
|
|
44
|
+
return {
|
|
45
|
+
invalidate() {},
|
|
46
|
+
handleInput(data: string) {
|
|
47
|
+
if (matchesKey(data, Key.escape)) return done(undefined);
|
|
48
|
+
if (matchesKey(data, Key.enter)) return done(selected);
|
|
49
|
+
if (matchesKey(data, Key.up)) selected = (selected + choices.length - 1) % choices.length;
|
|
50
|
+
if (matchesKey(data, Key.down)) selected = (selected + 1) % choices.length;
|
|
51
|
+
tui.requestRender();
|
|
52
|
+
},
|
|
53
|
+
handleMouse(event: TuiMouseEvent) {
|
|
54
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
55
|
+
const index = event.y - firstChoiceRow;
|
|
56
|
+
if (index >= 0 && index < choices.length) { selected = index; done(index); return { handled: true }; }
|
|
57
|
+
},
|
|
58
|
+
render(width: number): string[] {
|
|
59
|
+
const heading = [
|
|
60
|
+
fit(theme.fg("accent", `╭─ ${cleanText(title, 56)} ─`), width),
|
|
61
|
+
...wrapTextWithAnsi(cleanText(question, 160), Math.max(1, width - 2)).map((line) => fit(theme.fg("muted", `│ ${line}`), width))
|
|
62
|
+
];
|
|
63
|
+
firstChoiceRow = heading.length;
|
|
64
|
+
return [
|
|
65
|
+
...heading,
|
|
66
|
+
...choices.map((choice, index) => fit(theme.fg(index === selected ? "accent" : "dim", `${index === selected ? "❯" : " "} ${cleanText(choice, 80)}`), width)),
|
|
67
|
+
fit(theme.fg("dim", "╰─ ↑↓: choose · Enter: confirm · Esc: cancel"), width)
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type TodoFilter = "all" | "open" | "done";
|
|
75
|
+
export type TodoAction = { kind: "add" | "toggle" | "edit" | "delete"; id?: string };
|
|
76
|
+
|
|
77
|
+
export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filter: { value: TodoFilter }): Promise<TodoAction | undefined> {
|
|
78
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
79
|
+
return ctx.ui.custom<TodoAction | undefined>((tui, theme, _keys, done) => {
|
|
80
|
+
let selected = 0;
|
|
81
|
+
const filtered = () => todos().filter((item) => filter.value === "all" || (filter.value === "done") === item.done);
|
|
82
|
+
let firstVisible = 0;
|
|
83
|
+
return {
|
|
84
|
+
invalidate() {},
|
|
85
|
+
handleInput(data: string) {
|
|
86
|
+
const items = filtered();
|
|
87
|
+
if (matchesKey(data, Key.escape) || data === "q") return done(undefined);
|
|
88
|
+
if (matchesKey(data, Key.up)) selected = Math.max(0, selected - 1);
|
|
89
|
+
else if (matchesKey(data, Key.down)) selected = Math.min(Math.max(0, items.length - 1), selected + 1);
|
|
90
|
+
else if (data === "f") {
|
|
91
|
+
const next: Record<TodoFilter, TodoFilter> = { all: "open", open: "done", done: "all" };
|
|
92
|
+
filter.value = next[filter.value]; selected = 0;
|
|
93
|
+
} else if (data === "a") return done({ kind: "add" });
|
|
94
|
+
else if (items[selected] && (data === " " || matchesKey(data, Key.enter))) return done({ kind: "toggle", id: items[selected].id });
|
|
95
|
+
else if (items[selected] && data === "e") return done({ kind: "edit", id: items[selected].id });
|
|
96
|
+
else if (items[selected] && data === "d") return done({ kind: "delete", id: items[selected].id });
|
|
97
|
+
tui.requestRender();
|
|
98
|
+
},
|
|
99
|
+
handleMouse(event: TuiMouseEvent) {
|
|
100
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
101
|
+
selected = Math.max(0, Math.min(filtered().length - 1, selected + Math.sign(event.wheelDelta)));
|
|
102
|
+
tui.requestRender(); return { handled: true };
|
|
103
|
+
}
|
|
104
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
105
|
+
const index = firstVisible + event.y - 1;
|
|
106
|
+
const item = filtered()[index];
|
|
107
|
+
if (event.y >= 1 && event.y <= 12 && item) {
|
|
108
|
+
selected = index; done({ kind: "toggle", id: item.id }); return { handled: true };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
render(width: number): string[] {
|
|
112
|
+
const items = filtered();
|
|
113
|
+
selected = Math.min(selected, Math.max(0, items.length - 1));
|
|
114
|
+
const count = todos().filter((item) => !item.done).length;
|
|
115
|
+
const start = Math.max(0, selected - 11);
|
|
116
|
+
firstVisible = start;
|
|
117
|
+
return [
|
|
118
|
+
fit(theme.fg("accent", `╭─ pi-jar to-do · ${count} open · ${filter.value} ─`), width),
|
|
119
|
+
...(items.length ? items.slice(start, start + 12).map((item, index) =>
|
|
120
|
+
fit(theme.fg(start + index === selected ? "accent" : item.done ? "dim" : item.status === "in_progress" ? "warning" : "muted", `${start + index === selected ? "❯" : " "} ${todoMark(item)} ${item.title}`), width))
|
|
121
|
+
: [fit(theme.fg("dim", " No items in this view"), width)]),
|
|
122
|
+
fit(theme.fg("dim", "╰─ ↑↓: select · Space: check · a: add · e: edit · d: delete · f: filter · Esc: close"), width)
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
package/src/diff-view.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { diffRows, lineDiff, type ChangeTracker, type DiffRow, type FileChange } from "./changes.ts";
|
|
4
|
+
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
5
|
+
|
|
6
|
+
const ACTIONS = [
|
|
7
|
+
{ key: "a", label: "Accept this file (keep it, stop tracking)" },
|
|
8
|
+
{ key: "r", label: "Revert this file (asks to confirm)" },
|
|
9
|
+
{ key: "A", label: "Accept all files" },
|
|
10
|
+
{ key: "R", label: "Revert all files (asks to confirm)" }
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
const STATUS_MARK = { added: "A", modified: "M", deleted: "D" } as const;
|
|
14
|
+
|
|
15
|
+
/** Keep indentation but drop escape sequences and control characters that would break layout. */
|
|
16
|
+
export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, " ")
|
|
17
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, "·");
|
|
18
|
+
|
|
19
|
+
/** Styled diff rows for one file, with a gutter of old/new line numbers. */
|
|
20
|
+
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string): string[] {
|
|
21
|
+
const ops = lineDiff(change.before, change.after);
|
|
22
|
+
if (!ops) return [fg("warning", `Too large for an inline diff: +${change.added} −${change.removed} lines.`)];
|
|
23
|
+
const rows: DiffRow[] = diffRows(ops);
|
|
24
|
+
if (!rows.length) return [fg("dim", "No textual changes.")];
|
|
25
|
+
const gutter = Math.max(3, String(Math.max(...rows.map((row) => Math.max(row.oldLine ?? 0, row.newLine ?? 0)))).length);
|
|
26
|
+
const num = (value?: number) => (value ? String(value) : "").padStart(gutter);
|
|
27
|
+
return rows.map((row) => {
|
|
28
|
+
if (row.kind === "hunk") return fg("accent", row.text);
|
|
29
|
+
const text = truncateToWidth(safeLine(row.text), Math.max(1, width - gutter * 2 - 4));
|
|
30
|
+
const prefix = fg("dim", `${num(row.oldLine)} ${num(row.newLine)} `);
|
|
31
|
+
if (row.kind === "add") return prefix + fg("success", "+" + text);
|
|
32
|
+
if (row.kind === "remove") return prefix + fg("error", "-" + text);
|
|
33
|
+
return prefix + fg("muted", " " + text);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Review what the agent changed: files on the left, the selected diff on the right.
|
|
39
|
+
* `a` accepts (stops tracking) and `r` then `y` reverts a file; `A`/`R` apply to all.
|
|
40
|
+
*/
|
|
41
|
+
export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker): Promise<void> {
|
|
42
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
43
|
+
let changes = tracker.changes();
|
|
44
|
+
if (!changes.length) { ctx.ui.notify("pi-jar: no agent changes to review", "info"); return; }
|
|
45
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
46
|
+
let selected = 0;
|
|
47
|
+
let scroll = 0;
|
|
48
|
+
let listScroll = 0;
|
|
49
|
+
let bodyRows = 0;
|
|
50
|
+
let width = 80;
|
|
51
|
+
let armed: "one" | "all" | undefined;
|
|
52
|
+
let message = "";
|
|
53
|
+
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
54
|
+
let cache: { key: string; lines: string[] } | undefined;
|
|
55
|
+
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
56
|
+
const refresh = () => {
|
|
57
|
+
changes = tracker.changes();
|
|
58
|
+
cache = undefined;
|
|
59
|
+
selected = Math.min(selected, Math.max(0, changes.length - 1));
|
|
60
|
+
scroll = 0;
|
|
61
|
+
if (!changes.length) done();
|
|
62
|
+
};
|
|
63
|
+
const run = (label: string, action: () => void) => {
|
|
64
|
+
try { action(); message = label; }
|
|
65
|
+
catch (error) { message = "failed: " + (error instanceof Error ? error.message : String(error)); ctx.ui.notify("pi-jar: " + message, "error"); }
|
|
66
|
+
refresh();
|
|
67
|
+
};
|
|
68
|
+
const select = (index: number) => { selected = Math.max(0, Math.min(changes.length - 1, index)); scroll = 0; };
|
|
69
|
+
const scrollBody = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, (cache?.lines.length ?? 0) - bodyRows), scroll + delta)); };
|
|
70
|
+
const component = {
|
|
71
|
+
invalidate() { cache = undefined; },
|
|
72
|
+
handleInput(data: string) {
|
|
73
|
+
const current = changes[selected];
|
|
74
|
+
if (armed && (data === "y" || (armed === "one" && data === "r") || (armed === "all" && data === "R"))) {
|
|
75
|
+
const scope = armed; armed = undefined;
|
|
76
|
+
if (scope === "all") run(`reverted ${changes.length} file(s)`, () => { for (const change of changes) tracker.revert(change.path); });
|
|
77
|
+
else if (current) run(`reverted ${current.rel}`, () => tracker.revert(current.path));
|
|
78
|
+
tui.requestRender(); return;
|
|
79
|
+
}
|
|
80
|
+
armed = undefined;
|
|
81
|
+
if (matchesKey(data, Key.escape) || data === "q") return done();
|
|
82
|
+
if (data === "a" && current) run(`accepted ${current.rel}`, () => tracker.accept(current.path));
|
|
83
|
+
else if (data === "A") run(`accepted ${changes.length} file(s)`, () => tracker.acceptAll());
|
|
84
|
+
else if (data === "r" && current) { armed = "one"; message = `revert ${current.rel}? press r or y to confirm`; }
|
|
85
|
+
else if (data === "R") { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
|
|
86
|
+
else if (matchesKey(data, Key.up) || data === "k") select(selected - 1);
|
|
87
|
+
else if (matchesKey(data, Key.down) || data === "j") select(selected + 1);
|
|
88
|
+
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(Math.max(1, bodyRows - 2));
|
|
89
|
+
else if (matchesKey(data, Key.pageUp)) scrollBody(-Math.max(1, bodyRows - 2));
|
|
90
|
+
else if (data === "g") scrollBody(-Infinity);
|
|
91
|
+
else if (data === "G") scrollBody(Infinity);
|
|
92
|
+
tui.requestRender();
|
|
93
|
+
},
|
|
94
|
+
handleMouse(event: TuiMouseEvent) {
|
|
95
|
+
const row = event.y - layout.top;
|
|
96
|
+
const inContent = row >= 0 && row < layout.rows;
|
|
97
|
+
const inList = layout.leftWidth > 0 && event.x < layout.leftWidth + 3;
|
|
98
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
99
|
+
if (inContent && inList) select(selected + Math.sign(event.wheelDelta)); else scrollBody(Math.sign(event.wheelDelta) * 3);
|
|
100
|
+
tui.requestRender(); return { handled: true };
|
|
101
|
+
}
|
|
102
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
103
|
+
if (event.y === 0 && event.x >= width - 3) { done(); return { handled: true }; }
|
|
104
|
+
if (inContent && inList && listScroll + row < changes.length) { select(listScroll + row); tui.requestRender(); return { handled: true, focus: true }; }
|
|
105
|
+
const action = ACTIONS[event.y - layout.footerTop];
|
|
106
|
+
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
107
|
+
},
|
|
108
|
+
render(available: number): string[] {
|
|
109
|
+
width = Math.max(24, available);
|
|
110
|
+
bodyRows = contentRows(6 + ACTIONS.length, 6);
|
|
111
|
+
const listWidth = sidebarWidth(width, 20, 36);
|
|
112
|
+
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
113
|
+
const current = changes[selected];
|
|
114
|
+
if (!current) return [];
|
|
115
|
+
const key = `${selected}:${bodyWidth}:${current.after.length}`;
|
|
116
|
+
if (cache?.key !== key) cache = { key, lines: renderDiff(current, bodyWidth, fg) };
|
|
117
|
+
const header = listWidth ? [] : [fg("accent", `‹ ${selected + 1}/${changes.length} ${current.rel} ›`), ""];
|
|
118
|
+
const content = [...header, ...cache.lines];
|
|
119
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - bodyRows)));
|
|
120
|
+
if (selected < listScroll) listScroll = selected;
|
|
121
|
+
if (selected >= listScroll + bodyRows) listScroll = selected - bodyRows + 1;
|
|
122
|
+
const list = changes.slice(listScroll, listScroll + bodyRows).map((change, index) => {
|
|
123
|
+
const active = listScroll + index === selected;
|
|
124
|
+
const counts = fg("success", `+${change.added}`) + fg("error", ` −${change.removed}`);
|
|
125
|
+
const name = truncateToWidth((active ? "▌" : " ") + STATUS_MARK[change.status] + " " + change.rel, Math.max(4, listWidth - String(change.added).length - String(change.removed).length - 4));
|
|
126
|
+
return fg(active ? "accent" : "muted", name) + " " + counts;
|
|
127
|
+
});
|
|
128
|
+
const totals = changes.reduce((sum, change) => [sum[0]! + change.added, sum[1]! + change.removed], [0, 0]);
|
|
129
|
+
const title = `± CHANGES · ${changes.length} file${changes.length === 1 ? "" : "s"} · +${totals[0]} −${totals[1]}`;
|
|
130
|
+
const footer = [
|
|
131
|
+
...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
|
|
132
|
+
message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn scroll · press a key or click an action · Esc close")
|
|
133
|
+
];
|
|
134
|
+
const split = splitFrame(theme, width, title, list, content.slice(scroll, scroll + bodyRows), footer, bodyRows, listWidth);
|
|
135
|
+
layout = split.layout;
|
|
136
|
+
return split.lines;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
return component;
|
|
140
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Track edit/write targets before they run, and expose `/diff` plus ctrl+alt+d. */
|
|
144
|
+
export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTracker | undefined, changed: () => void): void {
|
|
145
|
+
pi.on("tool_call", (event) => {
|
|
146
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
147
|
+
const path = (event.input as { path?: unknown }).path;
|
|
148
|
+
if (typeof path === "string" && path.trim()) tracker()?.capture(path);
|
|
149
|
+
});
|
|
150
|
+
pi.on("tool_result", (event) => { if (event.toolName === "edit" || event.toolName === "write") changed(); });
|
|
151
|
+
const open = async (ctx: ExtensionContext) => {
|
|
152
|
+
const current = tracker();
|
|
153
|
+
if (!current) return;
|
|
154
|
+
try { await openDiffView(ctx, current); }
|
|
155
|
+
catch (error) { ctx.ui.notify("pi-jar: " + String(error), "error"); }
|
|
156
|
+
changed();
|
|
157
|
+
};
|
|
158
|
+
pi.registerCommand("diff", { description: "Review, accept or revert the files the agent changed", handler: async (_args, ctx) => open(ctx) });
|
|
159
|
+
pi.registerShortcut?.(Key.ctrlAlt("d"), { description: "Review agent changes (pi-jar)", handler: async (ctx) => open(ctx) });
|
|
160
|
+
}
|