pi-jar 0.1.3 → 0.1.5

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/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
- private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
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
- /** Record the pre-change content once; returns false when the file cannot be tracked. */
116
- capture(path: string): boolean {
117
- const target = this.absolute(path);
118
- if (this.baselines.has(target)) return true;
119
- // Only project files: plan files and scratch space elsewhere are not part of the review.
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 content = readText(target);
124
- if (content === undefined) return false;
125
- const bytes = this.bytes(content);
126
- if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
127
- this.baselines.set(target, content);
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.
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Directory a parent hands to a subagent so the child can report each file's content before its
7
+ * first edit/write. The parent then adopts those baselines into its own `/diff` review.
8
+ */
9
+ export const CHILD_BASELINE_ENV = "PI_JAR_BASELINE_DIR";
10
+
11
+ type BaselineRecord = { path: string; content: string | null };
12
+
13
+ /** Writer and reader must agree on the name: one JSON file per absolute path. */
14
+ const fileFor = (dir: string, absPath: string) => join(dir, createHash("sha1").update(absPath).digest("hex") + ".json");
15
+
16
+ /**
17
+ * Store the pre-change content (`null` = file did not exist) once per absolute path. The first
18
+ * write wins: the temp file is hard-linked into place, which fails instead of replacing an
19
+ * existing baseline, so readers never see a partial or newer record. Throws on I/O failure.
20
+ */
21
+ export function writeChildBaseline(dir: string, absPath: string, content: string | null): void {
22
+ const target = fileFor(dir, absPath);
23
+ const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
24
+ if (existsSync(target)) return;
25
+ mkdirSync(dir, { recursive: true });
26
+ try {
27
+ writeFileSync(temp, JSON.stringify({ path: absPath, content } satisfies BaselineRecord));
28
+ linkSync(temp, target);
29
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
30
+ finally { rmSync(temp, { force: true }); }
31
+ }
32
+
33
+ /** The recorded baseline, or `undefined` when absent, unreadable or malformed. */
34
+ export function readChildBaseline(dir: string, absPath: string): string | null | undefined {
35
+ let record: unknown;
36
+ try { record = JSON.parse(readFileSync(fileFor(dir, absPath), "utf8")); }
37
+ catch { return undefined; } // absent or corrupt: the parent simply does not adopt this file
38
+ if (!record || typeof record !== "object") return undefined;
39
+ const { path, content } = record as Partial<BaselineRecord>;
40
+ if (path !== absPath || (content !== null && typeof content !== "string")) return undefined;
41
+ return content;
42
+ }
@@ -14,18 +14,31 @@ type ToolResult = { content: Array<{ type: string; text?: string }>; details?: u
14
14
 
15
15
  const SUMMARY_SCAN_CHARS = 32 * 1024;
16
16
  const DIFF_SCAN_CHARS = 64 * 1024;
17
- const resultSummaryCache = new WeakMap<object, { sample: string; lines: number; truncated: boolean }>();
17
+ interface Summary { sample: string; lines: number; truncated: boolean; preview?: string }
18
+ /**
19
+ * Pi hands renderResult a fresh `{ content, details }` wrapper on every display update, and Ctrl+O or a
20
+ * theme change re-renders every tool of the session, so these caches key on the stable content/details.
21
+ */
22
+ const resultSummaryCache = new WeakMap<object, Summary>();
18
23
  const writeCountCache = new WeakMap<object, { lines: number; truncated: boolean }>();
24
+ const diffCountCache = new WeakMap<object, { diff: string; additions: number; removals: number }>();
19
25
 
20
- function summarizeResult(result: ToolResult): { sample: string; lines: number; truncated: boolean } {
21
- if (result && typeof result === "object") {
22
- const cached = resultSummaryCache.get(result as object);
23
- if (cached) return cached;
24
- }
26
+ /** Line count without splitting the text into an array. */
27
+ function lineCount(text: string): number {
28
+ if (!text) return 0;
29
+ let lines = 1;
30
+ for (let at = text.indexOf("\n"); at >= 0; at = text.indexOf("\n", at + 1)) lines++;
31
+ return lines;
32
+ }
33
+
34
+ function summarizeResult(result: ToolResult): Summary {
35
+ const content = result.content;
36
+ const cached = resultSummaryCache.get(content);
37
+ if (cached) return cached;
25
38
  let sample = "";
26
39
  let truncated = false;
27
40
  let textParts = 0;
28
- for (const item of result.content) {
41
+ for (const item of content) {
29
42
  if (item.type !== "text" || typeof item.text !== "string") continue;
30
43
  if (textParts++ && sample.length < SUMMARY_SCAN_CHARS) sample += "\n";
31
44
  const room = SUMMARY_SCAN_CHARS - sample.length;
@@ -33,26 +46,41 @@ function summarizeResult(result: ToolResult): { sample: string; lines: number; t
33
46
  sample += item.text.slice(0, room);
34
47
  if (item.text.length > room) { truncated = true; break; }
35
48
  }
36
- const value = { sample, lines: sample ? sample.split("\n").length : 0, truncated };
37
- if (result && typeof result === "object") resultSummaryCache.set(result as object, value);
49
+ const value: Summary = { sample, lines: lineCount(sample), truncated };
50
+ if (content && typeof content === "object") resultSummaryCache.set(content, value);
38
51
  return value;
39
52
  }
40
53
 
41
54
  function firstNonEmpty(text: string): string {
42
- for (const line of text.split("\n")) if (line.trim()) return line;
43
- return "";
55
+ for (let start = 0; ;) {
56
+ const end = text.indexOf("\n", start);
57
+ const line = text.slice(start, end < 0 ? text.length : end);
58
+ if (line.trim()) return line;
59
+ if (end < 0) return "";
60
+ start = end + 1;
61
+ }
44
62
  }
45
63
 
46
64
  function boundedLineCount(text: string, limit = SUMMARY_SCAN_CHARS): { lines: number; truncated: boolean } {
47
- const sample = text.slice(0, limit);
48
- return { lines: sample ? sample.split("\n").length : 0, truncated: text.length > limit };
65
+ return { lines: lineCount(text.slice(0, limit)), truncated: text.length > limit };
49
66
  }
50
67
 
51
68
  const expandHint = " · [ expand ] Ctrl+O";
52
69
 
70
+ /**
71
+ * Whitespace-collapsed, trimmed text cut to `max` characters. Only a bounded prefix is scanned: a call
72
+ * card re-renders on every streamed argument delta, so collapsing a whole heredoc each time was O(n²).
73
+ */
53
74
  function compactText(text: string, max = 72): string {
54
- const clean = text.replace(/\s+/g, " ").trim();
55
- return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)) + "…";
75
+ for (let end = Math.min(text.length, 2 * max + 64); ; end = Math.min(text.length, end * 4)) {
76
+ const clean = text.slice(0, end).replace(/\s+/g, " ").trimStart();
77
+ if (end >= text.length) {
78
+ const full = clean.trimEnd();
79
+ return full.length <= max ? full : full.slice(0, Math.max(0, max - 1)) + "…";
80
+ }
81
+ // The collapsed prefix is a prefix of the whole; past max + 1 characters only one trailing space could still be trimmed.
82
+ if (clean.length > max + 1) return clean.slice(0, Math.max(0, max - 1)) + "…";
83
+ }
56
84
  }
57
85
 
58
86
  const bashCache = new WeakMap<object, { cwd: string; trusted: boolean; tool: ReturnType<typeof createBashToolDefinition> }>();
@@ -112,8 +140,11 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
112
140
  renderResult(result, options, theme, context) {
113
141
  if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
114
142
  const summary = summarizeResult(result);
115
- const first = firstNonEmpty(summary.sample);
116
- const preview = first ? ` · ${compactText(first, 56)}` : "";
143
+ if (summary.preview === undefined) {
144
+ const first = firstNonEmpty(summary.sample);
145
+ summary.preview = first ? ` · ${compactText(first, 56)}` : "";
146
+ }
147
+ const preview = summary.preview;
117
148
  const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
118
149
  const label = options.isPartial ? "running" : "done";
119
150
  return new Text(theme.fg(options.isPartial ? "warning" : "muted",
@@ -138,14 +169,17 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
138
169
  return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
139
170
  }
140
171
  if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
141
- let additions = 0;
142
- let removals = 0;
143
- for (const line of diff.split("\n")) {
144
- if (line.startsWith("+") && !line.startsWith("+++")) additions++;
145
- else if (line.startsWith("-") && !line.startsWith("---")) removals++;
172
+ let counts = diffCountCache.get(details!);
173
+ if (counts?.diff !== diff) {
174
+ counts = { diff, additions: 0, removals: 0 };
175
+ for (const line of diff.split("\n")) {
176
+ if (line.startsWith("+") && !line.startsWith("+++")) counts.additions++;
177
+ else if (line.startsWith("-") && !line.startsWith("---")) counts.removals++;
178
+ }
179
+ diffCountCache.set(details!, counts);
146
180
  }
147
- return new Text(theme.fg("success", `+${additions}`) + theme.fg("dim", " / ")
148
- + theme.fg("error", `-${removals}`) + theme.fg("dim", expandHint), 0, 0);
181
+ return new Text(theme.fg("success", `+${counts.additions}`) + theme.fg("dim", " / ")
182
+ + theme.fg("error", `-${counts.removals}`) + theme.fg("dim", expandHint), 0, 0);
149
183
  }
150
184
  });
151
185
 
package/src/composer.ts CHANGED
@@ -4,6 +4,7 @@ import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type Mascot
4
4
  import type { SuggestionState } from "./suggest.ts";
5
5
  import type { WorkingPhase } from "./working.ts";
6
6
  import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
7
+ import { RowCache } from "./row-cache.ts";
7
8
  // Aliased: roundedInput has a parameter named `icon`.
8
9
  import { icon as glyph, type IconKey } from "./icons.ts";
9
10
 
@@ -77,12 +78,27 @@ export interface RoundedInputOptions {
77
78
  dim?: (text: string) => string;
78
79
  }
79
80
 
81
+ /** The last framed editor: Pi repaints the composer on every render (each streamed token) while the draft sits still. */
82
+ let lastFrame: { lines: string[]; key: string; border: (text: string) => string; dim?: (text: string) => string; framed: string[] } | undefined;
83
+ /** While typing only the edited row changes; the rest of a long draft is reused. */
84
+ const framedRows = new RowCache();
85
+ const fittedLines = new RowCache();
86
+ const sameLines = (a: readonly string[], b: readonly string[]) => {
87
+ if (a.length !== b.length) return false;
88
+ for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
89
+ return true;
90
+ };
91
+
80
92
  /** Frame a real editor without changing its keyboard, history or autocomplete implementation. */
81
93
  export function roundedInput(lines: string[], width: number, focused: boolean, theme: EditorTheme, focusPaint?: (text: string) => string,
82
94
  icon = composerIcon("idle"), session = "", options: RoundedInputOptions = {}): string[] {
83
95
  if (width < 8 || lines.length < 2) return lines.map((line) => truncateToWidth(line, Math.max(0, width)));
84
96
  const border = focused && focusPaint ? focusPaint : theme.borderColor;
85
97
  const dim = options.dim ?? ((text: string) => text);
98
+ // Painter samples catch a theme change behind the same function.
99
+ const key = [width, icon, session, options.ghost ?? "", options.hint ?? "", options.paintedIcon ?? "", options.visibleRows ?? "",
100
+ options.below ?? "", glyph("tab"), border("─"), dim("─")].join("\0");
101
+ if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
86
102
  const bottom = bottomBorderIndex(lines, options.visibleRows);
87
103
  const inner = width - 2;
88
104
  const meta = session ? `${icon} · session ${session}` : icon;
@@ -97,6 +113,10 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
97
113
  const left = below ? `─ ${below} ` : "";
98
114
  const fill = Math.max(0, width - 2 - visibleWidth(left) - visibleWidth(hint) - 1);
99
115
  const bottomLine = border("╰" + left + "─".repeat(fill)) + (hint ? dim(hint) : "") + border((hint ? "─" : "─") + "╯");
116
+ const side = border("│");
117
+ framedRows.begin(`${inner}\0${side}`);
118
+ fittedLines.begin(String(width));
119
+ const fit = (text: string) => fittedLines.get(text, (value) => truncateToWidth(value, width));
100
120
  const content = lines.slice(1, bottom).map((line, index) => {
101
121
  let row = line;
102
122
  if (index === 0 && options.ghost && row.includes(CURSOR)) {
@@ -106,13 +126,13 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
106
126
  const tab = glyph("tab") === "tab" ? "tab" : keyHint("tab", "tab");
107
127
  if (room > 2) row = base + dim(truncateToWidth(options.ghost + " " + tab, room, "…"));
108
128
  }
109
- const fitted = truncateToWidth(row, inner);
110
- return border("│") + fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted))) + border("│");
129
+ return framedRows.get(row, (value) => side + truncateToWidth(value, inner, "...", true) + side);
111
130
  });
112
131
  // Autocomplete rows stay below the frame, indented to line up with the text.
113
- const trailing = lines.slice(bottom + 1).map((line) => truncateToWidth(" " + line, width));
114
- return [truncateToWidth(top, width), ...content, truncateToWidth(bottomLine, width),
115
- ...(options.below ? [truncateToWidth(" " + options.below, width)] : []), ...trailing];
132
+ const trailing = lines.slice(bottom + 1).map((line) => fit(" " + line));
133
+ const framed = [fit(top), ...content, fit(bottomLine), ...(options.below ? [fit(" " + options.below)] : []), ...trailing];
134
+ lastFrame = { lines: lines.slice(), key, border, ...(options.dim ? { dim: options.dim } : {}), framed };
135
+ return framed.slice();
116
136
  }
117
137
 
118
138
  export interface ComposerDecor {
@@ -174,15 +194,19 @@ class RoundedEditor extends CustomEditor {
174
194
  const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
175
195
  this.ghostShown = !!ghost;
176
196
  const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
177
- const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, this.decor.icon(), this.decor.session(), {
178
- ...(ghost ? { ghost } : {}), ...(this.decor.hint() ? { hint: this.decor.hint()! } : {}),
179
- ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}),
197
+ const icon = this.decor.icon();
198
+ const session = this.decor.session();
199
+ const hint = this.decor.hint();
200
+ const paintedIcon = this.decor.paintedIcon();
201
+ const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
202
+ ...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
180
203
  ...(visibleRows != null ? { visibleRows } : {}), dim: this.decor.dim,
181
204
  ...(below ? { below } : {})
182
205
  });
183
206
  const tip = width >= 8 ? this.decor.tip(width) : undefined;
184
207
  this.tipRows = tip ? 1 : 0;
185
- return tip ? [truncateToWidth(tip, width), ...framed] : framed;
208
+ // The tip is a few painted cells; measuring (cached) skips re-segmenting it on every frame.
209
+ return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
186
210
  }
187
211
 
188
212
  override handleInput(data: string): void {
@@ -244,13 +268,16 @@ class ThemedEditor implements EditorComponent {
244
268
  const text = this.base.getText();
245
269
  const ghost = text === "" ? this.decor.ghost() : undefined;
246
270
  const below = this.decor.attachments(text);
247
- const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, this.decor.icon(), this.decor.session(), {
248
- ...(ghost ? { ghost } : {}), ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}), dim: this.decor.dim,
271
+ const icon = this.decor.icon();
272
+ const session = this.decor.session();
273
+ const paintedIcon = this.decor.paintedIcon();
274
+ const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
275
+ ...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), dim: this.decor.dim,
249
276
  ...(below ? { below } : {})
250
277
  });
251
278
  const tip = width >= 8 ? this.decor.tip(width) : undefined;
252
279
  this.tipRows = tip ? 1 : 0;
253
- return tip ? [truncateToWidth(tip, width), ...framed] : framed;
280
+ return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
254
281
  }
255
282
  addToHistory(text: string) { this.base.addToHistory?.(text); }
256
283
  insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }