@quandev104/pi-style 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/extensions/pi-style.js +568 -261
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
- package/extension-src/pi-style/features/messages/index.ts +95 -32
- package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
- package/extension-src/pi-style/features/startup/index.ts +4 -4
- package/extension-src/pi-style/features/tools/boxed/bash.ts +11 -19
- package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
- package/extension-src/pi-style/pi/session-coordinator.ts +9 -1
- package/extension-src/pi-style/shared/ansi.ts +3 -0
- package/extension-src/pi-style/shared/box.ts +175 -68
- package/extension-src/pi-style/shared/split-diff.ts +395 -86
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Component } from "@earendil-works/pi-tui";
|
|
7
7
|
import type { BoxTheme } from "../../shared/box.js";
|
|
8
|
-
import { boxBorder, boxInnerWidth,
|
|
8
|
+
import { boxBlankLine, boxBorder, boxInnerWidth, boxLabeledBorder, boxLine, boxWidth } from "../../shared/box.js";
|
|
9
9
|
|
|
10
10
|
export type MessageBlockOptions = {
|
|
11
11
|
kind: string;
|
|
@@ -18,7 +18,7 @@ export type MessageBlockOptions = {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
function formatMessageBlockTitle(theme: BoxTheme, kind: string, title?: string, icon = "➔"): string {
|
|
21
|
-
const rawTitle = title ? `${icon} ${kind}
|
|
21
|
+
const rawTitle = title ? `${icon} ${kind} · ${title}` : `${icon} ${kind}`;
|
|
22
22
|
const coloredTitle = theme.fg("accent", rawTitle);
|
|
23
23
|
return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
|
|
24
24
|
}
|
|
@@ -26,13 +26,17 @@ function formatMessageBlockTitle(theme: BoxTheme, kind: string, title?: string,
|
|
|
26
26
|
/**
|
|
27
27
|
* Render a boxed message block.
|
|
28
28
|
*
|
|
29
|
+
* The title is embedded in the rounded top border, the body sits between
|
|
30
|
+
* blank padding rows, and the expand hint (when present) is embedded at the
|
|
31
|
+
* right end of the bottom border — no inset dividers.
|
|
32
|
+
*
|
|
29
33
|
* Returns only border/content lines with foreground styling. Background is
|
|
30
34
|
* applied by the parent Box (all patched components extend Box with a
|
|
31
35
|
* customMessageBg bgFn), so this helper must NOT apply background itself —
|
|
32
36
|
* that would create a double-background conflict.
|
|
33
37
|
*/
|
|
34
38
|
export function renderBoxedMessageBlock(theme: BoxTheme, options: MessageBlockOptions): Component {
|
|
35
|
-
const { kind, title, right, body, icon = "➔",
|
|
39
|
+
const { kind, title, right, body, icon = "➔", cache: shouldCache = true } = options;
|
|
36
40
|
let cache: { width: number; lines: string[] } | null = null;
|
|
37
41
|
|
|
38
42
|
return {
|
|
@@ -45,27 +49,19 @@ export function renderBoxedMessageBlock(theme: BoxTheme, options: MessageBlockOp
|
|
|
45
49
|
const renderedWidth = boxWidth(width);
|
|
46
50
|
const contentWidth = boxInnerWidth(renderedWidth);
|
|
47
51
|
const titleLine = formatMessageBlockTitle(theme, kind, title, icon);
|
|
52
|
+
const bodyLines = body(contentWidth);
|
|
48
53
|
|
|
49
|
-
const lines: string[] = [
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
const lines: string[] = [
|
|
55
|
+
boxLabeledBorder(theme, "╭", "╮", titleLine, undefined, renderedWidth),
|
|
56
|
+
boxBlankLine(theme, renderedWidth),
|
|
57
|
+
...bodyLines.map((line) => boxLine(theme, line, renderedWidth)),
|
|
58
|
+
];
|
|
59
|
+
if (bodyLines.length > 0) lines.push(boxBlankLine(theme, renderedWidth));
|
|
52
60
|
if (right) {
|
|
53
|
-
|
|
54
|
-
lines.push(boxLineWithRight(theme, titleLine, rightStyled, renderedWidth));
|
|
61
|
+
lines.push(boxLabeledBorder(theme, "╰", "╯", "", theme.fg("dim", right), renderedWidth));
|
|
55
62
|
} else {
|
|
56
|
-
lines.push(
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const bodyLines = body(contentWidth);
|
|
60
|
-
const showDivider = hasDivider === "auto" ? bodyLines.length > 0 : hasDivider;
|
|
61
|
-
if (showDivider) {
|
|
62
|
-
lines.push(boxInsetDivider(theme, renderedWidth));
|
|
63
|
+
lines.push(boxBorder(theme, "╰", "╯", renderedWidth));
|
|
63
64
|
}
|
|
64
|
-
for (const line of bodyLines) {
|
|
65
|
-
lines.push(boxLine(theme, line, renderedWidth));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
lines.push(boxBorder(theme, "└", "┘", renderedWidth));
|
|
69
65
|
|
|
70
66
|
if (shouldCache) cache = { width, lines };
|
|
71
67
|
return lines;
|
|
@@ -12,22 +12,66 @@ function extractOscEnvelope(line: string): OscParts | undefined {
|
|
|
12
12
|
return { start: OSC133_ZONE_START, body: line.slice(OSC133_ZONE_START.length, bodyEnd), end: line.slice(bodyEnd) };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
const BG_RESET = "\x1b[49m";
|
|
16
|
+
|
|
17
|
+
/** Leading zero-width OSC sequences (e.g. OSC133 markers) of a line. */
|
|
18
|
+
function splitLeadingMarkers(line: string): { head: string; rest: string } {
|
|
16
19
|
let index = 0;
|
|
20
|
+
while (line.startsWith("\x1b]", index)) {
|
|
21
|
+
const bel = line.indexOf("\x07", index + 2);
|
|
22
|
+
const st = line.indexOf("\x1b\\", index + 2);
|
|
23
|
+
const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
|
|
24
|
+
if (end === -1) break;
|
|
25
|
+
index = end + 1;
|
|
26
|
+
}
|
|
27
|
+
return { head: line.slice(0, index), rest: line.slice(index) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Leading SGR escape sequence of a line ("" when none). */
|
|
31
|
+
function leadingSgr(line: string): string {
|
|
32
|
+
if (!line.startsWith("\x1b[")) return "";
|
|
33
|
+
let index = 2;
|
|
17
34
|
while (index < line.length) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (index < line.length) index++;
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
if (/\s/u.test(line[index] ?? "")) {
|
|
25
|
-
index++;
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
break;
|
|
35
|
+
const code = line.charCodeAt(index);
|
|
36
|
+
if (code >= 64 && code <= 126) return line.slice(0, index + 1);
|
|
37
|
+
index++;
|
|
29
38
|
}
|
|
30
|
-
return
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether an SGR sequence sets/resets the terminal background color. */
|
|
43
|
+
function isBackgroundSgr(sequence: string): boolean {
|
|
44
|
+
if (!sequence.startsWith("\x1b[") || !sequence.endsWith("m")) return false;
|
|
45
|
+
for (const code of sequence.slice(2, -1).split(";")) {
|
|
46
|
+
const value = Number(code);
|
|
47
|
+
if (value === 48 || value === 49) return true;
|
|
48
|
+
if (value >= 40 && value <= 47) return true;
|
|
49
|
+
if (value >= 100 && value <= 107) return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rebuild a native line so `lead` (prompt prefix / continuation indent) and the
|
|
56
|
+
* full target width are covered by the line's background.
|
|
57
|
+
*
|
|
58
|
+
* Native Box lines (user messages) are `bgAnsi + body + \x1b[49m`; prepending the
|
|
59
|
+
* prefix outside that wrap shifted the input row's background right by the
|
|
60
|
+
* prefix width while keeping the full container width, producing a staircase
|
|
61
|
+
* box (indented left, overflowing right). Rebuilding inside the wrap keeps the
|
|
62
|
+
* background flush across every row; plain (unwrapped) lines are padded to the
|
|
63
|
+
* target width instead so left/right edges stay aligned.
|
|
64
|
+
*/
|
|
65
|
+
function rebuildAtWidth(line: string, width: number, lead: string): string {
|
|
66
|
+
const { head, rest } = splitLeadingMarkers(line);
|
|
67
|
+
const bgAnsi = leadingSgr(rest);
|
|
68
|
+
if (bgAnsi && isBackgroundSgr(bgAnsi) && rest.endsWith(BG_RESET)) {
|
|
69
|
+
const body = rest.slice(bgAnsi.length, rest.length - BG_RESET.length);
|
|
70
|
+
const pad = " ".repeat(Math.max(0, width - visibleWidth(lead) - visibleWidth(body)));
|
|
71
|
+
return `${head}${bgAnsi}${lead}${body}${pad}${BG_RESET}`;
|
|
72
|
+
}
|
|
73
|
+
const padded = `${lead}${line}`;
|
|
74
|
+
return `${padded}${" ".repeat(Math.max(0, width - visibleWidth(padded)))}`;
|
|
31
75
|
}
|
|
32
76
|
|
|
33
77
|
function decorateMessageLine(
|
|
@@ -35,6 +79,7 @@ function decorateMessageLine(
|
|
|
35
79
|
index: number,
|
|
36
80
|
lastIndex: number,
|
|
37
81
|
contentIndex: number,
|
|
82
|
+
width: number,
|
|
38
83
|
options: {
|
|
39
84
|
firstEnvelope: OscParts | undefined;
|
|
40
85
|
firstHasStart: boolean;
|
|
@@ -44,14 +89,29 @@ function decorateMessageLine(
|
|
|
44
89
|
): string {
|
|
45
90
|
const { firstEnvelope, firstHasStart, multilineEnvelope, prefix } = options;
|
|
46
91
|
const prefixWidth = visibleWidth(prefix);
|
|
92
|
+
const lead = index === contentIndex ? prefix : index > contentIndex ? " ".repeat(prefixWidth) : "";
|
|
47
93
|
if (index === contentIndex && firstEnvelope)
|
|
48
|
-
return `${firstEnvelope.start}${
|
|
94
|
+
return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix)}${firstEnvelope.end}`;
|
|
49
95
|
if (index === contentIndex && firstHasStart)
|
|
50
|
-
return `${OSC133_ZONE_START}${
|
|
96
|
+
return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix)}`;
|
|
51
97
|
if (index === lastIndex && multilineEnvelope && index !== contentIndex)
|
|
52
|
-
return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${
|
|
53
|
-
|
|
54
|
-
|
|
98
|
+
return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth(
|
|
99
|
+
line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
|
|
100
|
+
width,
|
|
101
|
+
lead,
|
|
102
|
+
)}`;
|
|
103
|
+
if (
|
|
104
|
+
index === contentIndex &&
|
|
105
|
+
index === lastIndex &&
|
|
106
|
+
multilineEnvelope &&
|
|
107
|
+
line.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL)
|
|
108
|
+
)
|
|
109
|
+
return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth(
|
|
110
|
+
line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
|
|
111
|
+
width,
|
|
112
|
+
prefix,
|
|
113
|
+
)}`;
|
|
114
|
+
return rebuildAtWidth(line, width, lead);
|
|
55
115
|
}
|
|
56
116
|
|
|
57
117
|
function contentText(line: string): string {
|
|
@@ -88,23 +148,26 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
|
|
|
88
148
|
const first = nativeLines[0] ?? "";
|
|
89
149
|
const last = nativeLines.at(-1) ?? "";
|
|
90
150
|
const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
151
|
+
// The last line is a content-start candidate only when the envelope is
|
|
152
|
+
// single-line, or when no earlier line carries content. Assistant messages
|
|
153
|
+
// with a single content line render as a multiline envelope whose only body
|
|
154
|
+
// sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it
|
|
155
|
+
// would drop the prefix for every short assistant reply.
|
|
156
|
+
const firstContentIndex = nativeLines.findIndex((line, index) => {
|
|
157
|
+
if (index !== nativeLines.length - 1 || !multilineEnvelope) return hasContent(line);
|
|
158
|
+
return !nativeLines.slice(0, index).some((earlier) => hasContent(earlier)) && hasContent(line);
|
|
159
|
+
});
|
|
94
160
|
if (firstContentIndex < 0) return nativeLines;
|
|
95
161
|
const firstEnvelope = firstContentIndex === 0 ? extractOscEnvelope(first) : undefined;
|
|
96
162
|
const firstHasStart = firstContentIndex === 0 && first.startsWith(OSC133_ZONE_START);
|
|
97
|
-
const decorated = nativeLines.map((line, index) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (index > firstContentIndex && hasContent(line)) return `${" ".repeat(prefixWidth)}${line}`;
|
|
106
|
-
return line;
|
|
107
|
-
});
|
|
163
|
+
const decorated = nativeLines.map((line, index) =>
|
|
164
|
+
decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, width, {
|
|
165
|
+
firstEnvelope,
|
|
166
|
+
firstHasStart,
|
|
167
|
+
multilineEnvelope,
|
|
168
|
+
prefix,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
108
171
|
if (!decorated.every((line) => visibleWidth(line) <= width)) return undefined;
|
|
109
172
|
if (!nativeLines.every((line) => visibleWidth(line) <= bodyWidth)) return undefined;
|
|
110
173
|
return decorated;
|
|
@@ -106,7 +106,7 @@ function patchCompaction(instance: MessageBlockInstance, _original: () => void,
|
|
|
106
106
|
const block = renderBoxedMessageBlock(theme, {
|
|
107
107
|
kind: "Compaction",
|
|
108
108
|
title: `${tokenStr} tokens`,
|
|
109
|
-
...(expanded ? {} : { right:
|
|
109
|
+
...(expanded ? {} : { right: expandHint() }),
|
|
110
110
|
body,
|
|
111
111
|
icon: "⊟",
|
|
112
112
|
hasDivider: expanded,
|
|
@@ -132,7 +132,7 @@ function patchSkill(instance: MessageBlockInstance, _original: () => void, theme
|
|
|
132
132
|
const block = renderBoxedMessageBlock(theme, {
|
|
133
133
|
kind: "Skill",
|
|
134
134
|
title: skillName,
|
|
135
|
-
...(expanded ? {} : { right:
|
|
135
|
+
...(expanded ? {} : { right: expandHint() }),
|
|
136
136
|
body,
|
|
137
137
|
icon: "⊟",
|
|
138
138
|
hasDivider: expanded,
|
|
@@ -156,7 +156,7 @@ function patchBranch(instance: MessageBlockInstance, _original: () => void, them
|
|
|
156
156
|
|
|
157
157
|
const block = renderBoxedMessageBlock(theme, {
|
|
158
158
|
kind: "Branch",
|
|
159
|
-
...(expanded ? {} : { right:
|
|
159
|
+
...(expanded ? {} : { right: expandHint() }),
|
|
160
160
|
body,
|
|
161
161
|
icon: "⊟",
|
|
162
162
|
hasDivider: expanded,
|
|
@@ -193,7 +193,7 @@ function renderSystemContextPanel(
|
|
|
193
193
|
"─".repeat(pathWidth),
|
|
194
194
|
)}${divider}${resolved.apply("dim", "─".repeat(metricWidth))}`;
|
|
195
195
|
const lines = [
|
|
196
|
-
renderPanelBorder(resolved, "
|
|
196
|
+
renderPanelBorder(resolved, "╭", "╮", panelWidth),
|
|
197
197
|
renderPanelLine(resolved, titleLine, panelWidth),
|
|
198
198
|
renderPanelLine(resolved, header, panelWidth),
|
|
199
199
|
renderPanelLine(resolved, separator, panelWidth),
|
|
@@ -215,7 +215,7 @@ function renderSystemContextPanel(
|
|
|
215
215
|
),
|
|
216
216
|
);
|
|
217
217
|
}
|
|
218
|
-
lines.push(renderPanelBorder(resolved, "
|
|
218
|
+
lines.push(renderPanelBorder(resolved, "╰", "╯", panelWidth));
|
|
219
219
|
return lines;
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -269,7 +269,7 @@ function renderToolsPanel(resolved: ResolvedTheme, tools: readonly StartupToolIt
|
|
|
269
269
|
"─".repeat(countWidth),
|
|
270
270
|
)}${divider}${resolved.apply("dim", "─".repeat(toolsWidth))}`;
|
|
271
271
|
const lines = [
|
|
272
|
-
renderPanelBorder(resolved, "
|
|
272
|
+
renderPanelBorder(resolved, "╭", "╮", panelWidth),
|
|
273
273
|
renderPanelLine(resolved, titleLine, panelWidth),
|
|
274
274
|
renderPanelLine(resolved, header, panelWidth),
|
|
275
275
|
renderPanelLine(resolved, separator, panelWidth),
|
|
@@ -291,7 +291,7 @@ function renderToolsPanel(resolved: ResolvedTheme, tools: readonly StartupToolIt
|
|
|
291
291
|
),
|
|
292
292
|
);
|
|
293
293
|
}
|
|
294
|
-
lines.push(renderPanelBorder(resolved, "
|
|
294
|
+
lines.push(renderPanelBorder(resolved, "╰", "╯", panelWidth));
|
|
295
295
|
return lines;
|
|
296
296
|
}
|
|
297
297
|
|
|
@@ -18,7 +18,6 @@ import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../share
|
|
|
18
18
|
import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
|
|
19
19
|
import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
|
|
20
20
|
|
|
21
|
-
const MAX_BASH_PREVIEW_LINES = 5;
|
|
22
21
|
const MAX_LINE_CHARS = 2000;
|
|
23
22
|
const ESC = "\x1b";
|
|
24
23
|
const BASH_TOOL_NOTICE_PATTERN = /^\[Showing (?:last|lines)\b.*\. Full output: .+\]$/;
|
|
@@ -186,13 +185,17 @@ function renderBoxedBashResult(
|
|
|
186
185
|
inner: Component,
|
|
187
186
|
result: unknown,
|
|
188
187
|
context: BoxedToolContext,
|
|
188
|
+
expandHint?: string,
|
|
189
189
|
): Component {
|
|
190
190
|
const rawCommand = String(context?.args?.command ?? "...");
|
|
191
191
|
const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
|
|
192
192
|
return renderBoxedToolResult(theme, inner, {
|
|
193
193
|
widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
|
|
194
194
|
referenceLines,
|
|
195
|
-
footerLines: [
|
|
195
|
+
footerLines: [
|
|
196
|
+
formatBoxedFooter(theme, result as never, [`timeout ${formatTimeout(context)}`], getElapsed(context)),
|
|
197
|
+
],
|
|
198
|
+
...(expandHint ? { expandHint } : {}),
|
|
196
199
|
isError: context.isError,
|
|
197
200
|
isPartial: Boolean(context.isPartial),
|
|
198
201
|
});
|
|
@@ -207,7 +210,6 @@ function createBashResultPreview(
|
|
|
207
210
|
text: string,
|
|
208
211
|
options: { expanded: boolean },
|
|
209
212
|
color: "toolOutput" | "error",
|
|
210
|
-
extraLinesBefore: number = 0,
|
|
211
213
|
): Component {
|
|
212
214
|
let cacheKey = "";
|
|
213
215
|
let cacheLines: string[] | null = null;
|
|
@@ -226,7 +228,7 @@ function createBashResultPreview(
|
|
|
226
228
|
|
|
227
229
|
if (!expanded) {
|
|
228
230
|
// Collapsed: only process the tail of the output
|
|
229
|
-
const needed =
|
|
231
|
+
const needed = cfg.maxCollapsedLines;
|
|
230
232
|
let totalNewlines = 0;
|
|
231
233
|
let scanFrom = 0; // default: take full text if fewer than needed newlines
|
|
232
234
|
for (let i = text.length - 1; i >= 0; i--) {
|
|
@@ -262,18 +264,8 @@ function createBashResultPreview(
|
|
|
262
264
|
: formatToolOutputLine(theme, truncated, "text");
|
|
263
265
|
});
|
|
264
266
|
|
|
265
|
-
// Count remaining lines (lines before scanFrom)
|
|
266
|
-
const remaining = extraLinesBefore + (scanFrom > 0 ? countNewlines(text, 0, scanFrom) : 0);
|
|
267
|
-
|
|
268
|
-
if (remaining <= 0) {
|
|
269
|
-
cacheKey = cacheId;
|
|
270
|
-
cacheLines = truncatedShown;
|
|
271
|
-
return cacheLines;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const hint = safeTruncateToWidth(`... ${remaining} more lines, press Ctrl+o to expand`, bodyWidth, "…");
|
|
275
267
|
cacheKey = cacheId;
|
|
276
|
-
cacheLines =
|
|
268
|
+
cacheLines = truncatedShown;
|
|
277
269
|
return cacheLines;
|
|
278
270
|
}
|
|
279
271
|
|
|
@@ -323,7 +315,7 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
323
315
|
const outputColor = context.isError ? "error" : "toolOutput";
|
|
324
316
|
|
|
325
317
|
if (!options.expanded) {
|
|
326
|
-
const scanLines =
|
|
318
|
+
const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
|
|
327
319
|
let nlCount = 0;
|
|
328
320
|
let tailStart = 0;
|
|
329
321
|
for (let i = raw.length - 1; i >= 0; i--) {
|
|
@@ -337,11 +329,11 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
337
329
|
}
|
|
338
330
|
const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
|
|
339
331
|
const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
|
|
340
|
-
const inner = createBashResultPreview(theme, tail, options, outputColor
|
|
341
|
-
return renderBoxedBashResult(theme, inner, result, context);
|
|
332
|
+
const inner = createBashResultPreview(theme, tail, options, outputColor);
|
|
333
|
+
return renderBoxedBashResult(theme, inner, result, context, totalLinesBefore > 0 ? "Ctrl+O for more" : undefined);
|
|
342
334
|
}
|
|
343
335
|
const output = stripBashToolNoticeLines(stripAnsi(raw));
|
|
344
|
-
const inner = createBashResultPreview(theme, output, options, outputColor
|
|
336
|
+
const inner = createBashResultPreview(theme, output, options, outputColor);
|
|
345
337
|
return renderBoxedBashResult(theme, inner, result, context);
|
|
346
338
|
},
|
|
347
339
|
};
|
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
// (renderCall/renderResult only; no edit-core re-registration).
|
|
3
3
|
|
|
4
4
|
import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
6
5
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
7
|
-
import { getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
|
|
6
|
+
import { type BoxTheme, getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
|
|
7
|
+
import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
|
|
8
8
|
import {
|
|
9
|
+
AdaptiveDiffComponent,
|
|
9
10
|
buildSplitRows,
|
|
10
11
|
countDiffStats,
|
|
11
12
|
extractEditedPath,
|
|
12
13
|
firstText,
|
|
13
|
-
renderDiffMeter,
|
|
14
|
-
SplitDiffComponent,
|
|
15
14
|
} from "../../../shared/split-diff.js";
|
|
16
15
|
import {
|
|
17
16
|
type BoxedToolContext,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
displayPath,
|
|
20
19
|
noteExecutionStart,
|
|
21
20
|
resultFooterLines,
|
|
21
|
+
stateElapsedMs,
|
|
22
22
|
} from "./shared.js";
|
|
23
23
|
|
|
24
24
|
const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
|
|
@@ -26,11 +26,35 @@ const MAX_HIGHLIGHT_DIFF_ROWS = 120;
|
|
|
26
26
|
|
|
27
27
|
type EditResultDetails = { diff?: string; path?: string } | undefined;
|
|
28
28
|
|
|
29
|
+
/** `Diff · +3 -0` divider label. */
|
|
30
|
+
function diffDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
|
|
31
|
+
const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
|
|
32
|
+
const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
|
|
33
|
+
return `Diff · ${plus} ${minus}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
|
|
37
|
+
function editDiffFooter(
|
|
38
|
+
theme: BoxTheme,
|
|
39
|
+
result: { content?: readonly unknown[]; details?: unknown },
|
|
40
|
+
context: BoxedToolContext,
|
|
41
|
+
stats: { additions: number; removals: number },
|
|
42
|
+
): string {
|
|
43
|
+
const elapsedMs = getElapsedMs(result) ?? stateElapsedMs(context);
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
|
|
46
|
+
const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
|
|
47
|
+
const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
|
|
48
|
+
parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
|
|
49
|
+
return parts.join(theme.fg("dim", " · "));
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
export const editTool: BoxedToolDefinition = {
|
|
30
53
|
call(args, theme, context) {
|
|
31
54
|
noteExecutionStart(context);
|
|
32
55
|
const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
|
|
33
|
-
return renderBoxedToolCall(theme, "Edit", [
|
|
56
|
+
return renderBoxedToolCall(theme, "Edit", [], {
|
|
57
|
+
headerDetail: detail,
|
|
34
58
|
isError: Boolean(context.isError),
|
|
35
59
|
isPartial: Boolean(context.isPartial),
|
|
36
60
|
isPending: Boolean(context.isPartial),
|
|
@@ -71,40 +95,32 @@ export const editTool: BoxedToolDefinition = {
|
|
|
71
95
|
const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
|
|
72
96
|
const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
|
|
73
97
|
|
|
74
|
-
// Build
|
|
98
|
+
// Build diff rows + adaptive layout
|
|
75
99
|
const rows = buildSplitRows(diff);
|
|
76
100
|
const expanded = options.expanded;
|
|
77
101
|
const shouldHighlight =
|
|
78
102
|
Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
|
|
103
|
+
const stats = countDiffStats(diff);
|
|
79
104
|
|
|
80
|
-
//
|
|
81
|
-
const { additions, removals } = countDiffStats(diff);
|
|
82
|
-
const meter = renderDiffMeter(theme, additions, removals);
|
|
83
|
-
const summary =
|
|
84
|
-
`${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
|
|
85
|
-
` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
|
|
86
|
-
` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
|
|
87
|
-
` ${theme.fg("muted", "split")}` +
|
|
88
|
-
(meter ? ` ${meter}` : "");
|
|
89
|
-
|
|
90
|
-
// Render split-diff with syntax colors for small outputs.
|
|
105
|
+
// Render adaptive diff (unified/split per width) with syntax colors for small outputs.
|
|
91
106
|
const maxRows = expanded ? 160 : 36;
|
|
92
|
-
const
|
|
107
|
+
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
108
|
+
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
93
109
|
|
|
94
110
|
return renderBoxedToolResult(
|
|
95
111
|
theme,
|
|
96
112
|
{
|
|
97
113
|
render(width: number): string[] {
|
|
98
|
-
|
|
99
|
-
const headerLines = new Text(summary, 0, 0).render(safeWidth);
|
|
100
|
-
return [...headerLines, ...split.render(safeWidth)];
|
|
114
|
+
return diffView.render(width);
|
|
101
115
|
},
|
|
102
116
|
invalidate(): void {
|
|
103
|
-
|
|
117
|
+
diffView.invalidate();
|
|
104
118
|
},
|
|
105
119
|
},
|
|
106
120
|
{
|
|
107
|
-
|
|
121
|
+
dividerLabel: diffDividerLabel(theme, stats),
|
|
122
|
+
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
123
|
+
footerLines: [editDiffFooter(theme, result, context, stats)],
|
|
108
124
|
},
|
|
109
125
|
);
|
|
110
126
|
},
|
|
@@ -6,11 +6,12 @@ import type { BoxTheme, MetricResultLike } from "../../../shared/box.js";
|
|
|
6
6
|
import {
|
|
7
7
|
formatBoxedFooter,
|
|
8
8
|
formatToolName,
|
|
9
|
+
formatToolOutputLine,
|
|
9
10
|
formatToolParamLines,
|
|
10
11
|
getTextOutput,
|
|
11
12
|
renderBoxedToolCall,
|
|
12
13
|
renderBoxedToolResult,
|
|
13
|
-
|
|
14
|
+
selectRenderLines,
|
|
14
15
|
} from "../../../shared/box.js";
|
|
15
16
|
import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
|
|
16
17
|
import { type BoxedToolContext, noteExecutionStart } from "./shared.js";
|
|
@@ -43,20 +44,21 @@ export function renderFallbackResult(
|
|
|
43
44
|
const maxLines = expanded ? getToolsRenderConfig().maxExpandedLines : MAX_FALLBACK_PREVIEW_LINES;
|
|
44
45
|
const output = getTextOutput(result);
|
|
45
46
|
const elapsedMs = getStateElapsedMs(context.state);
|
|
47
|
+
const { lines, omitted } = selectRenderLines(output, maxLines);
|
|
46
48
|
|
|
47
49
|
return renderBoxedToolResult(
|
|
48
50
|
theme,
|
|
49
|
-
(
|
|
50
|
-
const body =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return body ? body.split("\n") : [];
|
|
51
|
+
() => {
|
|
52
|
+
const body = lines.map((line) => formatToolOutputLine(theme, line, isError ? "error" : "toolOutput"));
|
|
53
|
+
if (expanded && omitted > 0) {
|
|
54
|
+
body.push(theme.fg("muted", `… ${omitted} more lines omitted by render budget`));
|
|
55
|
+
}
|
|
56
|
+
return body;
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
footerLines: [formatBoxedFooter(theme, result, [], elapsedMs)],
|
|
59
60
|
renderLineBudget: maxLines,
|
|
61
|
+
...(expanded || omitted <= 0 ? {} : { expandHint: "Ctrl+O for more" }),
|
|
60
62
|
isError,
|
|
61
63
|
isPartial: Boolean(options.isPartial),
|
|
62
64
|
},
|