pi-claude-code-ui 1.0.86 → 1.0.88

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/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  > [!IMPORTANT]
4
4
  > **1.0.69 — package rename (permanent).** Canonical npm name is now [`pi-claude-code-ui`](https://www.npmjs.com/package/pi-claude-code-ui). `pi-claude-style-tools` is legacy and will not receive further releases. Install with `pi install npm:pi-claude-code-ui` or `npm i pi-claude-code-ui`.
5
5
 
6
+ ## 1.0.88 — 2026-10-19
7
+
8
+ ### Added
9
+
10
+ - **`diffLayout` setting** ([#34](https://github.com/FammasMaz/pi-cc-tools/issues/34)) - `"auto"` (default) keeps the current behavior and uses split view only when it fits without heavy wrapping. `"split"` always shows side-by-side diffs when both code columns fit, and `"unified"` always stacks them. Applies to `edit`, `write` and `apply_patch` previews.
11
+
12
+ ### Fixed
13
+
14
+ - **Finished edits keep their diff after resume, expand and resize** (Jerry, [#37](https://github.com/FammasMaz/pi-cc-tools/pull/37), fixes [#35](https://github.com/FammasMaz/pi-cc-tools/issues/35)) - edits are located once before they run and their start lines are saved in the result details, so a resumed session redraws the same diff at the same line numbers instead of only the stats line, and expanding or resizing no longer drops back to line 1. Sessions saved before this change are located from the patch Pi already stores with each edit result.
15
+
16
+ ## 1.0.87 — 2026-10-19
17
+
18
+ ### Fixed
19
+
20
+ - **Expanding image reads no longer crashes Pi** — clear the image-source reuse cache together with the hidden image components when a `read` result is collapsed. Re-expanding standalone or grouped image reads now creates valid image components instead of reusing a missing component and throwing `TypeError: Cannot read properties of undefined (reading 'render')`.
21
+
6
22
  ## 1.0.86 — 2026-10-08
7
23
 
8
24
  ### Fixed
package/README.md CHANGED
@@ -57,6 +57,7 @@ Set in `.pi/settings.json` or the active Pi agent settings file (`$PI_CODING_AGE
57
57
  "liveToolPreview": true,
58
58
  "liveToolPreviewLines": 5,
59
59
  "diffCollapsedLines": 24,
60
+ "diffLayout": "auto",
60
61
  "themeAdaptive": true,
61
62
  "diffTheme": "github-dark"
62
63
  }
@@ -152,6 +153,7 @@ Use `/cc-tools` to control tool UI at runtime:
152
153
  | `liveToolPreview` | `true` | Show a small live output preview while tools are still running |
153
154
  | `liveToolPreviewLines` | `5` | Lines shown in the collapsed live preview |
154
155
  | `diffCollapsedLines` | `24` | Diff lines before collapsing |
156
+ | `diffLayout` | `"auto"` | `"auto"` uses split view only when it fits without heavy wrapping; `"split"` always uses split view when both columns fit; `"unified"` always stacks |
155
157
 
156
158
  ## Notes
157
159
 
@@ -124,6 +124,8 @@ interface SettingsFile {
124
124
  liveToolPreviewLines?: number;
125
125
  showTruncationHints?: boolean;
126
126
  diffCollapsedLines?: number;
127
+ /** Diff layout: "auto" (default) picks split only when it fits cleanly; "split" and "unified" force one layout. */
128
+ diffLayout?: "auto" | "split" | "unified";
127
129
  diffTheme?: string;
128
130
  diffColors?: Record<string, string>;
129
131
  /**
@@ -2965,6 +2967,11 @@ function syncLiveToolRenderState(component: any): void {
2965
2967
  state._liveLineCount = collectNonEmptyLines(raw, 0).total;
2966
2968
  } else if (component?.isPartial !== true) {
2967
2969
  delete state._liveLineCount;
2970
+ // renderCall never sees the result, so hand a finished edit's details over: its diff is redrawn from them, not from
2971
+ // the file. A call aborted while streaming ends with a bare error instead of a toolResult; it never drew a diff.
2972
+ if (component?.toolName === "edit" && component.result && (!ctxLike.isError || component.result.role === "toolResult")) {
2973
+ state._editResult = component.result.details ?? {};
2974
+ }
2968
2975
  }
2969
2976
  }
2970
2977
 
@@ -3016,6 +3023,8 @@ function removeImageChildren(component: any): void {
3016
3023
  try { component.removeChild?.(child); } catch { /* noop */ }
3017
3024
  }
3018
3025
  component.imageComponents = [];
3026
+ // Pi reuses image components by matching imageSources; clear both caches.
3027
+ component.imageSources = [];
3019
3028
  component.imageSpacers = [];
3020
3029
  }
3021
3030
 
@@ -3267,6 +3276,7 @@ function fileExistsForTool(cwd: string, filePath: string): boolean {
3267
3276
  }
3268
3277
 
3269
3278
  const WRITE_EXISTED_BEFORE = new Map<string, boolean>();
3279
+ const EDIT_LOCALIZED = new Map<string, Promise<LocalizedEditDiff[] | null>>();
3270
3280
 
3271
3281
  interface RtkRewriteRecord {
3272
3282
  original: string;
@@ -4953,12 +4963,20 @@ function maxLineNumber(lines: DiffLine[]): number {
4953
4963
  return max;
4954
4964
  }
4955
4965
 
4966
+ function diffLayout(): "auto" | "split" | "unified" {
4967
+ return getMode(readSettings().diffLayout, ["auto", "split", "unified"] as const, "auto");
4968
+ }
4969
+
4956
4970
  function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
4957
4971
  if (!diff.lines.length) return false;
4958
- if (tw < SPLIT_MIN_WIDTH) return false;
4972
+ const layout = diffLayout();
4973
+ if (layout === "unified") return false;
4959
4974
  const nw = Math.max(2, String(maxLineNumber(diff.lines)).length);
4960
4975
  const half = Math.floor((tw - 1) / 2);
4961
4976
  const gw = nw + 5;
4977
+ // Forced split still needs room for both code columns; below that it would overflow the row.
4978
+ if (layout === "split") return half - gw >= 12;
4979
+ if (tw < SPLIT_MIN_WIDTH) return false;
4962
4980
  const cw = Math.max(12, half - gw);
4963
4981
  if (cw < SPLIT_MIN_CODE_WIDTH) return false;
4964
4982
  const vis = diff.lines.slice(0, maxRows);
@@ -5555,15 +5573,71 @@ function getFirstChangedNewLine(diff: ParsedDiff): number {
5555
5573
  return 0;
5556
5574
  }
5557
5575
 
5576
+ // Where an edit's diff starts in the old and the new file, plus the text it replaced if that is not oldText verbatim
5577
+ // (a fuzzy match). Saved with the result: once the edit has run, the file no longer holds oldText to find these again.
5578
+ interface SavedEditPreview {
5579
+ oldLine: number;
5580
+ newLine: number;
5581
+ oldText?: string;
5582
+ }
5583
+
5558
5584
  interface LocalizedEditDiff {
5559
5585
  diff: ParsedDiff;
5560
5586
  line: number;
5587
+ saved?: SavedEditPreview;
5561
5588
  }
5562
5589
 
5563
- async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ oldText: string; newText: string }>, cwd: string): Promise<LocalizedEditDiff[] | null> {
5590
+ function restoreLocalizedEditDiffs(saved: unknown, operations: Array<{ oldText: string; newText: string }>): LocalizedEditDiff[] | null {
5591
+ if (!Array.isArray(saved) || saved.length !== operations.length) return null;
5592
+ return operations.map((edit, i) => {
5593
+ const { oldLine, newLine, oldText } = saved[i] as SavedEditPreview;
5594
+ const diff = offsetParsedDiff(parseDiff(oldText ?? normalizeToLf(edit.oldText), normalizeToLf(edit.newText)), oldLine - 1, newLine - 1);
5595
+ return { diff, line: getFirstChangedNewLine(diff) };
5596
+ });
5597
+ }
5598
+
5599
+ // Results saved before SavedEditPreview existed: the patch Pi saves with every edit result still holds the old lines
5600
+ // around each change, at their old line numbers. Lines it does not hold can match nothing.
5601
+ function oldContentFromPatch(patch: string, operations: Array<{ oldText: string; newText: string }>): string {
5602
+ const known: string[] = [];
5603
+ const changes: number[] = [];
5604
+ for (const hunk of Diff.parsePatch(patch)[0]?.hunks ?? []) {
5605
+ let at = hunk.oldStart - 1;
5606
+ for (const line of hunk.lines) {
5607
+ if (line[0] === "-" || line[0] === "+") changes.push(at);
5608
+ if (line[0] === " " || line[0] === "-") known[at++] = line.slice(1);
5609
+ }
5610
+ }
5611
+ // The patch keeps 4 lines around a change. An oldText reaching further supplies the missing lines itself, but only if
5612
+ // it fits in exactly one place touching a change: a chance fit elsewhere would put the edit on the wrong line.
5613
+ const fuzzy = known.map(normalizeTextForFuzzyMatch);
5614
+ for (const { oldText } of operations) {
5615
+ const own = normalizeToLf(oldText).split("\n");
5616
+ const want = normalizeTextForFuzzyMatch(normalizeToLf(oldText)).split("\n");
5617
+ const last = want.length - 1;
5618
+ const starts = new Set<number>();
5619
+ for (const change of changes) {
5620
+ for (let at = Math.max(0, change - want.length); at <= change; at++) starts.add(at);
5621
+ }
5622
+ const fits = [...starts].filter((at) => {
5623
+ let overlaps = false;
5624
+ return want.every((line, i) => {
5625
+ const have = fuzzy[at + i];
5626
+ if (have === undefined) return true;
5627
+ overlaps = true;
5628
+ return i === 0 ? have.endsWith(line) : i === last ? have.startsWith(line) : have === line;
5629
+ }) && overlaps;
5630
+ });
5631
+ // An empty first or last piece is only the line break before or after oldText, not a line of its own.
5632
+ if (fits.length === 1) own.forEach((line, i) => { if (line || (i > 0 && i < last)) known[fits[0] + i] ??= line; });
5633
+ }
5634
+ return `${Array.from(known, (line) => line ?? "\u0000").join("\n")}\n`;
5635
+ }
5636
+
5637
+ async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ oldText: string; newText: string }>, cwd: string, savedPatch?: string): Promise<LocalizedEditDiff[] | null> {
5564
5638
  if (!filePath || operations.length === 0) return null;
5565
5639
  try {
5566
- const rawContent = await readFileAsync(resolve(cwd, filePath), "utf8");
5640
+ const rawContent = savedPatch === undefined ? await readFileAsync(resolve(cwd, filePath), "utf8") : oldContentFromPatch(savedPatch, operations);
5567
5641
  const normalizedContent = normalizeToLf(stripBomText(rawContent));
5568
5642
  const normalizedOps = operations.map((edit) => ({ oldText: normalizeToLf(edit.oldText), newText: normalizeToLf(edit.newText) }));
5569
5643
  const baseContent = normalizedOps.some((edit) => findEditMatch(normalizedContent, edit.oldText).usedFuzzyMatch)
@@ -5588,7 +5662,9 @@ async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ o
5588
5662
  const oldStartLine = lineNumberAtIndex(baseContent, match.matchIndex);
5589
5663
  const newStartLine = oldStartLine + lineDelta;
5590
5664
  const diff = offsetParsedDiff(parseDiff(oldChunk, match.newText), oldStartLine - 1, newStartLine - 1);
5591
- localized[match.editIndex] = { diff, line: getFirstChangedNewLine(diff) };
5665
+ const saved: SavedEditPreview = { oldLine: oldStartLine, newLine: newStartLine };
5666
+ if (oldChunk !== normalizedOps[match.editIndex].oldText) saved.oldText = oldChunk;
5667
+ localized[match.editIndex] = { diff, line: getFirstChangedNewLine(diff), saved };
5592
5668
  lineDelta += countLineBreaks(match.newText) - countLineBreaks(oldChunk);
5593
5669
  }
5594
5670
  return localized.every(Boolean) ? (localized as LocalizedEditDiff[]) : null;
@@ -7684,6 +7760,13 @@ export default function (pi: ExtensionAPI) {
7684
7760
  });
7685
7761
 
7686
7762
  const editTool = createEditTool(cwd);
7763
+ // The live preview and execute() share one look at the file, taken before the edit runs: what the row shows live is
7764
+ // then exactly what is saved with the result, and so what it shows again after a resume.
7765
+ const localizeEditOnce = (toolCallId: string, filePath: string, operations: Array<{ oldText: string; newText: string }>) => {
7766
+ let localized = EDIT_LOCALIZED.get(toolCallId);
7767
+ if (!localized) EDIT_LOCALIZED.set(toolCallId, localized = computeLocalizedEditDiffs(filePath, operations, cwd));
7768
+ return localized;
7769
+ };
7687
7770
  pi.registerTool({
7688
7771
  name: "edit",
7689
7772
  label: "edit",
@@ -7694,11 +7777,14 @@ export default function (pi: ExtensionAPI) {
7694
7777
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7695
7778
  const fp = params.path ?? (params as any).file_path ?? "";
7696
7779
  const operations = getEditOperations(params);
7697
- const localizedDiffs = operations.length === 1 ? await computeLocalizedEditDiffs(fp, operations, cwd) : null;
7780
+ const localizedDiffs = await localizeEditOnce(toolCallId, fp, operations);
7781
+ EDIT_LOCALIZED.delete(toolCallId);
7698
7782
  const result = await editTool.execute(toolCallId, params, signal, onUpdate);
7699
7783
  if (operations.length === 0) return result;
7700
7784
  const { diffs, summary, totalLines, totalHunks } = summarizeEditOperations(operations);
7701
7785
  const baseDetails = (((result as any).details ?? {}) as Record<string, unknown>);
7786
+ // null: the edits could not be located, and the row showed (and keeps showing) the diff without file line numbers.
7787
+ const preview = localizedDiffs?.map((entry) => entry.saved) ?? null;
7702
7788
  if (operations.length === 1) {
7703
7789
  const localized = localizedDiffs?.[0];
7704
7790
  const editLine = localized?.line ?? (typeof baseDetails.firstChangedLine === "number" ? baseDetails.firstChangedLine : 0);
@@ -7711,6 +7797,7 @@ export default function (pi: ExtensionAPI) {
7711
7797
  hunks: countDiffHunks(diff),
7712
7798
  added: diff?.added ?? 0,
7713
7799
  removed: diff?.removed ?? 0,
7800
+ preview,
7714
7801
  };
7715
7802
  return result;
7716
7803
  }
@@ -7723,6 +7810,7 @@ export default function (pi: ExtensionAPI) {
7723
7810
  hunks: totalHunks,
7724
7811
  totalAdded: diffs.reduce((sum, diff) => sum + diff.added, 0),
7725
7812
  totalRemoved: diffs.reduce((sum, diff) => sum + diff.removed, 0),
7813
+ preview,
7726
7814
  };
7727
7815
  return result;
7728
7816
  },
@@ -7733,7 +7821,9 @@ export default function (pi: ExtensionAPI) {
7733
7821
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
7734
7822
  syncToolCallStatus(ctx);
7735
7823
  const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
7736
- if (!(ctx.argsComplete && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
7824
+ // History replay never sets argsComplete; a finished edit is drawn from its result instead.
7825
+ const finished = ctx.state._editResult as Record<string, unknown> | undefined;
7826
+ if (!((ctx.argsComplete || finished) && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
7737
7827
  const diffWidth = contextDiffWidth(ctx, 3);
7738
7828
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
7739
7829
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
@@ -7742,7 +7832,13 @@ export default function (pi: ExtensionAPI) {
7742
7832
  ctx.state._ptBody = theme.fg("muted", "(rendering…)");
7743
7833
  ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
7744
7834
  const lg = lang(fp);
7745
- void computeLocalizedEditDiffs(fp, operations, cwd)
7835
+ // Located once per row: expanding or resizing must not look in the file again, it no longer holds oldText.
7836
+ ctx.state._editLocalized ??= !finished
7837
+ ? localizeEditOnce(ctx.toolCallId, fp, operations)
7838
+ : "preview" in finished
7839
+ ? Promise.resolve(restoreLocalizedEditDiffs(finished.preview, operations))
7840
+ : computeLocalizedEditDiffs(fp, operations, cwd, typeof finished.patch === "string" ? finished.patch : "");
7841
+ void (ctx.state._editLocalized as Promise<LocalizedEditDiff[] | null>)
7746
7842
  .then((localizedDiffs) => {
7747
7843
  if (ctx.state._pk !== key) return;
7748
7844
  const diffs = localizedDiffs?.map((entry) => entry.diff) ?? fallbackDiffs;
@@ -7911,6 +8007,7 @@ export default function (pi: ExtensionAPI) {
7911
8007
  clearAllBashDurationContexts();
7912
8008
  clearRtkRewriteState();
7913
8009
  WRITE_EXISTED_BEFORE.clear();
8010
+ EDIT_LOCALIZED.clear();
7914
8011
  clearHighlightCache();
7915
8012
  invalidateThemePaletteCache();
7916
8013
  bumpToolBranchVisualEpoch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.86",
3
+ "version": "1.0.88",
4
4
  "description": "Claude Code-style grouped tool rows for pi with Ctrl+O/Ctrl+Shift+O previews, math rendering, and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",