pi-claude-code-ui 1.0.87 → 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,16 @@
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
+
6
16
  ## 1.0.87 — 2026-10-19
7
17
 
8
18
  ### 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
 
@@ -3269,6 +3276,7 @@ function fileExistsForTool(cwd: string, filePath: string): boolean {
3269
3276
  }
3270
3277
 
3271
3278
  const WRITE_EXISTED_BEFORE = new Map<string, boolean>();
3279
+ const EDIT_LOCALIZED = new Map<string, Promise<LocalizedEditDiff[] | null>>();
3272
3280
 
3273
3281
  interface RtkRewriteRecord {
3274
3282
  original: string;
@@ -4955,12 +4963,20 @@ function maxLineNumber(lines: DiffLine[]): number {
4955
4963
  return max;
4956
4964
  }
4957
4965
 
4966
+ function diffLayout(): "auto" | "split" | "unified" {
4967
+ return getMode(readSettings().diffLayout, ["auto", "split", "unified"] as const, "auto");
4968
+ }
4969
+
4958
4970
  function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
4959
4971
  if (!diff.lines.length) return false;
4960
- if (tw < SPLIT_MIN_WIDTH) return false;
4972
+ const layout = diffLayout();
4973
+ if (layout === "unified") return false;
4961
4974
  const nw = Math.max(2, String(maxLineNumber(diff.lines)).length);
4962
4975
  const half = Math.floor((tw - 1) / 2);
4963
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;
4964
4980
  const cw = Math.max(12, half - gw);
4965
4981
  if (cw < SPLIT_MIN_CODE_WIDTH) return false;
4966
4982
  const vis = diff.lines.slice(0, maxRows);
@@ -5557,15 +5573,71 @@ function getFirstChangedNewLine(diff: ParsedDiff): number {
5557
5573
  return 0;
5558
5574
  }
5559
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
+
5560
5584
  interface LocalizedEditDiff {
5561
5585
  diff: ParsedDiff;
5562
5586
  line: number;
5587
+ saved?: SavedEditPreview;
5563
5588
  }
5564
5589
 
5565
- 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> {
5566
5638
  if (!filePath || operations.length === 0) return null;
5567
5639
  try {
5568
- const rawContent = await readFileAsync(resolve(cwd, filePath), "utf8");
5640
+ const rawContent = savedPatch === undefined ? await readFileAsync(resolve(cwd, filePath), "utf8") : oldContentFromPatch(savedPatch, operations);
5569
5641
  const normalizedContent = normalizeToLf(stripBomText(rawContent));
5570
5642
  const normalizedOps = operations.map((edit) => ({ oldText: normalizeToLf(edit.oldText), newText: normalizeToLf(edit.newText) }));
5571
5643
  const baseContent = normalizedOps.some((edit) => findEditMatch(normalizedContent, edit.oldText).usedFuzzyMatch)
@@ -5590,7 +5662,9 @@ async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ o
5590
5662
  const oldStartLine = lineNumberAtIndex(baseContent, match.matchIndex);
5591
5663
  const newStartLine = oldStartLine + lineDelta;
5592
5664
  const diff = offsetParsedDiff(parseDiff(oldChunk, match.newText), oldStartLine - 1, newStartLine - 1);
5593
- 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 };
5594
5668
  lineDelta += countLineBreaks(match.newText) - countLineBreaks(oldChunk);
5595
5669
  }
5596
5670
  return localized.every(Boolean) ? (localized as LocalizedEditDiff[]) : null;
@@ -7686,6 +7760,13 @@ export default function (pi: ExtensionAPI) {
7686
7760
  });
7687
7761
 
7688
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
+ };
7689
7770
  pi.registerTool({
7690
7771
  name: "edit",
7691
7772
  label: "edit",
@@ -7696,11 +7777,14 @@ export default function (pi: ExtensionAPI) {
7696
7777
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7697
7778
  const fp = params.path ?? (params as any).file_path ?? "";
7698
7779
  const operations = getEditOperations(params);
7699
- const localizedDiffs = operations.length === 1 ? await computeLocalizedEditDiffs(fp, operations, cwd) : null;
7780
+ const localizedDiffs = await localizeEditOnce(toolCallId, fp, operations);
7781
+ EDIT_LOCALIZED.delete(toolCallId);
7700
7782
  const result = await editTool.execute(toolCallId, params, signal, onUpdate);
7701
7783
  if (operations.length === 0) return result;
7702
7784
  const { diffs, summary, totalLines, totalHunks } = summarizeEditOperations(operations);
7703
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;
7704
7788
  if (operations.length === 1) {
7705
7789
  const localized = localizedDiffs?.[0];
7706
7790
  const editLine = localized?.line ?? (typeof baseDetails.firstChangedLine === "number" ? baseDetails.firstChangedLine : 0);
@@ -7713,6 +7797,7 @@ export default function (pi: ExtensionAPI) {
7713
7797
  hunks: countDiffHunks(diff),
7714
7798
  added: diff?.added ?? 0,
7715
7799
  removed: diff?.removed ?? 0,
7800
+ preview,
7716
7801
  };
7717
7802
  return result;
7718
7803
  }
@@ -7725,6 +7810,7 @@ export default function (pi: ExtensionAPI) {
7725
7810
  hunks: totalHunks,
7726
7811
  totalAdded: diffs.reduce((sum, diff) => sum + diff.added, 0),
7727
7812
  totalRemoved: diffs.reduce((sum, diff) => sum + diff.removed, 0),
7813
+ preview,
7728
7814
  };
7729
7815
  return result;
7730
7816
  },
@@ -7735,7 +7821,9 @@ export default function (pi: ExtensionAPI) {
7735
7821
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
7736
7822
  syncToolCallStatus(ctx);
7737
7823
  const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
7738
- 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);
7739
7827
  const diffWidth = contextDiffWidth(ctx, 3);
7740
7828
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
7741
7829
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
@@ -7744,7 +7832,13 @@ export default function (pi: ExtensionAPI) {
7744
7832
  ctx.state._ptBody = theme.fg("muted", "(rendering…)");
7745
7833
  ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
7746
7834
  const lg = lang(fp);
7747
- 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>)
7748
7842
  .then((localizedDiffs) => {
7749
7843
  if (ctx.state._pk !== key) return;
7750
7844
  const diffs = localizedDiffs?.map((entry) => entry.diff) ?? fallbackDiffs;
@@ -7913,6 +8007,7 @@ export default function (pi: ExtensionAPI) {
7913
8007
  clearAllBashDurationContexts();
7914
8008
  clearRtkRewriteState();
7915
8009
  WRITE_EXISTED_BEFORE.clear();
8010
+ EDIT_LOCALIZED.clear();
7916
8011
  clearHighlightCache();
7917
8012
  invalidateThemePaletteCache();
7918
8013
  bumpToolBranchVisualEpoch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.87",
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",