@quandev104/pi-style 0.2.7 → 0.2.10

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -2
  3. package/dist/extensions/pi-style.js +8900 -8464
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -0
  6. package/extension-src/pi-style/domain/config-normalization.ts +3 -0
  7. package/extension-src/pi-style/domain/config-types.ts +8 -0
  8. package/extension-src/pi-style/features/messages/index.ts +451 -37
  9. package/extension-src/pi-style/features/messages/special-blocks.ts +2 -22
  10. package/extension-src/pi-style/features/startup/index.ts +24 -4
  11. package/extension-src/pi-style/features/startup/logo.ts +22 -18
  12. package/extension-src/pi-style/features/tools/bash-execution.ts +24 -8
  13. package/extension-src/pi-style/features/tools/boxed/edit.ts +25 -30
  14. package/extension-src/pi-style/features/tools/boxed/git.ts +21 -13
  15. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +35 -38
  16. package/extension-src/pi-style/features/tools/boxed/shared.ts +34 -0
  17. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +114 -38
  18. package/extension-src/pi-style/features/tools/boxed/write.ts +1 -0
  19. package/extension-src/pi-style/features/tools/index.ts +10 -6
  20. package/extension-src/pi-style/pi/compatibility-coordinator.ts +2 -0
  21. package/extension-src/pi-style/pi/compatibility-probe.ts +88 -19
  22. package/extension-src/pi-style/pi/session-coordinator.ts +20 -1
  23. package/extension-src/pi-style/shared/ansi.ts +45 -0
  24. package/extension-src/pi-style/shared/box.ts +126 -47
  25. package/package.json +10 -9
@@ -46,13 +46,12 @@ interface MessageBlockInstance {
46
46
  expanded?: unknown;
47
47
  _expanded?: unknown;
48
48
  markdownTheme?: unknown;
49
- box?: { clear(): void; addChild(child: unknown): void; setBgFn?(fn: (text: string) => string): void };
49
+ box?: { clear(): void; addChild(child: unknown): void };
50
50
  customComponent?: unknown;
51
51
  customRenderer?: unknown;
52
52
  clear?(): void;
53
53
  addChild(child: unknown): void;
54
54
  removeChild(child: unknown): void;
55
- setBgFn?(fn: (text: string) => string): void;
56
55
  }
57
56
 
58
57
  const EXPAND_HINT = "Ctrl+O to expand";
@@ -77,16 +76,6 @@ function createMarkdownBody(
77
76
  return (contentWidth: number) => md.render(contentWidth);
78
77
  }
79
78
 
80
- /**
81
- * Neutralize the native customMessageBg fill for boxed blocks: the boxed block
82
- * owns its visual boundary, so the parent background is removed (the box sits
83
- * directly on the terminal/page background).
84
- */
85
- function neutralizeMessageBlockBackground(target: { setBgFn?(fn: (text: string) => string): void } | undefined): void {
86
- if (!target) return;
87
- if (typeof target.setBgFn === "function") target.setBgFn((text) => text);
88
- }
89
-
90
79
  function patchCompaction(instance: MessageBlockInstance, _original: () => void, theme: BoxTheme): boolean {
91
80
  const tokensBefore = instance.message?.tokensBefore;
92
81
  if (tokensBefore == null) return false;
@@ -97,12 +86,9 @@ function patchCompaction(instance: MessageBlockInstance, _original: () => void,
97
86
  const summary = typeof instance.message?.summary === "string" ? instance.message.summary : "";
98
87
  const markdownTheme = instance.markdownTheme as MarkdownTheme | undefined;
99
88
 
100
- neutralizeMessageBlockBackground(instance as { setBgFn?(fn: (text: string) => string): void });
101
-
102
89
  const body = expanded && summary && markdownTheme ? createMarkdownBody(summary, markdownTheme, theme) : () => [];
103
90
 
104
91
  const tokenStr = Number(tokensBefore).toLocaleString();
105
- neutralizeMessageBlockBackground(instance as { setBgFn?(fn: (text: string) => string): void });
106
92
  const block = renderBoxedMessageBlock(theme, {
107
93
  kind: "Compaction",
108
94
  title: `${tokenStr} tokens`,
@@ -125,8 +111,6 @@ function patchSkill(instance: MessageBlockInstance, _original: () => void, theme
125
111
  const content = typeof instance.skillBlock?.content === "string" ? instance.skillBlock.content : "";
126
112
  const markdownTheme = instance.markdownTheme as MarkdownTheme | undefined;
127
113
 
128
- neutralizeMessageBlockBackground(instance as { setBgFn?(fn: (text: string) => string): void });
129
-
130
114
  const body = expanded && content && markdownTheme ? createMarkdownBody(content, markdownTheme, theme) : () => [];
131
115
 
132
116
  const block = renderBoxedMessageBlock(theme, {
@@ -150,8 +134,6 @@ function patchBranch(instance: MessageBlockInstance, _original: () => void, them
150
134
  const summary = typeof instance.message?.summary === "string" ? instance.message.summary : "";
151
135
  const markdownTheme = instance.markdownTheme as MarkdownTheme | undefined;
152
136
 
153
- neutralizeMessageBlockBackground(instance as { setBgFn?(fn: (text: string) => string): void });
154
-
155
137
  const body = expanded && summary && markdownTheme ? createMarkdownBody(summary, markdownTheme, theme) : () => [];
156
138
 
157
139
  const block = renderBoxedMessageBlock(theme, {
@@ -185,9 +167,7 @@ function patchCustomMessage(instance: MessageBlockInstance, _original: () => voi
185
167
  }
186
168
  if (instance.box) instance.removeChild(instance.box);
187
169
 
188
- // The boxed shell owns its boundary; drop the native customMessageBg fill.
189
- neutralizeMessageBlockBackground(instance.box);
190
- neutralizeMessageBlockBackground(instance as { setBgFn?(fn: (text: string) => string): void });
170
+ // The boxed shell owns its boundary; the native customMessageBg fill stays.
191
171
 
192
172
  const rawCustomType = instance.message?.customType;
193
173
  const customType = typeof rawCustomType === "string" ? rawCustomType : "Custom";
@@ -2,8 +2,9 @@ import type { Component, OverlayHandle, OverlayOptions } from "@earendil-works/p
2
2
  import type { NormalizedPiStyleConfig } from "../../domain/config-types.js";
3
3
  import type { StatusSnapshot } from "../../domain/status.js";
4
4
  import { type ActiveTheme, type ResolvedTheme, resolveTheme } from "../../domain/theme.js";
5
- import { fitAnsiWidth, truncateAnsi, visibleWidth } from "../../shared/ansi.js";
6
- import { compactLogoHeader } from "./logo.js";
5
+ import { fitAnsiWidth, fitAnsiWidthTail, truncateAnsi, visibleWidth } from "../../shared/ansi.js";
6
+ import { shortenPath } from "../../shared/box.js";
7
+ import { compactLogoHeader, logoDetailWidth } from "./logo.js";
7
8
 
8
9
  export type StartupReason = "startup" | "reload" | "new" | "resume" | "fork";
9
10
 
@@ -302,6 +303,16 @@ const STARTUP_PADDING_TOP = 2;
302
303
  /** Blank rows below the block, separating it from the editor / chat. */
303
304
  const STARTUP_PADDING_BOTTOM = 2;
304
305
 
306
+ /**
307
+ * Startup heading title: the working directory of the session (home-contracted
308
+ * to `~`) — i.e. the path of the repo currently being worked in. Falls back to
309
+ * the directory basename, then the brand, when the runtime reports no cwd.
310
+ */
311
+ function startupProjectTitle(snapshot: StartupSnapshot): string {
312
+ if (snapshot.cwd) return shortenPath(snapshot.cwd);
313
+ return snapshot.project ?? "pi-style";
314
+ }
315
+
305
316
  function styledLines(
306
317
  theme: ActiveTheme,
307
318
  config: NormalizedPiStyleConfig,
@@ -319,13 +330,22 @@ function styledLines(
319
330
  // Breathing room above the block (separates it from the status line / terminal top).
320
331
  lines.push(...Array.from({ length: STARTUP_PADDING_TOP }, () => ""));
321
332
 
322
- const logoTitle = resolved.mode === "ascii" ? "pi-style" : `${resolved.glyph("pi")} pi-style`;
333
+ // Heading: the π glyph followed by the current project path (never the
334
+ // package name — the heading identifies WHERE you are, not what styles it).
335
+ // The path is tail-fitted so the repo name survives narrow terminals.
336
+ const asciiMode = resolved.mode === "ascii";
337
+ const glyph = resolved.glyph("pi");
338
+ const projectTitle = startupProjectTitle(snapshot);
339
+ const titleBudget = Math.max(0, logoDetailWidth(bodyWidth) - (asciiMode ? 0 : visibleWidth(glyph) + 1));
340
+ const fittedTitle = fitAnsiWidthTail(projectTitle, titleBudget, asciiMode ? "..." : "…");
341
+ const logoTitle = asciiMode ? fittedTitle : `${glyph} ${fittedTitle}`;
323
342
  lines.push(
324
343
  ...compactLogoHeader(
325
344
  resolved,
326
345
  [
327
346
  resolved.apply("accent", logoTitle),
328
- resolved.apply("muted", "/ commands · ! bash"),
347
+ resolved.apply("muted", "/ commands"),
348
+ resolved.apply("muted", "! bash"),
329
349
  resolved.apply("success", "● ready"),
330
350
  ],
331
351
  bodyWidth,
@@ -9,15 +9,14 @@ import { fitAnsiWidth, parseAnsiFgToRgb, visibleWidth } from "../../shared/ansi.
9
9
  */
10
10
 
11
11
  export const PI_LOGO_LINES = [
12
- "████████████╗",
13
- "████████████║",
14
- "████╔═══████║",
15
- "████║ ████║",
16
- "████████╬═══████╗",
17
- "████████║ ████║ ",
18
- "████╔═══╝ ████║",
19
- "████║ ████║",
20
- "╚═══╝ ╚═══╝",
12
+ "████████████",
13
+ "████████████",
14
+ "████ ████",
15
+ "████ ████",
16
+ "████████ ████",
17
+ "████████ ████",
18
+ "████ ████",
19
+ "████ ████",
21
20
  ] as const;
22
21
 
23
22
  const LOGO_PALETTE_STEPS = 24;
@@ -117,20 +116,25 @@ export function styledLogoLines(resolved: ResolvedTheme): string[] {
117
116
  return logoGradientCacheLines;
118
117
  }
119
118
 
119
+ /** Width of the side-detail column next to the block-art logo at a given content width. */
120
+ export function logoDetailWidth(width: number): number {
121
+ const logoWidth = Math.max(...PI_LOGO_LINES.map((line) => visibleWidth(line)));
122
+ return Math.max(0, width - logoWidth - visibleWidth(LOGO_GAP));
123
+ }
124
+
120
125
  /**
121
126
  * Assemble the compact startup header: gradient logo with side details when
122
127
  * wide enough, stacked logo + details next, and a minimal title/status pair
123
- * for very narrow terminals. Every returned line fits within `width`.
128
+ * for very narrow terminals. `details` is a column of detail rows — the first
129
+ * is the title, the last is the status, and the rows between are hints — each
130
+ * rendered on its own line, vertically centered beside the logo. Every
131
+ * returned line fits within `width`.
124
132
  */
125
- export function compactLogoHeader(
126
- resolved: ResolvedTheme,
127
- details: readonly [title: string, hints: string, status: string],
128
- width: number,
129
- ): string[] {
133
+ export function compactLogoHeader(resolved: ResolvedTheme, details: readonly string[], width: number): string[] {
130
134
  const logoLines = styledLogoLines(resolved);
131
- const logoWidth = Math.max(...PI_LOGO_LINES.map((line) => visibleWidth(line)));
132
135
  const safeWidth = Math.max(1, width);
133
- const detailWidth = safeWidth - logoWidth - visibleWidth(LOGO_GAP);
136
+ const logoWidth = Math.max(...PI_LOGO_LINES.map((line) => visibleWidth(line)));
137
+ const detailWidth = logoDetailWidth(safeWidth);
134
138
 
135
139
  if (detailWidth >= LOGO_SIDE_DETAIL_MIN_WIDTH) {
136
140
  const detailStartRow = Math.max(0, Math.floor((PI_LOGO_LINES.length - details.length) / 2));
@@ -147,5 +151,5 @@ export function compactLogoHeader(
147
151
  return [...logoLines, ...details.map((detail) => fitAnsiWidth(detail, safeWidth))];
148
152
  }
149
153
 
150
- return [details[0], details[2]].map((detail) => fitAnsiWidth(detail, safeWidth));
154
+ return [details[0] ?? "", details[details.length - 1] ?? ""].map((detail) => fitAnsiWidth(detail, safeWidth));
151
155
  }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { BoxTheme } from "../../shared/box.js";
15
15
  import {
16
+ applyBgTint,
16
17
  boxBlankLine,
17
18
  boxInnerWidth,
18
19
  boxLabeledBorder,
@@ -102,21 +103,36 @@ export function renderBashExecutionBox(instance: unknown, args: unknown[]): stri
102
103
  const cacheKey = `${themeCacheKey(theme)}|${width}|${host.status}|${host.exitCode ?? ""}|${host.command}`;
103
104
  if (host.status !== "running" && host.piStyleRenderCache?.key === cacheKey) return host.piStyleRenderCache.lines;
104
105
  const inner = boxInnerWidth(renderedWidth);
106
+ // The box owns its status tint (box ⇒ background) and, on failure, its
107
+ // frame color; the leading spacer line sits outside the frame and stays
108
+ // transparent.
109
+ const frameColor = host.status === "error" ? "error" : host.status === "cancelled" ? "warning" : undefined;
110
+ const bgName =
111
+ host.status === "running" ? "toolPendingBg" : host.status === "complete" ? "toolSuccessBg" : "toolErrorBg"; // error | cancelled
105
112
  // The native Text children render one leading padding space per line;
106
113
  // drop it so boxLine's own side padding produces symmetric borders.
107
114
  const wrapped = content
108
115
  .render(inner)
109
- .map((line) => boxLine(theme, line.startsWith(" ") ? line.slice(1) : line, renderedWidth));
110
- const lines = [
116
+ .map((line) => boxLine(theme, line.startsWith(" ") ? line.slice(1) : line, renderedWidth, frameColor));
117
+ const [spacer, ...boxLines] = [
111
118
  "",
112
- boxLabeledBorder(theme, TOP_LEFT, TOP_RIGHT, bashBoxTitle(theme, host), undefined, renderedWidth),
113
- boxBlankLine(theme, renderedWidth),
119
+ boxLabeledBorder(theme, TOP_LEFT, TOP_RIGHT, bashBoxTitle(theme, host), undefined, renderedWidth, frameColor),
120
+ boxBlankLine(theme, renderedWidth, frameColor),
114
121
  ...wrapped,
115
- boxBlankLine(theme, renderedWidth),
116
- boxLabeledBorder(theme, BOTTOM_LEFT, BOTTOM_RIGHT, bashBoxFooter(theme, host), undefined, renderedWidth),
122
+ boxBlankLine(theme, renderedWidth, frameColor),
123
+ boxLabeledBorder(
124
+ theme,
125
+ BOTTOM_LEFT,
126
+ BOTTOM_RIGHT,
127
+ bashBoxFooter(theme, host),
128
+ undefined,
129
+ renderedWidth,
130
+ frameColor,
131
+ ),
117
132
  ];
118
- if (host.status !== "running") host.piStyleRenderCache = { key: cacheKey, lines };
119
- return lines;
133
+ const tinted = [spacer ?? "", ...applyBgTint(theme, bgName, boxLines)];
134
+ if (host.status !== "running") host.piStyleRenderCache = { key: cacheKey, lines: tinted };
135
+ return tinted;
120
136
  } catch {
121
137
  return undefined;
122
138
  }
@@ -23,11 +23,14 @@ import { isResultSeen } from "./session-config.js";
23
23
  import {
24
24
  type BoxedToolContext,
25
25
  type BoxedToolDefinition,
26
+ clearDiffHeaderStats,
27
+ diffHeaderStatsSuffix,
26
28
  displayPath,
27
29
  getRenderCacheKey,
28
30
  memoizedStateComponent,
29
31
  noteBoxedCallState,
30
32
  noteBoxedResultPhase,
33
+ noteDiffHeaderStats,
31
34
  noteExecutionStart,
32
35
  resultFooterLines,
33
36
  stateElapsedMs,
@@ -46,36 +49,28 @@ const EMPTY_EDIT_RESULT: Component = Object.freeze({
46
49
 
47
50
  type EditResultDetails = { diff?: string; path?: string } | undefined;
48
51
 
49
- /** `Diff · +3 -0` divider label. */
50
- function diffDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
51
- const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
52
- const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
53
- return `Diff · ${plus} ${minus}`;
54
- }
55
-
56
- /** Edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
52
+ /** Edit footer: elapsed time only. The diff stats live in the box header and
53
+ * a single edited file is implied, so neither repeats in the footer. */
57
54
  function editDiffFooter(
58
55
  theme: BoxTheme,
59
56
  result: { content?: readonly unknown[]; details?: unknown },
60
57
  context: BoxedToolContext,
61
- stats: { additions: number; removals: number },
62
58
  ): string {
63
59
  const elapsedMs = getElapsedMs(result) ?? stateElapsedMs(context);
64
- const parts: string[] = [];
65
- if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
66
- const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
67
- const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
68
- parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
69
- return parts.join(theme.fg("dim", " · "));
60
+ return elapsedMs === undefined ? "" : theme.fg("text", formatElapsedMs(elapsedMs));
70
61
  }
71
62
 
72
63
  export const editTool: BoxedToolDefinition = {
73
64
  call(args, theme, context) {
74
65
  noteExecutionStart(context);
75
66
  noteBoxedCallState(context);
76
- const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
67
+ const path = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
77
68
  return renderBoxedToolCall(theme, "Edit", [], {
78
- headerDetail: detail,
69
+ // Lazy: the settled result publishes diff stats into the shared renderer
70
+ // state, and this function resolves at render time — so the header picks
71
+ // up `· +N -M` on the same paint the diff body appears (the write footer
72
+ // uses the same state-sharing contract).
73
+ headerDetail: () => `${path}${diffHeaderStatsSuffix(theme, context)}`,
79
74
  isError: Boolean(context.isError),
80
75
  isPartial: Boolean(context.isPartial),
81
76
  isPending: Boolean(context.isPartial),
@@ -98,6 +93,7 @@ export const editTool: BoxedToolDefinition = {
98
93
 
99
94
  // Handle errors
100
95
  if (context.isError) {
96
+ clearDiffHeaderStats(context);
101
97
  const output = getTextOutput(result);
102
98
  return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
103
99
  footerLines: resultFooterLines(theme, result, context),
@@ -110,6 +106,7 @@ export const editTool: BoxedToolDefinition = {
110
106
  const diff = details?.diff as string | undefined;
111
107
 
112
108
  if (!diff) {
109
+ clearDiffHeaderStats(context);
113
110
  const output = stripAnsi(getTextOutput(result)).trim();
114
111
  const fallback = `↳ ${output || "Edit applied"}`;
115
112
  return renderBoxedToolResult(theme, () => [theme.fg("dim", fallback)], {
@@ -122,21 +119,16 @@ export const editTool: BoxedToolDefinition = {
122
119
  const argPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
123
120
  const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
124
121
  const expanded = options.expanded;
125
- // Stats feed the footer, which is part of the cache key (cheap line scan —
126
- // unlike the row/component construction below, which must not run on hits).
122
+ // Stats feed the header slot and the cache key (cheap line scan — unlike
123
+ // the row/component construction below, which must not run on hits).
127
124
  const stats = countDiffStats(diff);
125
+ noteDiffHeaderStats(context, stats);
126
+ const footer = editDiffFooter(theme, result, context);
128
127
 
129
128
  return memoizedStateComponent(
130
129
  context.state,
131
130
  "__piStyleEditDiffResult",
132
- getRenderCacheKey(
133
- "edit-diff-result",
134
- theme,
135
- Boolean(expanded),
136
- diff,
137
- sourcePath ?? "",
138
- editDiffFooter(theme, result, context, stats),
139
- ),
131
+ getRenderCacheKey("edit-diff-result", theme, Boolean(expanded), diff, sourcePath ?? "", footer),
140
132
  () => {
141
133
  // Expensive construction (buildSplitRows + AdaptiveDiffComponent,
142
134
  // ~0.4ms for a 160-row diff) runs only on cache misses, never per
@@ -162,9 +154,12 @@ export const editTool: BoxedToolDefinition = {
162
154
  },
163
155
  },
164
156
  {
165
- dividerLabel: diffDividerLabel(theme, stats),
166
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
167
- footerLines: [editDiffFooter(theme, result, context, stats)],
157
+ // Stats live in the box header (`➔ Edit ✓ · path · +N -M`), so no
158
+ // `Diff` divider: the body continues the open call box directly.
159
+ showDivider: false,
160
+ skipLeadingBlank: true,
161
+ ...(expandHint ? { expandHint } : {}),
162
+ footerLines: footer ? [footer] : [],
168
163
  },
169
164
  );
170
165
  },
@@ -1840,21 +1840,24 @@ export function renderGitCardLines(
1840
1840
  // `renderBoxedToolResult` + the same `AdaptiveDiffComponent` `Edit` uses — no
1841
1841
  // second diff visual language (ADR 0005 / GIT-002). The Git header lives
1842
1842
  // outside the box (the call panel card); each file gets its own `╭…╰` frame
1843
- // with a `Diff · +N -M` divider and a `Ctrl+O more` expand hint when collapsed.
1843
+ // whose top border carries `path · +N -M` (no divider the stats live in the
1844
+ // header, exactly like `Edit`), with a `Ctrl+O more` expand hint on the bottom
1845
+ // border when collapsed.
1844
1846
 
1845
1847
  const GIT_DIFF_MAX_HIGHLIGHT_CHARS = 12000;
1846
1848
  const GIT_DIFF_MAX_HIGHLIGHT_ROWS = 120;
1847
1849
  const GIT_DIFF_MAX_ROWS_COLLAPSED = 36;
1848
1850
  const GIT_DIFF_MAX_ROWS_EXPANDED = 160;
1849
1851
 
1850
- function diffDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
1852
+ /** Colored `+N -M` stats fragment shared by diff frame headers. */
1853
+ function diffStatsFragment(theme: BoxTheme, stats: { additions: number; removals: number }): string {
1851
1854
  const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
1852
1855
  const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
1853
- return `Diff · ${plus} ${minus}`;
1856
+ return `${plus} ${minus}`;
1854
1857
  }
1855
1858
 
1856
- function fileBoxTopLabel(theme: BoxTheme, path: string): string {
1857
- const body = theme.fg("text", path);
1859
+ function fileBoxTopLabel(theme: BoxTheme, path: string, stats?: { additions: number; removals: number }): string {
1860
+ const body = stats ? `${theme.fg("text", path)} · ${diffStatsFragment(theme, stats)}` : theme.fg("text", path);
1858
1861
  return typeof theme?.bold === "function" ? theme.bold(body) : body;
1859
1862
  }
1860
1863
 
@@ -1928,7 +1931,9 @@ function buildGitDiffResultComponent(
1928
1931
 
1929
1932
  const footerParts: string[] = [];
1930
1933
  if (elapsedMs !== undefined) footerParts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
1931
- footerParts.push(theme.fg("dim", `${fileCount} ${pluralForm("file", fileCount)}`));
1934
+ // A single frame already implies one file — the count is only worth a footer
1935
+ // slot when the diff spans several.
1936
+ if (fileCount > 1) footerParts.push(theme.fg("dim", `${fileCount} ${pluralForm("file", fileCount)}`));
1932
1937
  const footer = footerParts.join(theme.fg("dim", " · "));
1933
1938
 
1934
1939
  const fileBoxes: DiffFileBox[] = [];
@@ -1942,18 +1947,19 @@ function buildGitDiffResultComponent(
1942
1947
  topLabel: fileBoxTopLabel(theme, parsed.show ? "Git show" : "Git diff"),
1943
1948
  resultComponent: renderBoxedToolResult(theme, () => [theme.fg("muted", "No changes")], {
1944
1949
  showDivider: false,
1950
+ skipLeadingBlank: true,
1945
1951
  footerLines: emptyFooter ? [emptyFooter] : [],
1946
1952
  }),
1947
1953
  });
1948
1954
  } else {
1949
1955
  for (const file of parsed.files) {
1950
- const topLabel = fileBoxTopLabel(theme, file.path);
1951
1956
  if (file.binary) {
1952
1957
  fileBoxes.push({
1953
- topLabel,
1958
+ topLabel: fileBoxTopLabel(theme, file.path),
1954
1959
  resultComponent: renderBoxedToolResult(theme, () => [binaryBodyLine(theme, file.status)], {
1955
- dividerLabel: "Binary",
1956
- footerLines: [footer],
1960
+ showDivider: false,
1961
+ skipLeadingBlank: true,
1962
+ footerLines: footer ? [footer] : [],
1957
1963
  }),
1958
1964
  });
1959
1965
  continue;
@@ -1968,10 +1974,12 @@ function buildGitDiffResultComponent(
1968
1974
  const view = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
1969
1975
  const expandHint = !expanded && view.hasCollapsed() ? "Ctrl+O more" : undefined;
1970
1976
  fileBoxes.push({
1971
- topLabel,
1977
+ topLabel: fileBoxTopLabel(theme, file.path, countDiffStats(file.body)),
1972
1978
  resultComponent: renderBoxedToolResult(theme, view, {
1973
- dividerLabel: diffDividerLabel(theme, countDiffStats(file.body)),
1974
- footerLines: [footer],
1979
+ // Stats live in the frame's top border (`path · +N -M`) — no divider.
1980
+ showDivider: false,
1981
+ skipLeadingBlank: true,
1982
+ footerLines: footer ? [footer] : [],
1975
1983
  ...(expandHint ? { expandHint } : {}),
1976
1984
  }),
1977
1985
  });
@@ -15,11 +15,14 @@ import { getStateElapsedMs, isResultSeen } from "./session-config.js";
15
15
  import {
16
16
  type BoxedToolContext,
17
17
  type BoxedToolDefinition,
18
+ clearDiffHeaderStats,
19
+ diffHeaderStatsSuffix,
18
20
  displayPath,
19
21
  getRenderCacheKey,
20
22
  memoizedStateComponent,
21
23
  noteBoxedCallState,
22
24
  noteBoxedResultPhase,
25
+ noteDiffHeaderStats,
23
26
  noteExecutionStart,
24
27
  stateElapsedMs,
25
28
  } from "./shared.js";
@@ -63,7 +66,12 @@ export function getQuickEditToolConfig(toolName: unknown): QuickEditToolConfig |
63
66
  return typeof toolName === "string" ? QUICK_EDIT_TOOLS[toolName] : undefined;
64
67
  }
65
68
 
66
- function extractQuickEditDiff(text: string): string | undefined {
69
+ /**
70
+ * Parse the `── diff ──` section of a quick-edit-family output text into a
71
+ * synthetic unified diff (exported for the turn-summary registry, which
72
+ * derives diff stats from session content without a renderer).
73
+ */
74
+ export function extractQuickEditDiff(text: string): string | undefined {
67
75
  const lines = stripAnsi(text).replace(/\r/g, "").split("\n");
68
76
  const start = lines.indexOf("── diff ──");
69
77
  if (start < 0) return undefined;
@@ -117,27 +125,15 @@ function extractQuickEditDiff(text: string): string | undefined {
117
125
  return diffLines.length > 0 ? diffLines.join("\n") : undefined;
118
126
  }
119
127
 
120
- /** `Diff · +3 -0` divider label. */
121
- function quickEditDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
122
- const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
123
- const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
124
- return `Diff · ${plus} ${minus}`;
125
- }
126
-
127
- /** Quick-edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
128
+ /** Quick-edit footer: elapsed time only. The diff stats live in the box
129
+ * header and a single edited file is implied, so neither repeats there. */
128
130
  function quickEditDiffFooter(
129
131
  theme: BoxTheme,
130
132
  result: { content?: readonly unknown[]; details?: unknown },
131
133
  context: BoxedToolContext,
132
- stats: { additions: number; removals: number },
133
134
  ): string {
134
135
  const elapsedMs = getElapsedMs(result) ?? getStateElapsedMs(context.state);
135
- const parts: string[] = [];
136
- if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
137
- const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
138
- const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
139
- parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
140
- return parts.join(theme.fg("dim", " · "));
136
+ return elapsedMs === undefined ? "" : theme.fg("text", formatElapsedMs(elapsedMs));
141
137
  }
142
138
 
143
139
  function renderQuickEditResult(
@@ -160,38 +156,36 @@ function renderQuickEditResult(
160
156
 
161
157
  const output = getTextOutput(result);
162
158
  if (context.isError) {
159
+ clearDiffHeaderStats(context);
160
+ const footer = quickEditFooter(theme, context);
163
161
  return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
164
- footerLines: [quickEditFooter(theme, context)],
162
+ ...(footer ? { footerLines: [footer] } : {}),
165
163
  isError: true,
166
164
  });
167
165
  }
168
166
 
169
167
  const diff = extractQuickEditDiff(output);
170
168
  if (!diff) {
169
+ clearDiffHeaderStats(context);
171
170
  const fallback = stripAnsi(output).trim() || config.fallbackLabel;
171
+ const footer = quickEditFooter(theme, context);
172
172
  return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", fallback)}`], {
173
- footerLines: [quickEditFooter(theme, context)],
173
+ ...(footer ? { footerLines: [footer] } : {}),
174
174
  });
175
175
  }
176
176
 
177
177
  const expanded = options.expanded;
178
178
  const argPath = String(context?.args?.path ?? "");
179
- // Stats feed the footer, which is part of the cache key (cheap line scan —
180
- // unlike the row/component construction below, which must not run on hits).
179
+ // Stats feed the header slot and the cache key (cheap line scan — unlike
180
+ // the row/component construction below, which must not run on hits).
181
181
  const stats = countDiffStats(diff);
182
+ noteDiffHeaderStats(context, stats);
183
+ const footer = quickEditDiffFooter(theme, result, context);
182
184
 
183
185
  return memoizedStateComponent(
184
186
  context.state,
185
187
  "__piStyleQuickEditDiffResult",
186
- getRenderCacheKey(
187
- "quick-edit-diff-result",
188
- theme,
189
- config.toolLabel,
190
- Boolean(expanded),
191
- diff,
192
- argPath,
193
- quickEditDiffFooter(theme, result, context, stats),
194
- ),
188
+ getRenderCacheKey("quick-edit-diff-result", theme, config.toolLabel, Boolean(expanded), diff, argPath, footer),
195
189
  () => {
196
190
  // Expensive construction (buildSplitRows + AdaptiveDiffComponent) runs
197
191
  // only on cache misses, never per render pass. Everything below is a
@@ -216,9 +210,12 @@ function renderQuickEditResult(
216
210
  },
217
211
  },
218
212
  {
219
- dividerLabel: quickEditDividerLabel(theme, stats),
220
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
221
- footerLines: [quickEditDiffFooter(theme, result, context, stats)],
213
+ // Stats live in the box header (`➔ Quick Edit ✓ · path · +N -M`),
214
+ // so no `Diff` divider: the body continues the open call box directly.
215
+ showDivider: false,
216
+ skipLeadingBlank: true,
217
+ ...(expandHint ? { expandHint } : {}),
218
+ footerLines: footer ? [footer] : [],
222
219
  },
223
220
  );
224
221
  },
@@ -227,10 +224,7 @@ function renderQuickEditResult(
227
224
 
228
225
  function quickEditFooter(theme: BoxTheme, context: BoxedToolContext): string {
229
226
  const elapsedMs = getStateElapsedMs(context.state);
230
- const parts: string[] = [];
231
- if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
232
- parts.push(theme.fg("dim", "1 file"));
233
- return parts.join(theme.fg("dim", " · "));
227
+ return elapsedMs === undefined ? "" : theme.fg("text", formatElapsedMs(elapsedMs));
234
228
  }
235
229
 
236
230
  export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition {
@@ -238,9 +232,12 @@ export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition
238
232
  call(args, theme, context) {
239
233
  noteExecutionStart(context);
240
234
  noteBoxedCallState(context);
241
- const detail = displayPath(String(args?.path ?? ""), context);
235
+ const path = displayPath(String(args?.path ?? ""), context);
242
236
  return renderBoxedToolCall(theme, config.toolLabel, [], {
243
- headerDetail: detail,
237
+ // Lazy: the settled result publishes diff stats into the shared renderer
238
+ // state, and this function resolves at render time — so the header picks
239
+ // up `· +N -M` on the same paint the diff body appears.
240
+ headerDetail: () => `${path}${diffHeaderStatsSuffix(theme, context)}`,
244
241
  isError: Boolean(context.isError),
245
242
  isPartial: Boolean(context.isPartial),
246
243
  isPending: Boolean(context.isPartial),
@@ -142,6 +142,40 @@ export function stateElapsedMs(context: BoxedToolContext): number | undefined {
142
142
  return getStateElapsedMs(context.state);
143
143
  }
144
144
 
145
+ /** State slot a diff result renderer publishes its stats into so the call
146
+ * renderer can append them to the box header (`path · +3 -0`) on the same
147
+ * paint. One slot suffices: renderer state is per tool call, and a call never
148
+ * renders two diffs. */
149
+ const DIFF_HEADER_STATS_KEY = "__piStyleDiffHeaderStats";
150
+
151
+ /** Publish diff stats for the call header (called by settled result renderers). */
152
+ export function noteDiffHeaderStats(context: BoxedToolContext, stats: { additions: number; removals: number }): void {
153
+ context.state[DIFF_HEADER_STATS_KEY] = { additions: stats.additions, removals: stats.removals };
154
+ }
155
+
156
+ /** Drop published diff stats (error / no-diff results keep the header clean). */
157
+ export function clearDiffHeaderStats(context: BoxedToolContext): void {
158
+ delete context.state[DIFF_HEADER_STATS_KEY];
159
+ }
160
+
161
+ /** Colored `+N -M` diff stats pair: diff colors when nonzero, dim zeros. */
162
+ export function formatDiffStatsPair(theme: BoxTheme, additions: number, removals: number): string {
163
+ const plus = additions > 0 ? theme.fg("toolDiffAdded", `+${additions}`) : theme.fg("dim", "+0");
164
+ const minus = removals > 0 ? theme.fg("toolDiffRemoved", `-${removals}`) : theme.fg("dim", "-0");
165
+ return `${plus} ${minus}`;
166
+ }
167
+
168
+ /** ` · +3 -0` header suffix with diff colors, or "" while no stats are
169
+ * published (pending call / error result). */
170
+ export function diffHeaderStatsSuffix(theme: BoxTheme, context: BoxedToolContext): string {
171
+ const stats = context.state[DIFF_HEADER_STATS_KEY] as { additions?: unknown; removals?: unknown } | undefined;
172
+ if (!stats || typeof stats !== "object") return "";
173
+ const additions = Number(stats.additions);
174
+ const removals = Number(stats.removals);
175
+ if (!Number.isFinite(additions) || !Number.isFinite(removals)) return "";
176
+ return ` · ${formatDiffStatsPair(theme, additions, removals)}`;
177
+ }
178
+
145
179
  /** Footer parts with state-based elapsed when result.details lacks timing. */
146
180
  export function boxedFooterWithState(
147
181
  theme: BoxTheme,