@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.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/dist/extensions/pi-style.js +8900 -8464
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-normalization.ts +3 -0
- package/extension-src/pi-style/domain/config-types.ts +8 -0
- package/extension-src/pi-style/features/messages/index.ts +451 -37
- package/extension-src/pi-style/features/messages/special-blocks.ts +2 -22
- package/extension-src/pi-style/features/startup/index.ts +24 -4
- package/extension-src/pi-style/features/startup/logo.ts +22 -18
- package/extension-src/pi-style/features/tools/bash-execution.ts +24 -8
- package/extension-src/pi-style/features/tools/boxed/edit.ts +25 -30
- package/extension-src/pi-style/features/tools/boxed/git.ts +21 -13
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +35 -38
- package/extension-src/pi-style/features/tools/boxed/shared.ts +34 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +114 -38
- package/extension-src/pi-style/features/tools/boxed/write.ts +1 -0
- package/extension-src/pi-style/features/tools/index.ts +10 -6
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +2 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +88 -19
- package/extension-src/pi-style/pi/session-coordinator.ts +20 -1
- package/extension-src/pi-style/shared/ansi.ts +45 -0
- package/extension-src/pi-style/shared/box.ts +126 -47
- 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
|
|
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;
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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[
|
|
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
|
|
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(
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
67
|
+
const path = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
|
|
77
68
|
return renderBoxedToolCall(theme, "Edit", [], {
|
|
78
|
-
|
|
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
|
|
126
|
-
//
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1956
|
-
|
|
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
|
-
|
|
1974
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
121
|
-
|
|
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
|
-
|
|
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: [
|
|
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: [
|
|
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
|
|
180
|
-
//
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
235
|
+
const path = displayPath(String(args?.path ?? ""), context);
|
|
242
236
|
return renderBoxedToolCall(theme, config.toolLabel, [], {
|
|
243
|
-
|
|
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,
|