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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/docs/DESIGN.md +85 -0
  4. package/docs/DEVELOPMENT.md +95 -0
  5. package/docs/FLAME.md +54 -0
  6. package/docs/INTEGRATIONS.md +82 -0
  7. package/docs/PLAN.md +68 -0
  8. package/docs/SHOWCASE.md +22 -0
  9. package/docs/WORKFLOWS.md +141 -0
  10. package/extensions/index.ts +773 -0
  11. package/package.json +62 -0
  12. package/src/accent.ts +36 -0
  13. package/src/advisor.ts +194 -0
  14. package/src/animations.ts +23 -0
  15. package/src/ask-tool.ts +185 -0
  16. package/src/attachments.ts +79 -0
  17. package/src/changes.ts +146 -0
  18. package/src/commit.ts +61 -0
  19. package/src/compact-tools.ts +129 -0
  20. package/src/composer.ts +404 -0
  21. package/src/context-view.ts +141 -0
  22. package/src/delegate.ts +208 -0
  23. package/src/dialogs.ts +127 -0
  24. package/src/diff-view.ts +160 -0
  25. package/src/flame.ts +245 -0
  26. package/src/footer-settings.ts +32 -0
  27. package/src/footer.ts +147 -0
  28. package/src/goal-loop.ts +313 -0
  29. package/src/goals.ts +114 -0
  30. package/src/history-ui.ts +136 -0
  31. package/src/history.ts +168 -0
  32. package/src/info-panels.ts +75 -0
  33. package/src/mascot.ts +103 -0
  34. package/src/model-roles.ts +332 -0
  35. package/src/panel.ts +90 -0
  36. package/src/plan-utils.ts +191 -0
  37. package/src/plan-view.ts +149 -0
  38. package/src/plan.ts +460 -0
  39. package/src/prompt-search.ts +83 -0
  40. package/src/quota.ts +134 -0
  41. package/src/roles-ui.ts +190 -0
  42. package/src/roles.ts +25 -0
  43. package/src/session-gallery.ts +52 -0
  44. package/src/session-ui.ts +76 -0
  45. package/src/settings-ui.ts +154 -0
  46. package/src/settings.ts +96 -0
  47. package/src/shells.ts +319 -0
  48. package/src/side-model.ts +48 -0
  49. package/src/split-view.ts +74 -0
  50. package/src/status.ts +71 -0
  51. package/src/suggest.ts +89 -0
  52. package/src/task-tool.ts +135 -0
  53. package/src/tasks-ui.ts +73 -0
  54. package/src/tasks.ts +147 -0
  55. package/src/usage-view.ts +98 -0
  56. package/src/usage.ts +16 -0
  57. package/src/welcome.ts +260 -0
  58. package/src/working.ts +60 -0
  59. package/themes/pi-jar-dark-amber.json +84 -0
  60. package/themes/pi-jar-dark-azure.json +84 -0
  61. package/themes/pi-jar-dark-gray.json +84 -0
  62. package/themes/pi-jar-dark-pink.json +84 -0
  63. package/themes/pi-jar-dark-teal.json +84 -0
  64. package/themes/pi-jar-dark-violet.json +84 -0
  65. package/themes/pi-jar-dark.json +84 -0
package/src/changes.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { existsSync, lstatSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+
4
+ /** Files larger than this, or binary files, are not tracked for review. */
5
+ export const MAX_TRACKED_BYTES = 1024 * 1024;
6
+ export const MAX_TRACKED_FILES = 200;
7
+ /** Above this many (old × new) changed lines the inline diff is replaced by a summary. */
8
+ const MAX_DIFF_CELLS = 4_000_000;
9
+
10
+ export type ChangeStatus = "added" | "modified" | "deleted";
11
+ export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number }
12
+ export type DiffOp = { op: " " | "+" | "-"; text: string };
13
+ export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number };
14
+
15
+ /** Text content, `null` when the file is missing, or `undefined` when it cannot be tracked. */
16
+ function readText(path: string): string | null | undefined {
17
+ if (!existsSync(path)) return null;
18
+ const info = lstatSync(path);
19
+ if (!info.isFile() || info.size > MAX_TRACKED_BYTES) return undefined;
20
+ const buffer = readFileSync(path);
21
+ if (buffer.subarray(0, 8000).includes(0)) return undefined;
22
+ return buffer.toString("utf8");
23
+ }
24
+
25
+ const splitLines = (text: string) => text === "" ? [] : text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
26
+
27
+ /** Line diff: trims the common prefix/suffix, then an LCS over the changed middle. */
28
+ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
29
+ const a = splitLines(before), b = splitLines(after);
30
+ let start = 0;
31
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
32
+ let endA = a.length, endB = b.length;
33
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
34
+ const midA = a.slice(start, endA), midB = b.slice(start, endB);
35
+ if (midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
36
+ const n = midA.length, m = midB.length;
37
+ // lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened.
38
+ const lcs = new Uint32Array((n + 1) * (m + 1));
39
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
40
+ lcs[i * (m + 1) + j] = midA[i] === midB[j] ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
41
+ : Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
42
+ }
43
+ const ops: DiffOp[] = a.slice(0, start).map((text) => ({ op: " ", text }));
44
+ let i = 0, j = 0;
45
+ while (i < n || j < m) {
46
+ if (i < n && j < m && midA[i] === midB[j]) { ops.push({ op: " ", text: midA[i]! }); i++; j++; }
47
+ else if (j < m && (i >= n || lcs[i * (m + 1) + j + 1]! > lcs[(i + 1) * (m + 1) + j]!)) ops.push({ op: "+", text: midB[j++]! });
48
+ else ops.push({ op: "-", text: midA[i++]! });
49
+ }
50
+ for (const text of a.slice(endA)) ops.push({ op: " ", text });
51
+ return ops;
52
+ }
53
+
54
+ /** Unified hunks with `context` lines around each change. */
55
+ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
56
+ const rows: DiffRow[] = [];
57
+ const changed = ops.map((op) => op.op !== " ");
58
+ let oldLine = 1, newLine = 1;
59
+ const positions = ops.map((op) => {
60
+ const at = { oldLine, newLine };
61
+ if (op.op !== "+") oldLine++;
62
+ if (op.op !== "-") newLine++;
63
+ return at;
64
+ });
65
+ let index = 0;
66
+ while (index < ops.length) {
67
+ if (!changed[index]) { index++; continue; }
68
+ const from = Math.max(0, index - context);
69
+ let to = index;
70
+ // Extend the hunk while the next change is within 2 × context lines.
71
+ while (to < ops.length) {
72
+ let next = to + 1;
73
+ while (next < ops.length && !changed[next]) next++;
74
+ if (next < ops.length && next - to <= context * 2) { to = next; continue; }
75
+ break;
76
+ }
77
+ const end = Math.min(ops.length, to + context + 1);
78
+ const slice = ops.slice(from, end);
79
+ const oldCount = slice.filter((op) => op.op !== "+").length;
80
+ const newCount = slice.filter((op) => op.op !== "-").length;
81
+ rows.push({ kind: "hunk", text: `@@ -${positions[from]!.oldLine},${oldCount} +${positions[from]!.newLine},${newCount} @@` });
82
+ for (let at = from; at < end; at++) {
83
+ const op = ops[at]!;
84
+ rows.push({ kind: op.op === "+" ? "add" : op.op === "-" ? "remove" : "context", text: op.text,
85
+ ...(op.op !== "+" ? { oldLine: positions[at]!.oldLine } : {}), ...(op.op !== "-" ? { newLine: positions[at]!.newLine } : {}) });
86
+ }
87
+ index = end;
88
+ }
89
+ return rows;
90
+ }
91
+
92
+ /**
93
+ * Remembers each file's content before the agent's first edit/write to it, so the user can
94
+ * review everything changed since and accept or revert per file. Session-local and in memory.
95
+ */
96
+ export class ChangeTracker {
97
+ private baselines = new Map<string, string | null>();
98
+ private readonly cwd: () => string;
99
+ constructor(cwd: () => string) { this.cwd = cwd; }
100
+
101
+ private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
102
+
103
+ /** Record the pre-change content once; returns false when the file cannot be tracked. */
104
+ capture(path: string): boolean {
105
+ const target = this.absolute(path);
106
+ if (this.baselines.has(target)) return true;
107
+ // Only project files: plan files and scratch space elsewhere are not part of the review.
108
+ const rel = relative(this.cwd(), target);
109
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
110
+ if (this.baselines.size >= MAX_TRACKED_FILES) return false;
111
+ const content = readText(target);
112
+ if (content === undefined) return false;
113
+ this.baselines.set(target, content);
114
+ return true;
115
+ }
116
+
117
+ changes(): FileChange[] {
118
+ const result: FileChange[] = [];
119
+ for (const [path, before] of this.baselines) {
120
+ const now = readText(path);
121
+ if (now === undefined || now === before) continue;
122
+ const ops = lineDiff(before ?? "", now ?? "");
123
+ const added = ops ? ops.filter((op) => op.op === "+").length : splitLines(now ?? "").length;
124
+ const removed = ops ? ops.filter((op) => op.op === "-").length : splitLines(before ?? "").length;
125
+ const rel = relative(this.cwd(), path);
126
+ result.push({ path, rel: rel && !rel.startsWith("..") ? rel : path, status: before === null ? "added" : now === null ? "deleted" : "modified",
127
+ before: before ?? "", after: now ?? "", added, removed });
128
+ }
129
+ return result.sort((a, b) => a.rel.localeCompare(b.rel));
130
+ }
131
+
132
+ count(): number { return this.changes().length; }
133
+ /** Keep the current content: stop tracking the file. */
134
+ accept(path: string): void { this.baselines.delete(this.absolute(path)); }
135
+ acceptAll(): void { this.baselines.clear(); }
136
+ /** Restore the pre-change content (or remove a file the agent created). */
137
+ revert(path: string): void {
138
+ const target = this.absolute(path);
139
+ if (!this.baselines.has(target)) throw new Error("not a tracked change: " + path);
140
+ const before = this.baselines.get(target)!;
141
+ if (before === null) { if (existsSync(target)) unlinkSync(target); }
142
+ else writeFileSync(target, before);
143
+ this.baselines.delete(target);
144
+ }
145
+ clear(): void { this.baselines.clear(); }
146
+ }
package/src/commit.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ModelRoleManager } from "./model-roles.ts";
6
+ import { askRole, type SideUsage } from "./side-model.ts";
7
+
8
+ const DIFF_LIMIT = 60_000;
9
+
10
+ export const COMMIT_SYSTEM = [
11
+ "You write git commit messages.",
12
+ "Reply with the message only: no code fences, no preamble.",
13
+ "First line: imperative summary under 72 characters, matching the style of the recent commits (e.g. a `type: ` prefix if they use one).",
14
+ "Then a blank line and a short body explaining what changed and why, wrapped at 72 columns. Skip the body for trivial changes."
15
+ ].join("\n");
16
+
17
+ /** Remove fences or quoting a model may add around the message. */
18
+ export function cleanCommitMessage(text: string): string {
19
+ let body = text.trim().replace(/^```[a-z]*\n([\s\S]*?)\n```$/i, "$1").trim();
20
+ if (/^["'].*["']$/s.test(body)) body = body.slice(1, -1).trim();
21
+ return body.split("\n").map((line) => line.trimEnd()).join("\n").replace(/\n{3,}/g, "\n\n");
22
+ }
23
+
24
+ export function commitPrompt(stat: string, diff: string, log: string, hint: string): string {
25
+ const clipped = diff.length > DIFF_LIMIT ? diff.slice(0, DIFF_LIMIT) + `\n… diff truncated (${diff.length - DIFF_LIMIT} more characters)` : diff;
26
+ return [hint ? `Author's note: ${hint}\n` : "", "Recent commits:\n" + (log.trim() || "(none)"), "\nStaged files:\n" + stat.trim(), "\nStaged diff:\n" + clipped].join("\n");
27
+ }
28
+
29
+ /** `/jar commit [note]`: draft a message for the staged changes with the commit role, review it, commit. Never pushes. */
30
+ export async function jarCommit(pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
+ if (!ctx.hasUI) { ctx.ui.notify("/jar commit needs the interactive UI to review the message", "warning"); return; }
32
+ const git = async (...args: string[]) => {
33
+ const result = await pi.exec("git", args, { cwd: ctx.cwd, timeout: 20_000 });
34
+ if (result.code !== 0) throw new Error(`git ${args[0]}: ${(result.stderr || result.stdout).trim() || "exit " + result.code}`);
35
+ return result.stdout;
36
+ };
37
+ try {
38
+ await git("rev-parse", "--is-inside-work-tree");
39
+ let stat = await git("diff", "--cached", "--stat");
40
+ if (!stat.trim()) {
41
+ const dirty = await git("status", "--porcelain");
42
+ if (!dirty.trim()) { ctx.ui.notify("Nothing to commit", "info"); return; }
43
+ if (!await ctx.ui.confirm("Nothing staged", "Stage all changes (git add -A) and continue?")) return;
44
+ await git("add", "-A");
45
+ stat = await git("diff", "--cached", "--stat");
46
+ }
47
+ const [diff, log] = await Promise.all([git("diff", "--cached", "--no-color"), git("log", "-n", "8", "--oneline").catch(() => "")]);
48
+ ctx.ui.notify("Drafting a commit message…", "info");
49
+ const answer = await askRole(ctx, roles, usage, "commit", COMMIT_SYSTEM, commitPrompt(stat, diff, log, hint), ctx.signal);
50
+ const message = cleanCommitMessage(await ctx.ui.editor(`Commit message · ${answer.model} · save to commit, empty to cancel`, cleanCommitMessage(answer.text)) ?? "");
51
+ if (!message) { ctx.ui.notify("Commit cancelled", "info"); return; }
52
+ const dir = mkdtempSync(join(tmpdir(), "pi-jar-commit-"));
53
+ try {
54
+ writeFileSync(join(dir, "MSG"), message + "\n");
55
+ const out = await git("commit", "-F", join(dir, "MSG"));
56
+ ctx.ui.notify(out.trim().split("\n")[0] || "Committed", "info");
57
+ } finally { rmSync(dir, { recursive: true, force: true }); }
58
+ } catch (error) {
59
+ ctx.ui.notify("pi-jar commit: " + (error instanceof Error ? error.message : String(error)), "error");
60
+ }
61
+ }
@@ -0,0 +1,129 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ createEditToolDefinition,
4
+ createReadToolDefinition,
5
+ createWriteToolDefinition,
6
+ getAgentDir,
7
+ SettingsManager,
8
+ type ExtensionAPI,
9
+ type ExtensionContext
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Text } from "@earendil-works/pi-tui";
12
+
13
+ type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
14
+
15
+ const outputText = (result: ToolResult): string => result.content
16
+ .filter((item) => item.type === "text" && typeof item.text === "string")
17
+ .map((item) => item.text!)
18
+ .join("\n");
19
+
20
+ const nonEmptyLines = (text: string): string[] => text.split("\n").filter((line) => line.trim().length > 0);
21
+ const expandHint = " · [ expand ] Ctrl+O";
22
+
23
+ function compactText(text: string, max = 72): string {
24
+ const clean = text.replace(/\s+/g, " ").trim();
25
+ return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)) + "…";
26
+ }
27
+
28
+ function bashFor(ctx: ExtensionContext) {
29
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: ctx.isProjectTrusted() });
30
+ return createBashToolDefinition(ctx.cwd, {
31
+ shellPath: settings.getShellPath(),
32
+ commandPrefix: settings.getShellCommandPrefix()
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Keep Pi's execution/schema/prompt metadata intact and replace only the collapsed
38
+ * presentation. Expanded cards delegate back to Pi's native renderers.
39
+ */
40
+ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
41
+ if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
42
+
43
+ const cwd = process.cwd();
44
+ const read = createReadToolDefinition(cwd);
45
+ const bash = createBashToolDefinition(cwd);
46
+ const edit = createEditToolDefinition(cwd);
47
+ const write = createWriteToolDefinition(cwd);
48
+
49
+ pi.registerTool({
50
+ ...read,
51
+ renderCall(args, theme, context) {
52
+ if (context.expanded && read.renderCall) return read.renderCall(args, theme, context);
53
+ const range = args.offset || args.limit
54
+ ? theme.fg("dim", ` · ${args.offset ?? 1}${args.limit ? `+${args.limit}` : ""}`)
55
+ : "";
56
+ return new Text(theme.fg("toolTitle", theme.bold("read ")) + theme.fg("accent", args.path) + range, 0, 0);
57
+ },
58
+ renderResult(result, options, theme, context) {
59
+ if (options.expanded && read.renderResult) return read.renderResult(result, options, theme, context);
60
+ if (options.isPartial) return new Text(theme.fg("warning", "reading…"), 0, 0);
61
+ const text = outputText(result);
62
+ const count = text ? text.split("\n").length : 0;
63
+ return new Text(theme.fg("muted", `${count} line${count === 1 ? "" : "s"}${expandHint}`), 0, 0);
64
+ }
65
+ });
66
+
67
+ pi.registerTool({
68
+ ...bash,
69
+ async execute(id, params, signal, onUpdate, ctx) {
70
+ return bashFor(ctx).execute(id, params, signal, onUpdate, ctx);
71
+ },
72
+ renderCall(args, theme, context) {
73
+ if (context.expanded && bash.renderCall) return bash.renderCall(args, theme, context);
74
+ return new Text(theme.fg("toolTitle", theme.bold("$ ")) + theme.fg("accent", compactText(args.command, 88)), 0, 0);
75
+ },
76
+ renderResult(result, options, theme, context) {
77
+ if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
78
+ const text = outputText(result);
79
+ const lines = nonEmptyLines(text);
80
+ const preview = lines[0] ? ` · ${compactText(lines[0], 56)}` : "";
81
+ const label = options.isPartial ? "running" : "done";
82
+ return new Text(theme.fg(options.isPartial ? "warning" : "muted",
83
+ `${label} · ${lines.length} line${lines.length === 1 ? "" : "s"}${preview}${expandHint}`), 0, 0);
84
+ }
85
+ });
86
+
87
+ pi.registerTool({
88
+ ...edit,
89
+ renderCall(args, theme, context) {
90
+ if (context.expanded && edit.renderCall) return edit.renderCall(args, theme, context);
91
+ return new Text(theme.fg("toolTitle", theme.bold("edit ")) + theme.fg("accent", args.path), 0, 0);
92
+ },
93
+ renderResult(result, options, theme, context) {
94
+ if (options.expanded && edit.renderResult) return edit.renderResult(result, options, theme, context);
95
+ if (options.isPartial) return new Text(theme.fg("warning", "editing…"), 0, 0);
96
+ const details = result.details as { diff?: string } | undefined;
97
+ const diff = details?.diff ?? "";
98
+ if (!diff) {
99
+ const text = outputText(result);
100
+ return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
101
+ }
102
+ let additions = 0;
103
+ let removals = 0;
104
+ for (const line of diff.split("\n")) {
105
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
106
+ else if (line.startsWith("-") && !line.startsWith("---")) removals++;
107
+ }
108
+ return new Text(theme.fg("success", `+${additions}`) + theme.fg("dim", " / ")
109
+ + theme.fg("error", `-${removals}`) + theme.fg("dim", expandHint), 0, 0);
110
+ }
111
+ });
112
+
113
+ pi.registerTool({
114
+ ...write,
115
+ renderCall(args, theme, context) {
116
+ if (context.expanded && write.renderCall) return write.renderCall(args, theme, context);
117
+ const count = args.content.split("\n").length;
118
+ return new Text(theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", args.path)
119
+ + theme.fg("dim", ` · ${count} lines`), 0, 0);
120
+ },
121
+ renderResult(result, options, theme, context) {
122
+ if (options.expanded && write.renderResult) return write.renderResult(result, options, theme, context);
123
+ if (options.isPartial) return new Text(theme.fg("warning", "writing…"), 0, 0);
124
+ const text = outputText(result);
125
+ return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "written", 96))
126
+ + theme.fg("dim", text ? expandHint : ""), 0, 0);
127
+ }
128
+ });
129
+ }