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 +10 -0
- package/README.md +2 -0
- package/extensions/index.ts +102 -7
- package/package.json +1 -1
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|