pi-claude-code-ui 1.0.87 → 1.0.89

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,29 @@
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.89 — 2026-10-19
7
+
8
+ ### Fixed
9
+
10
+ - **Direct MCP tools show what they did** - `mcp__<server>__<tool>` rows showed only `MCP status` until expanded. They now show `server:tool` and the main argument (for example `MCP subagents:Agent Debug NaN`), and grouped headers list the tool names (`Agent, Ctx Reduce`) instead of `Mcp Subagents Agent`.
11
+ - **Running tool rows no longer draw their header twice** - the first pending blink invalidated the row while Pi was still building it, which left the call line in the row twice until the next redraw (until the result arrived on expanded rows). Affected every tool, not only MCP.
12
+ - **Theme switches repaint everything** - after `/theme` or a light/dark switch, diff previews kept the old diff palette, rows inside tool groups kept the old colors, and the spinner verb/status kept the old theme until restart. All three now follow the new theme.
13
+ - **MCP output uses the same dim color as other tools** - MCP running previews and expanded output were drawn in `toolOutput`, brighter than read, bash, grep and other tools.
14
+
15
+ ### Performance
16
+
17
+ - **Each diff preview renders once** - since 1.0.80 edit, write and apply_patch rows started their Shiki render at a guessed width and rendered again once the real row width was known. On resume every diff row paid twice. Rows now wait for their width: half the diff renders, and the first frame of a 60-row tool history dropped from about 37 ms to 8 ms in the benchmark.
18
+
19
+ ## 1.0.88 — 2026-10-19
20
+
21
+ ### Added
22
+
23
+ - **`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.
24
+
25
+ ### Fixed
26
+
27
+ - **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.
28
+
6
29
  ## 1.0.87 — 2026-10-19
7
30
 
8
31
  ### 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
 
@@ -89,6 +89,8 @@ const PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-container-render");
89
89
  const TOOL_RENDER_CACHE = Symbol.for("pi-claude-style-tools:tool-render-cache");
90
90
  const COMPONENT_PARENT = Symbol.for("pi-claude-style-tools:component-parent");
91
91
  const PARENT_TRACKING_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-parent-tracking");
92
+ const TOOL_UPDATE_DISPLAY_ACTIVE = Symbol("pi-claude-style-tools:update-display-active");
93
+ const TOOL_UPDATE_DISPLAY_RERUN = Symbol("pi-claude-style-tools:update-display-rerun");
92
94
  const TOOL_CACHE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-cache-invalidation");
93
95
  const TOOL_MOUSE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-fullscreen-mouse");
94
96
  const FULLSCREEN_HOVER_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-fullscreen-hover");
@@ -124,6 +126,8 @@ interface SettingsFile {
124
126
  liveToolPreviewLines?: number;
125
127
  showTruncationHints?: boolean;
126
128
  diffCollapsedLines?: number;
129
+ /** Diff layout: "auto" (default) picks split only when it fits cleanly; "split" and "unified" force one layout. */
130
+ diffLayout?: "auto" | "split" | "unified";
127
131
  diffTheme?: string;
128
132
  diffColors?: Record<string, string>;
129
133
  /**
@@ -613,7 +617,7 @@ function getGroupedToolName(tools: any[]): string | undefined {
613
617
 
614
618
  function getToolGroupLabel(tools: any[]): string {
615
619
  const sameName = getGroupedToolName(tools);
616
- return sameName ? humanizeToolName(sameName) : "Multiple Tools";
620
+ return sameName ? toolDisplayName(sameName) : "Multiple Tools";
617
621
  }
618
622
 
619
623
  function getToolGroupOverallStatus(tools: any[]): ToolStatus {
@@ -681,7 +685,7 @@ function formatToolNameList(tools: any[]): string {
681
685
  counts.set(name, (counts.get(name) ?? 0) + 1);
682
686
  }
683
687
  return [...counts.entries()]
684
- .map(([name, count]) => `${humanizeToolName(name)}${count > 1 ? `×${count}` : ""}`)
688
+ .map(([name, count]) => `${toolDisplayName(name)}${count > 1 ? `×${count}` : ""}`)
685
689
  .join(", ");
686
690
  }
687
691
 
@@ -761,6 +765,7 @@ function getToolArgSummary(tool: any): string {
761
765
  if (name === "grep") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
762
766
  if (name === "find") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
763
767
  if (name === "ls") return shortPath(process.cwd(), args.path ?? ".");
768
+ if (parseNamespacedMcpToolName(name)) return summarizeDirectMcpArgs(args);
764
769
  return summarizeText(getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || name, 72);
765
770
  }
766
771
 
@@ -771,7 +776,7 @@ function getToolCallLine(tool: any): string {
771
776
  return line.replaceAll(WRAP_MARK, "").replaceAll(CLIP_MARK, "");
772
777
  }
773
778
  const summary = getToolArgSummary(tool);
774
- const label = humanizeToolName(getToolName(tool));
779
+ const label = toolDisplayName(getToolName(tool));
775
780
  return `${label}${summary ? ` ${summary}` : ""}`;
776
781
  }
777
782
 
@@ -915,6 +920,7 @@ class ToolGroupComponent extends Container {
915
920
  private cachedStatusKey?: string;
916
921
  private cachedToolCount?: number;
917
922
  private cachedLines?: string[];
923
+ private paletteFingerprint?: string;
918
924
 
919
925
  private clearRenderCache(): void {
920
926
  this.dirty = true;
@@ -984,6 +990,15 @@ class ToolGroupComponent extends Container {
984
990
  // only; children keep their own ToolText/TOOL_RENDER_CACHE entries and
985
991
  // recompute only when their content actually changes.
986
992
  this.clearRenderCache();
993
+ // Exception: a theme switch (Pi calls ui.invalidate()) changes colors the
994
+ // child renderers baked into their text, so those must re-run once.
995
+ // (Child invalidation re-enters here via clearToolRenderCache; the palette is
996
+ // recorded first so that re-entry is a no-op.)
997
+ const palette = currentThemePaletteFingerprint();
998
+ if (palette === undefined || palette === this.paletteFingerprint) return;
999
+ const changed = this.paletteFingerprint !== undefined;
1000
+ this.paletteFingerprint = palette;
1001
+ if (changed) for (const tool of this.tools) tool?.invalidate?.();
987
1002
  }
988
1003
 
989
1004
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
@@ -1009,6 +1024,8 @@ class ToolGroupComponent extends Container {
1009
1024
  }
1010
1025
  const safeWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
1011
1026
  // Fast path: settled groups with a valid memo skip ALL child walks.
1027
+ // Remember the palette this group was drawn with (see invalidate()).
1028
+ if (this.paletteFingerprint === undefined) this.paletteFingerprint = currentThemePaletteFingerprint();
1012
1029
  // Child mutations mark dirty via clearToolRenderCache → invalidate().
1013
1030
  if (
1014
1031
  !this.dirty
@@ -2965,6 +2982,11 @@ function syncLiveToolRenderState(component: any): void {
2965
2982
  state._liveLineCount = collectNonEmptyLines(raw, 0).total;
2966
2983
  } else if (component?.isPartial !== true) {
2967
2984
  delete state._liveLineCount;
2985
+ // renderCall never sees the result, so hand a finished edit's details over: its diff is redrawn from them, not from
2986
+ // the file. A call aborted while streaming ends with a bare error instead of a toolResult; it never drew a diff.
2987
+ if (component?.toolName === "edit" && component.result && (!ctxLike.isError || component.result.role === "toolResult")) {
2988
+ state._editResult = component.result.details ?? {};
2989
+ }
2968
2990
  }
2969
2991
  }
2970
2992
 
@@ -2987,7 +3009,7 @@ function patchToolRenderCacheInvalidation(): void {
2987
3009
  for (const method of methods) {
2988
3010
  const original = proto[method];
2989
3011
  if (typeof original !== "function") continue;
2990
- proto[method] = function patchedToolMutation(...args: any[]) {
3012
+ const run = function patchedToolMutation(this: any, ...args: any[]) {
2991
3013
  clearToolRenderCache(this);
2992
3014
  if (method === "updateDisplay" || method === "updateResult" || method === "invalidate") {
2993
3015
  syncLiveToolRenderState(this);
@@ -2996,6 +3018,28 @@ function patchToolRenderCacheInvalidation(): void {
2996
3018
  clearToolRenderCache(this);
2997
3019
  return result;
2998
3020
  };
3021
+ proto[method] = method !== "updateDisplay" ? run : function patchedToolUpdateDisplay(this: any, ...args: any[]) {
3022
+ // A renderer can invalidate its own row while updateDisplay is building it
3023
+ // (the first pending blink registration does). Pi's updateDisplay clears the
3024
+ // content box and re-adds regions, so a nested pass leaves the call/result
3025
+ // regions twice in the box until the next redraw. Defer it to one rerun.
3026
+ if (this[TOOL_UPDATE_DISPLAY_ACTIVE]) {
3027
+ this[TOOL_UPDATE_DISPLAY_RERUN] = true;
3028
+ return undefined;
3029
+ }
3030
+ this[TOOL_UPDATE_DISPLAY_ACTIVE] = true;
3031
+ try {
3032
+ let result = run.apply(this, args);
3033
+ if (this[TOOL_UPDATE_DISPLAY_RERUN]) {
3034
+ this[TOOL_UPDATE_DISPLAY_RERUN] = false;
3035
+ result = run.apply(this, args);
3036
+ }
3037
+ return result;
3038
+ } finally {
3039
+ this[TOOL_UPDATE_DISPLAY_ACTIVE] = false;
3040
+ this[TOOL_UPDATE_DISPLAY_RERUN] = false;
3041
+ }
3042
+ };
2999
3043
  }
3000
3044
 
3001
3045
  proto[TOOL_CACHE_PATCH_FLAG] = true;
@@ -3269,6 +3313,7 @@ function fileExistsForTool(cwd: string, filePath: string): boolean {
3269
3313
  }
3270
3314
 
3271
3315
  const WRITE_EXISTED_BEFORE = new Map<string, boolean>();
3316
+ const EDIT_LOCALIZED = new Map<string, Promise<LocalizedEditDiff[] | null>>();
3272
3317
 
3273
3318
  interface RtkRewriteRecord {
3274
3319
  original: string;
@@ -3780,6 +3825,9 @@ class ToolText extends Text {
3780
3825
  private toolCachedLines?: string[];
3781
3826
  private observedWidth?: number;
3782
3827
  private pendingObservedWidth?: number;
3828
+ getObservedWidth(): number | undefined {
3829
+ return this.observedWidth;
3830
+ }
3783
3831
  private widthObserver?: (width: number) => void;
3784
3832
  private widthObserverScheduled = false;
3785
3833
 
@@ -4145,6 +4193,13 @@ function themeBgRgb(theme: any, key: string): Rgb | null {
4145
4193
  let _themePaletteCacheTheme: unknown = null;
4146
4194
  let _themePaletteCacheName: string | null = null;
4147
4195
  let _themePaletteCacheFingerprint: string | null = null;
4196
+ // Pi passes the same global theme Proxy everywhere, so keeping the last one lets
4197
+ // non-render code (group invalidation) read the current palette.
4198
+ let _lastSeenTheme: unknown = null;
4199
+
4200
+ function currentThemePaletteFingerprint(): string | undefined {
4201
+ return _lastSeenTheme ? themePaletteFingerprint(_lastSeenTheme) : undefined;
4202
+ }
4148
4203
 
4149
4204
  /** Resolved-color fingerprint so palette re-derives when the active theme file changes under the same name/object. */
4150
4205
  function themePaletteFingerprint(theme: any): string {
@@ -4559,6 +4614,7 @@ function resetThemePalette(): void {
4559
4614
 
4560
4615
  function applyThemePaletteIfNeeded(theme: any): void {
4561
4616
  if (!theme) return;
4617
+ _lastSeenTheme = theme;
4562
4618
  if (!themeAdaptiveEnabled()) {
4563
4619
  applyToolBranchColor(theme);
4564
4620
  syncOutlineChromeFromBranch(theme);
@@ -4749,10 +4805,26 @@ function branchDiffWidth(componentWidth?: number, chromeWidth = 2): number {
4749
4805
  return Math.max(20, Math.min(width - chromeWidth, MAX_TERM_WIDTH));
4750
4806
  }
4751
4807
 
4808
+ // Diff preview cache keys also carry _toolBranchVisualEpoch, which applyThemePaletteIfNeeded
4809
+ // bumps when the theme palette changes: otherwise rows rendered before a /theme or light/dark
4810
+ // switch kept the old diff backgrounds and stat colors while new rows used the new ones.
4752
4811
  function contextDiffWidth(ctx: any, chromeWidth = 2): number {
4753
4812
  return branchDiffWidth(ctx.state?._diffComponentWidth, chromeWidth);
4754
4813
  }
4755
4814
 
4815
+ // Diff previews are laid out for the row's real width, which is only known after the
4816
+ // row's text component has rendered once. Starting the async Shiki render before that
4817
+ // used the termW() guess and rendered every diff a second time once the real width
4818
+ // arrived (on resume: every edit/write row, twice). Wait for the width instead; the
4819
+ // width observer invalidates the row as soon as it is known.
4820
+ function diffWidthReady(ctx: any): boolean {
4821
+ if (typeof ctx.state?._diffComponentWidth === "number") return true;
4822
+ const seen = ctx.lastComponent instanceof ToolText ? ctx.lastComponent.getObservedWidth() : undefined;
4823
+ if (seen === undefined || !ctx.state) return false;
4824
+ ctx.state._diffComponentWidth = seen;
4825
+ return true;
4826
+ }
4827
+
4756
4828
  function adaptiveWrapRows(tw?: number): number {
4757
4829
  const width = tw ?? termW();
4758
4830
  if (width >= 180) return MAX_WRAP_ROWS_WIDE;
@@ -4955,12 +5027,20 @@ function maxLineNumber(lines: DiffLine[]): number {
4955
5027
  return max;
4956
5028
  }
4957
5029
 
5030
+ function diffLayout(): "auto" | "split" | "unified" {
5031
+ return getMode(readSettings().diffLayout, ["auto", "split", "unified"] as const, "auto");
5032
+ }
5033
+
4958
5034
  function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
4959
5035
  if (!diff.lines.length) return false;
4960
- if (tw < SPLIT_MIN_WIDTH) return false;
5036
+ const layout = diffLayout();
5037
+ if (layout === "unified") return false;
4961
5038
  const nw = Math.max(2, String(maxLineNumber(diff.lines)).length);
4962
5039
  const half = Math.floor((tw - 1) / 2);
4963
5040
  const gw = nw + 5;
5041
+ // Forced split still needs room for both code columns; below that it would overflow the row.
5042
+ if (layout === "split") return half - gw >= 12;
5043
+ if (tw < SPLIT_MIN_WIDTH) return false;
4964
5044
  const cw = Math.max(12, half - gw);
4965
5045
  if (cw < SPLIT_MIN_CODE_WIDTH) return false;
4966
5046
  const vis = diff.lines.slice(0, maxRows);
@@ -5557,15 +5637,71 @@ function getFirstChangedNewLine(diff: ParsedDiff): number {
5557
5637
  return 0;
5558
5638
  }
5559
5639
 
5640
+ // Where an edit's diff starts in the old and the new file, plus the text it replaced if that is not oldText verbatim
5641
+ // (a fuzzy match). Saved with the result: once the edit has run, the file no longer holds oldText to find these again.
5642
+ interface SavedEditPreview {
5643
+ oldLine: number;
5644
+ newLine: number;
5645
+ oldText?: string;
5646
+ }
5647
+
5560
5648
  interface LocalizedEditDiff {
5561
5649
  diff: ParsedDiff;
5562
5650
  line: number;
5651
+ saved?: SavedEditPreview;
5563
5652
  }
5564
5653
 
5565
- async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ oldText: string; newText: string }>, cwd: string): Promise<LocalizedEditDiff[] | null> {
5654
+ function restoreLocalizedEditDiffs(saved: unknown, operations: Array<{ oldText: string; newText: string }>): LocalizedEditDiff[] | null {
5655
+ if (!Array.isArray(saved) || saved.length !== operations.length) return null;
5656
+ return operations.map((edit, i) => {
5657
+ const { oldLine, newLine, oldText } = saved[i] as SavedEditPreview;
5658
+ const diff = offsetParsedDiff(parseDiff(oldText ?? normalizeToLf(edit.oldText), normalizeToLf(edit.newText)), oldLine - 1, newLine - 1);
5659
+ return { diff, line: getFirstChangedNewLine(diff) };
5660
+ });
5661
+ }
5662
+
5663
+ // Results saved before SavedEditPreview existed: the patch Pi saves with every edit result still holds the old lines
5664
+ // around each change, at their old line numbers. Lines it does not hold can match nothing.
5665
+ function oldContentFromPatch(patch: string, operations: Array<{ oldText: string; newText: string }>): string {
5666
+ const known: string[] = [];
5667
+ const changes: number[] = [];
5668
+ for (const hunk of Diff.parsePatch(patch)[0]?.hunks ?? []) {
5669
+ let at = hunk.oldStart - 1;
5670
+ for (const line of hunk.lines) {
5671
+ if (line[0] === "-" || line[0] === "+") changes.push(at);
5672
+ if (line[0] === " " || line[0] === "-") known[at++] = line.slice(1);
5673
+ }
5674
+ }
5675
+ // The patch keeps 4 lines around a change. An oldText reaching further supplies the missing lines itself, but only if
5676
+ // it fits in exactly one place touching a change: a chance fit elsewhere would put the edit on the wrong line.
5677
+ const fuzzy = known.map(normalizeTextForFuzzyMatch);
5678
+ for (const { oldText } of operations) {
5679
+ const own = normalizeToLf(oldText).split("\n");
5680
+ const want = normalizeTextForFuzzyMatch(normalizeToLf(oldText)).split("\n");
5681
+ const last = want.length - 1;
5682
+ const starts = new Set<number>();
5683
+ for (const change of changes) {
5684
+ for (let at = Math.max(0, change - want.length); at <= change; at++) starts.add(at);
5685
+ }
5686
+ const fits = [...starts].filter((at) => {
5687
+ let overlaps = false;
5688
+ return want.every((line, i) => {
5689
+ const have = fuzzy[at + i];
5690
+ if (have === undefined) return true;
5691
+ overlaps = true;
5692
+ return i === 0 ? have.endsWith(line) : i === last ? have.startsWith(line) : have === line;
5693
+ }) && overlaps;
5694
+ });
5695
+ // An empty first or last piece is only the line break before or after oldText, not a line of its own.
5696
+ if (fits.length === 1) own.forEach((line, i) => { if (line || (i > 0 && i < last)) known[fits[0] + i] ??= line; });
5697
+ }
5698
+ return `${Array.from(known, (line) => line ?? "\u0000").join("\n")}\n`;
5699
+ }
5700
+
5701
+ async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ oldText: string; newText: string }>, cwd: string, savedPatch?: string): Promise<LocalizedEditDiff[] | null> {
5566
5702
  if (!filePath || operations.length === 0) return null;
5567
5703
  try {
5568
- const rawContent = await readFileAsync(resolve(cwd, filePath), "utf8");
5704
+ const rawContent = savedPatch === undefined ? await readFileAsync(resolve(cwd, filePath), "utf8") : oldContentFromPatch(savedPatch, operations);
5569
5705
  const normalizedContent = normalizeToLf(stripBomText(rawContent));
5570
5706
  const normalizedOps = operations.map((edit) => ({ oldText: normalizeToLf(edit.oldText), newText: normalizeToLf(edit.newText) }));
5571
5707
  const baseContent = normalizedOps.some((edit) => findEditMatch(normalizedContent, edit.oldText).usedFuzzyMatch)
@@ -5590,7 +5726,9 @@ async function computeLocalizedEditDiffs(filePath: string, operations: Array<{ o
5590
5726
  const oldStartLine = lineNumberAtIndex(baseContent, match.matchIndex);
5591
5727
  const newStartLine = oldStartLine + lineDelta;
5592
5728
  const diff = offsetParsedDiff(parseDiff(oldChunk, match.newText), oldStartLine - 1, newStartLine - 1);
5593
- localized[match.editIndex] = { diff, line: getFirstChangedNewLine(diff) };
5729
+ const saved: SavedEditPreview = { oldLine: oldStartLine, newLine: newStartLine };
5730
+ if (oldChunk !== normalizedOps[match.editIndex].oldText) saved.oldText = oldChunk;
5731
+ localized[match.editIndex] = { diff, line: getFirstChangedNewLine(diff), saved };
5594
5732
  lineDelta += countLineBreaks(match.newText) - countLineBreaks(oldChunk);
5595
5733
  }
5596
5734
  return localized.every(Boolean) ? (localized as LocalizedEditDiff[]) : null;
@@ -5939,6 +6077,18 @@ function isMcpToolName(name: string): boolean {
5939
6077
  return name === "mcp" || /^mcp[_:-]/i.test(name) || /[_:-]mcp[_:-]/i.test(name);
5940
6078
  }
5941
6079
 
6080
+ // Direct MCP tools are registered as `mcp__<server>__<tool>`; their args are the
6081
+ // tool's own args, not the gateway's `{ tool, server, ... }`.
6082
+ function parseNamespacedMcpToolName(name: string): { server: string; tool: string } | undefined {
6083
+ const match = /^mcp__(.+?)__(.+)$/.exec(name);
6084
+ return match ? { server: match[1], tool: match[2] } : undefined;
6085
+ }
6086
+
6087
+ function toolDisplayName(name: string): string {
6088
+ const direct = parseNamespacedMcpToolName(name);
6089
+ return humanizeToolName(direct ? direct.tool : name);
6090
+ }
6091
+
5942
6092
  function shouldUseGenericToolRenderer(name: unknown): boolean {
5943
6093
  return typeof name === "string" && name.length > 0 && !CORE_TOOL_OVERRIDES.has(name);
5944
6094
  }
@@ -6445,8 +6595,11 @@ function renderApplyPatchCall(args: any, theme: Theme, ctx: any, sp: (path: stri
6445
6595
  }
6446
6596
  ctx.state._openAiPatchFiles = preview.changes.map((change) => change.displayPath);
6447
6597
 
6598
+ if (!diffWidthReady(ctx)) {
6599
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, `${hdr}\n${withBranch(theme.fg("muted", "(rendering…)"), theme, false, true)}`);
6600
+ }
6448
6601
  const diffWidth = contextDiffWidth(ctx);
6449
- const key = `apply-preview:${ctx.state._applyPatchMetaKey ?? hashText(patchText)}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
6602
+ const key = `apply-preview:${ctx.state._applyPatchMetaKey ?? hashText(patchText)}:${diffWidth}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
6450
6603
  if (ctx.state._applyPatchPreviewKey !== key) {
6451
6604
  ctx.state._applyPatchPreviewKey = key;
6452
6605
  ctx.state._applyPatchPreviewBody = theme.fg("muted", "(rendering…)");
@@ -6529,7 +6682,37 @@ function renderApplyPatchResult(result: any, isPartial: boolean, theme: Theme, c
6529
6682
  return makeText(ctx.lastComponent, withBranch(`${theme.fg("success", "Applied")} ${meta.changeCount} files ${summary}${meta.totalLines ? ` ${theme.fg("muted", `(${meta.totalLines} diff lines)`)}` : ""}`, theme));
6530
6683
  }
6531
6684
 
6532
- function summarizeMcpToolCall(args: any, theme: Theme): string {
6685
+ const DIRECT_MCP_SUMMARY_KEYS = [
6686
+ "description", "subject", "title", "query", "path", "file_path", "url", "command", "name",
6687
+ "agent_id", "agentId", "taskId", "task_id", "threadId", "drop", "target", "message", "prompt", "task",
6688
+ ] as const;
6689
+
6690
+ function summarizeArgValue(value: unknown): string {
6691
+ if (typeof value === "string") return value.trim();
6692
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
6693
+ if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number")) return value.join(", ");
6694
+ return "";
6695
+ }
6696
+
6697
+ function summarizeDirectMcpArgs(args: any): string {
6698
+ if (!args || typeof args !== "object") return "";
6699
+ for (const key of DIRECT_MCP_SUMMARY_KEYS) {
6700
+ const value = summarizeArgValue(args[key]);
6701
+ if (value) return summarizeText(value, 72);
6702
+ }
6703
+ for (const [key, raw] of Object.entries(args)) {
6704
+ const value = summarizeArgValue(raw);
6705
+ if (value) return summarizeText(`${key}: ${value}`, 72);
6706
+ }
6707
+ return "";
6708
+ }
6709
+
6710
+ function summarizeMcpToolCall(name: string, args: any, theme: Theme): string {
6711
+ const direct = parseNamespacedMcpToolName(name);
6712
+ if (direct) {
6713
+ const detail = summarizeDirectMcpArgs(args);
6714
+ return `${direct.server}:${direct.tool}${detail ? ` ${theme.fg("muted", detail)}` : ""}`;
6715
+ }
6533
6716
  const tool = getStringArg(args, "tool");
6534
6717
  if (tool) return args?.server ? `${args.server}:${tool}` : tool;
6535
6718
  const connect = getStringArg(args, "connect");
@@ -6540,14 +6723,14 @@ function summarizeMcpToolCall(args: any, theme: Theme): string {
6540
6723
  }
6541
6724
 
6542
6725
  function summarizeGenericToolCall(name: string, args: any, theme: Theme, sp: (path: string) => string): string {
6543
- if (isMcpToolName(name)) return summarizeMcpToolCall(args, theme);
6726
+ if (isMcpToolName(name)) return summarizeMcpToolCall(name, args, theme);
6544
6727
  return summarizeOpenAiToolCall(name, args, theme, sp);
6545
6728
  }
6546
6729
 
6547
6730
  function renderMcpToolResult(result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text {
6548
6731
  if (isPartial) {
6549
6732
  return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", "MCP running..."), expanded, theme, ctx, {
6550
- styleLine: (line) => theme.fg("toolOutput", line || " "),
6733
+ styleLine: (line) => theme.fg("dim", line || " "),
6551
6734
  }));
6552
6735
  }
6553
6736
  clearBlinkTimer(ctx);
@@ -6571,7 +6754,7 @@ function renderMcpToolResult(result: any, expanded: boolean, isPartial: boolean,
6571
6754
  theme,
6572
6755
  previewLimit(),
6573
6756
  lines.length,
6574
- (line) => theme.fg(ctx.isError ? "error" : "toolOutput", line || " "),
6757
+ (line) => theme.fg(ctx.isError ? "error" : "dim", line || " "),
6575
6758
  );
6576
6759
  return makeText(ctx.lastComponent, withBranch(`${statusText}\n${preview}`, theme));
6577
6760
  }
@@ -7631,10 +7814,14 @@ export default function (pi: ExtensionAPI) {
7631
7814
  if (d?._type === "diff") {
7632
7815
  const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
7633
7816
  const hunks = d.diff?.lines?.filter((l: any) => l.type === "sep").length + (d.diff?.lines?.length ? 1 : 0);
7817
+ if (!diffWidthReady(ctx)) {
7818
+ const pendingSummary = diffSummaryWithMeta(d.diff.added, d.diff.removed, hunks, "");
7819
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(`${pendingSummary}\n${theme.fg("muted", "rendering diff…")}`, theme));
7820
+ }
7634
7821
  const diffWidth = contextDiffWidth(ctx);
7635
7822
  const mode = shouldUseSplit(d.diff, diffWidth, previewLines) ? "split" : "unified";
7636
7823
  const richSummary = diffSummaryWithMeta(d.diff.added, d.diff.removed, hunks, mode);
7637
- const key = `wd:${diffWidth}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}:${ctx.expanded ? 1 : 0}`;
7824
+ const key = `wd:${diffWidth}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
7638
7825
  if (ctx.state._wdk !== key) {
7639
7826
  ctx.state._wdk = key;
7640
7827
  ctx.state._wdt = withFinalBranchBlock(`${richSummary}\n${theme.fg("muted", "rendering diff…")}`, theme);
@@ -7661,8 +7848,11 @@ export default function (pi: ExtensionAPI) {
7661
7848
  const syntheticDiff = getCachedParsedDiff(ctx, `nf-diff:${d.filePath}:${contentHash}`, "", content);
7662
7849
  const richSummary = diffSummaryWithMeta(syntheticDiff.added, 0, 1, "new file");
7663
7850
  const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
7851
+ if (!diffWidthReady(ctx)) {
7852
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(`${richSummary}\n${theme.fg("muted", "rendering diff…")}`, theme));
7853
+ }
7664
7854
  const diffWidth = contextDiffWidth(ctx);
7665
- const pk = `nf:${d.filePath}:${contentHash}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
7855
+ const pk = `nf:${d.filePath}:${contentHash}:${diffWidth}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
7666
7856
  if (ctx.state._nfk !== pk) {
7667
7857
  ctx.state._nfk = pk;
7668
7858
  ctx.state._nft = withFinalBranchBlock(`${richSummary}\n${theme.fg("muted", "rendering diff…")}`, theme);
@@ -7686,6 +7876,13 @@ export default function (pi: ExtensionAPI) {
7686
7876
  });
7687
7877
 
7688
7878
  const editTool = createEditTool(cwd);
7879
+ // The live preview and execute() share one look at the file, taken before the edit runs: what the row shows live is
7880
+ // then exactly what is saved with the result, and so what it shows again after a resume.
7881
+ const localizeEditOnce = (toolCallId: string, filePath: string, operations: Array<{ oldText: string; newText: string }>) => {
7882
+ let localized = EDIT_LOCALIZED.get(toolCallId);
7883
+ if (!localized) EDIT_LOCALIZED.set(toolCallId, localized = computeLocalizedEditDiffs(filePath, operations, cwd));
7884
+ return localized;
7885
+ };
7689
7886
  pi.registerTool({
7690
7887
  name: "edit",
7691
7888
  label: "edit",
@@ -7696,11 +7893,14 @@ export default function (pi: ExtensionAPI) {
7696
7893
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7697
7894
  const fp = params.path ?? (params as any).file_path ?? "";
7698
7895
  const operations = getEditOperations(params);
7699
- const localizedDiffs = operations.length === 1 ? await computeLocalizedEditDiffs(fp, operations, cwd) : null;
7896
+ const localizedDiffs = await localizeEditOnce(toolCallId, fp, operations);
7897
+ EDIT_LOCALIZED.delete(toolCallId);
7700
7898
  const result = await editTool.execute(toolCallId, params, signal, onUpdate);
7701
7899
  if (operations.length === 0) return result;
7702
7900
  const { diffs, summary, totalLines, totalHunks } = summarizeEditOperations(operations);
7703
7901
  const baseDetails = (((result as any).details ?? {}) as Record<string, unknown>);
7902
+ // null: the edits could not be located, and the row showed (and keeps showing) the diff without file line numbers.
7903
+ const preview = localizedDiffs?.map((entry) => entry.saved) ?? null;
7704
7904
  if (operations.length === 1) {
7705
7905
  const localized = localizedDiffs?.[0];
7706
7906
  const editLine = localized?.line ?? (typeof baseDetails.firstChangedLine === "number" ? baseDetails.firstChangedLine : 0);
@@ -7713,6 +7913,7 @@ export default function (pi: ExtensionAPI) {
7713
7913
  hunks: countDiffHunks(diff),
7714
7914
  added: diff?.added ?? 0,
7715
7915
  removed: diff?.removed ?? 0,
7916
+ preview,
7716
7917
  };
7717
7918
  return result;
7718
7919
  }
@@ -7725,6 +7926,7 @@ export default function (pi: ExtensionAPI) {
7725
7926
  hunks: totalHunks,
7726
7927
  totalAdded: diffs.reduce((sum, diff) => sum + diff.added, 0),
7727
7928
  totalRemoved: diffs.reduce((sum, diff) => sum + diff.removed, 0),
7929
+ preview,
7728
7930
  };
7729
7931
  return result;
7730
7932
  },
@@ -7735,16 +7937,28 @@ export default function (pi: ExtensionAPI) {
7735
7937
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
7736
7938
  syncToolCallStatus(ctx);
7737
7939
  const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
7738
- if (!(ctx.argsComplete && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
7940
+ // History replay never sets argsComplete; a finished edit is drawn from its result instead.
7941
+ const finished = ctx.state._editResult as Record<string, unknown> | undefined;
7942
+ if (!((ctx.argsComplete || finished) && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
7943
+ if (!diffWidthReady(ctx)) {
7944
+ const pending = liveBranchDisplay(ctx.state, theme) ?? indentBranchBlock(withBranch(theme.fg("muted", "(rendering…)"), theme, false, true));
7945
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, `${hdr}\n${pending}`);
7946
+ }
7739
7947
  const diffWidth = contextDiffWidth(ctx, 3);
7740
- const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
7948
+ const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
7741
7949
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
7742
7950
  if (ctx.state._pk !== key) {
7743
7951
  ctx.state._pk = key;
7744
7952
  ctx.state._ptBody = theme.fg("muted", "(rendering…)");
7745
7953
  ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
7746
7954
  const lg = lang(fp);
7747
- void computeLocalizedEditDiffs(fp, operations, cwd)
7955
+ // Located once per row: expanding or resizing must not look in the file again, it no longer holds oldText.
7956
+ ctx.state._editLocalized ??= !finished
7957
+ ? localizeEditOnce(ctx.toolCallId, fp, operations)
7958
+ : "preview" in finished
7959
+ ? Promise.resolve(restoreLocalizedEditDiffs(finished.preview, operations))
7960
+ : computeLocalizedEditDiffs(fp, operations, cwd, typeof finished.patch === "string" ? finished.patch : "");
7961
+ void (ctx.state._editLocalized as Promise<LocalizedEditDiff[] | null>)
7748
7962
  .then((localizedDiffs) => {
7749
7963
  if (ctx.state._pk !== key) return;
7750
7964
  const diffs = localizedDiffs?.map((entry) => entry.diff) ?? fallbackDiffs;
@@ -7913,6 +8127,7 @@ export default function (pi: ExtensionAPI) {
7913
8127
  clearAllBashDurationContexts();
7914
8128
  clearRtkRewriteState();
7915
8129
  WRITE_EXISTED_BEFORE.clear();
8130
+ EDIT_LOCALIZED.clear();
7916
8131
  clearHighlightCache();
7917
8132
  invalidateThemePaletteCache();
7918
8133
  bumpToolBranchVisualEpoch();
@@ -73,7 +73,6 @@ function themeAdaptiveEnabled(): boolean {
73
73
  const _DEFAULT_CLAUDE_ORANGE = "\x1b[38;2;215;119;87m";
74
74
  const _DEFAULT_STATUS_DIM = "\x1b[38;2;153;153;153m";
75
75
 
76
- let _themeColorsCacheTheme: unknown = null;
77
76
  let _themeColorsLastAdaptive: boolean | null = null;
78
77
  let _themeColorsLastVerbKey: string | null = null;
79
78
  let _themeColorsLastStatusKey: string | null = null;
@@ -105,7 +104,6 @@ function applyThemeColors(theme: any): void {
105
104
  _themeColorsLastAdaptive = adaptive;
106
105
  _themeColorsLastVerbKey = verbColor;
107
106
  _themeColorsLastStatusKey = statusColor;
108
- _themeColorsCacheTheme = null;
109
107
  if (!adaptive) {
110
108
  CLAUDE_ORANGE = _DEFAULT_CLAUDE_ORANGE;
111
109
  STATUS_DIM = _DEFAULT_STATUS_DIM;
@@ -113,9 +111,9 @@ function applyThemeColors(theme: any): void {
113
111
  }
114
112
 
115
113
  if (!theme || !adaptive) return;
116
- if (_themeColorsCacheTheme === theme) return;
117
- _themeColorsCacheTheme = theme;
118
-
114
+ // ctx.ui.theme is Pi's global theme Proxy: the same object before and after
115
+ // /theme or a light/dark switch, so an identity cache never refreshed the
116
+ // spinner colors. Resolving is two map lookups per tick; just do it.
119
117
  const verb = resolveThemeColor(theme, verbColor, "accent");
120
118
  if (verb) CLAUDE_ORANGE = verb;
121
119
  const status = resolveThemeColor(theme, statusColor, "muted");
@@ -792,8 +790,7 @@ export default function (pi: ExtensionAPI) {
792
790
  }
793
791
  // Re-derive colors on every tick so /cc-spinner verb/status changes
794
792
  // take effect within ~250 ms without waiting for the next pi event.
795
- // applyThemeColors is identity-cached on (theme, verbKey, statusKey) so
796
- // this is cheap when nothing changed.
793
+ // applyThemeColors only re-resolves two theme colors, so this is cheap.
797
794
  applyThemeColors(activeCtx.ui?.theme);
798
795
  const nextMessage = buildWorkingMessage();
799
796
  if (!force && nextMessage === lastWorkingMessage) return;
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.89",
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",