@quandev104/pi-style 0.1.1
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 +35 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/extensions/pi-style.js +7897 -0
- package/dist/extensions/pi-style.js.map +1 -0
- package/extension-src/pi-style/app/command-service.ts +244 -0
- package/extension-src/pi-style/app/commands.ts +12 -0
- package/extension-src/pi-style/app/config-storage.ts +98 -0
- package/extension-src/pi-style/app/doctor.ts +53 -0
- package/extension-src/pi-style/app/index.ts +322 -0
- package/extension-src/pi-style/app/providers.ts +268 -0
- package/extension-src/pi-style/app/render-scheduler.ts +48 -0
- package/extension-src/pi-style/app/runtime.ts +404 -0
- package/extension-src/pi-style/app/snapshot.ts +15 -0
- package/extension-src/pi-style/domain/capabilities.ts +34 -0
- package/extension-src/pi-style/domain/config-authorization.ts +20 -0
- package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
- package/extension-src/pi-style/domain/config-diff.ts +30 -0
- package/extension-src/pi-style/domain/config-migrations.ts +52 -0
- package/extension-src/pi-style/domain/config-normalization.ts +567 -0
- package/extension-src/pi-style/domain/config-presets.ts +51 -0
- package/extension-src/pi-style/domain/config-types.ts +115 -0
- package/extension-src/pi-style/domain/providers.ts +37 -0
- package/extension-src/pi-style/domain/status-presets.ts +60 -0
- package/extension-src/pi-style/domain/status-renderer.ts +142 -0
- package/extension-src/pi-style/domain/status.ts +430 -0
- package/extension-src/pi-style/domain/theme.ts +232 -0
- package/extension-src/pi-style/features/.gitkeep +0 -0
- package/extension-src/pi-style/features/editor/index.ts +304 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
- package/extension-src/pi-style/features/messages/index.ts +145 -0
- package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
- package/extension-src/pi-style/features/startup/index.ts +564 -0
- package/extension-src/pi-style/features/startup/logo.ts +151 -0
- package/extension-src/pi-style/features/status-line/index.ts +319 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
- package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
- package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
- package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
- package/extension-src/pi-style/features/tools/index.ts +480 -0
- package/extension-src/pi-style/pi/commands.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
- package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
- package/extension-src/pi-style/pi/config-host.ts +52 -0
- package/extension-src/pi-style/pi/config-session.ts +105 -0
- package/extension-src/pi-style/pi/index.ts +77 -0
- package/extension-src/pi-style/pi/operational-state.ts +29 -0
- package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
- package/extension-src/pi-style/pi/session-usage.ts +62 -0
- package/extension-src/pi-style/pi/startup-resources.ts +70 -0
- package/extension-src/pi-style/shared/.gitkeep +0 -0
- package/extension-src/pi-style/shared/ansi.ts +386 -0
- package/extension-src/pi-style/shared/box.ts +805 -0
- package/extension-src/pi-style/shared/disposable-store.ts +28 -0
- package/extension-src/pi-style/shared/elapsed.ts +88 -0
- package/extension-src/pi-style/shared/render-budget.ts +367 -0
- package/extension-src/pi-style/shared/split-diff.ts +692 -0
- package/extension-src/pi-style/shared/theme-extras.ts +292 -0
- package/package.json +68 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Boxed tool renderer dispatcher.
|
|
2
|
+
//
|
|
3
|
+
// Maps Pi tool names to their boxed call/result renderers and falls back to a
|
|
4
|
+
// boxed generic renderer for unknown tools. The dispatcher is invoked from the
|
|
5
|
+
// tool decoration owner when tools.style === "compact-box".
|
|
6
|
+
|
|
7
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { BoxTheme } from "../../../shared/box.js";
|
|
9
|
+
import { bashTool } from "./bash.js";
|
|
10
|
+
import { editTool } from "./edit.js";
|
|
11
|
+
import { renderFallbackCall, renderFallbackResult } from "./fallback.js";
|
|
12
|
+
import { findTool } from "./find.js";
|
|
13
|
+
import { grepTool } from "./grep.js";
|
|
14
|
+
import { lsTool } from "./ls.js";
|
|
15
|
+
import { getQuickEditToolConfig, quickEditTool } from "./quick-edit.js";
|
|
16
|
+
import { readTool } from "./read.js";
|
|
17
|
+
import type { BoxedToolContext, BoxedToolDefinition } from "./shared.js";
|
|
18
|
+
import { writeTool } from "./write.js";
|
|
19
|
+
|
|
20
|
+
function quickEditToolFor(toolName: string): BoxedToolDefinition {
|
|
21
|
+
const config = getQuickEditToolConfig(toolName);
|
|
22
|
+
if (!config) throw new Error(`missing quick-edit config for ${toolName}`);
|
|
23
|
+
return quickEditTool(config);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const REGISTRY: Readonly<Record<string, BoxedToolDefinition>> = {
|
|
27
|
+
read: readTool,
|
|
28
|
+
write: writeTool,
|
|
29
|
+
edit: editTool,
|
|
30
|
+
bash: bashTool,
|
|
31
|
+
ls: lsTool,
|
|
32
|
+
find: findTool,
|
|
33
|
+
grep: grepTool,
|
|
34
|
+
quick_edit: quickEditToolFor("quick_edit"),
|
|
35
|
+
substitute_edit: quickEditToolFor("substitute_edit"),
|
|
36
|
+
target_edit: quickEditToolFor("target_edit"),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function hasBoxedRenderer(toolName: unknown): boolean {
|
|
40
|
+
return typeof toolName === "string" && Object.hasOwn(REGISTRY, toolName);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderBoxedToolCall(
|
|
44
|
+
toolName: unknown,
|
|
45
|
+
args: Record<string, unknown>,
|
|
46
|
+
theme: BoxTheme,
|
|
47
|
+
context: BoxedToolContext,
|
|
48
|
+
): Component {
|
|
49
|
+
const tool = typeof toolName === "string" ? REGISTRY[toolName] : undefined;
|
|
50
|
+
if (tool) return tool.call(args, theme, context);
|
|
51
|
+
return renderFallbackCall(toolName, args, theme, context);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renderBoxedToolResult(
|
|
55
|
+
toolName: unknown,
|
|
56
|
+
result: { content?: readonly unknown[]; details?: unknown },
|
|
57
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
58
|
+
theme: BoxTheme,
|
|
59
|
+
context: BoxedToolContext,
|
|
60
|
+
): Component {
|
|
61
|
+
const tool = typeof toolName === "string" ? REGISTRY[toolName] : undefined;
|
|
62
|
+
if (tool) return tool.result(result, options, theme, context);
|
|
63
|
+
return renderFallbackResult(toolName, result, options, theme, context);
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Boxed ls tool renderer.
|
|
2
|
+
|
|
3
|
+
import { stripAnsi } from "../../../shared/ansi.js";
|
|
4
|
+
import {
|
|
5
|
+
boxedToolWidthKey,
|
|
6
|
+
countLines,
|
|
7
|
+
getTextOutput,
|
|
8
|
+
renderBoxedToolResult,
|
|
9
|
+
shortenPath,
|
|
10
|
+
stripTrailingNotice,
|
|
11
|
+
} from "../../../shared/box.js";
|
|
12
|
+
import {
|
|
13
|
+
type BoxedToolDefinition,
|
|
14
|
+
clearFooterState,
|
|
15
|
+
compactCall,
|
|
16
|
+
compactFooterWithState,
|
|
17
|
+
noteExecutionStart,
|
|
18
|
+
resultFooterLines,
|
|
19
|
+
truncationOutputLines,
|
|
20
|
+
} from "./shared.js";
|
|
21
|
+
|
|
22
|
+
function displayPath(rawPath: string): string {
|
|
23
|
+
const path = String(rawPath ?? ".");
|
|
24
|
+
if (path === "." || path === "") return "current directory";
|
|
25
|
+
return shortenPath(path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const lsTool: BoxedToolDefinition = {
|
|
29
|
+
call(args, theme, context) {
|
|
30
|
+
noteExecutionStart(context);
|
|
31
|
+
const detail = displayPath(String(args?.path ?? "."));
|
|
32
|
+
return compactCall(theme, "List", `${theme.fg("dim", "Path: ")}${detail}`, {
|
|
33
|
+
detailKey: detail,
|
|
34
|
+
context,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
result(result, options, theme, context) {
|
|
38
|
+
clearFooterState(context);
|
|
39
|
+
const output = stripAnsi(getTextOutput(result)).trimEnd();
|
|
40
|
+
const detail = displayPath(String(context?.args?.path ?? "."));
|
|
41
|
+
const widthKey = boxedToolWidthKey("List", detail);
|
|
42
|
+
|
|
43
|
+
if (context.isError) {
|
|
44
|
+
return renderBoxedToolResult(theme, () => [theme.fg("error", output || "Error")], {
|
|
45
|
+
widthKey,
|
|
46
|
+
footerLines: resultFooterLines(theme, result, context),
|
|
47
|
+
isError: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!options.expanded) return compactFooterWithState(theme, result, context);
|
|
52
|
+
|
|
53
|
+
let itemCount = 0;
|
|
54
|
+
if (output && output !== "(empty directory)") {
|
|
55
|
+
const stripped = stripTrailingNotice(output);
|
|
56
|
+
itemCount = truncationOutputLines(result) ?? countLines(stripped);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const summary = `↳ Listed ${itemCount} ${itemCount === 1 ? "item" : "items"}.`;
|
|
60
|
+
return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
|
|
61
|
+
widthKey,
|
|
62
|
+
footerLines: resultFooterLines(theme, result, context),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Boxed quick-edit / substitute-edit / target-edit renderer.
|
|
2
|
+
|
|
3
|
+
import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { stripAnsi } from "../../../shared/ansi.js";
|
|
6
|
+
import type { BoxTheme } from "../../../shared/box.js";
|
|
7
|
+
import {
|
|
8
|
+
formatBoxedFooterFromValues,
|
|
9
|
+
getTextOutput,
|
|
10
|
+
renderBoxedToolCall,
|
|
11
|
+
renderBoxedToolResult,
|
|
12
|
+
} from "../../../shared/box.js";
|
|
13
|
+
import { buildSplitRows, countDiffStats, renderDiffMeter, SplitDiffComponent } from "../../../shared/split-diff.js";
|
|
14
|
+
import { getStateElapsedMs } from "./session-config.js";
|
|
15
|
+
import { type BoxedToolContext, type BoxedToolDefinition, displayPath, noteExecutionStart } from "./shared.js";
|
|
16
|
+
|
|
17
|
+
const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
|
|
18
|
+
const MAX_HIGHLIGHT_DIFF_ROWS = 120;
|
|
19
|
+
|
|
20
|
+
interface QuickEditToolConfig {
|
|
21
|
+
toolLabel: string;
|
|
22
|
+
applyingLabel: string;
|
|
23
|
+
fallbackLabel: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const QUICK_EDIT_TOOLS: Readonly<Record<string, QuickEditToolConfig>> = {
|
|
27
|
+
quick_edit: {
|
|
28
|
+
toolLabel: "Quick Edit",
|
|
29
|
+
applyingLabel: "quick-edit",
|
|
30
|
+
fallbackLabel: "Quick edit applied",
|
|
31
|
+
},
|
|
32
|
+
substitute_edit: {
|
|
33
|
+
toolLabel: "Substitute Edit",
|
|
34
|
+
applyingLabel: "substitute-edit",
|
|
35
|
+
fallbackLabel: "Substitute edit applied",
|
|
36
|
+
},
|
|
37
|
+
target_edit: {
|
|
38
|
+
toolLabel: "Target Edit",
|
|
39
|
+
applyingLabel: "target-edit",
|
|
40
|
+
fallbackLabel: "Target edit applied",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function getQuickEditToolConfig(toolName: unknown): QuickEditToolConfig | undefined {
|
|
45
|
+
return typeof toolName === "string" ? QUICK_EDIT_TOOLS[toolName] : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractQuickEditDiff(text: string): string | undefined {
|
|
49
|
+
const lines = stripAnsi(text).replace(/\r/g, "").split("\n");
|
|
50
|
+
const start = lines.indexOf("── diff ──");
|
|
51
|
+
if (start < 0) return undefined;
|
|
52
|
+
|
|
53
|
+
const diffLines: string[] = [];
|
|
54
|
+
let cumulativeDelta = 0;
|
|
55
|
+
let oldLine: number | undefined;
|
|
56
|
+
let newLine: number | undefined;
|
|
57
|
+
let chunkAdditions = 0;
|
|
58
|
+
let chunkRemovals = 0;
|
|
59
|
+
|
|
60
|
+
const finishChunk = () => {
|
|
61
|
+
cumulativeDelta += chunkAdditions - chunkRemovals;
|
|
62
|
+
oldLine = undefined;
|
|
63
|
+
newLine = undefined;
|
|
64
|
+
chunkAdditions = 0;
|
|
65
|
+
chunkRemovals = 0;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const line of lines.slice(start + 1)) {
|
|
69
|
+
if (line === "") {
|
|
70
|
+
finishChunk();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const headerMatch = line.match(/^:(\d+)(?:-\d+)?$/);
|
|
75
|
+
if (headerMatch) {
|
|
76
|
+
finishChunk();
|
|
77
|
+
const startLine = Number.parseInt(headerMatch[1] ?? "", 10);
|
|
78
|
+
oldLine = startLine;
|
|
79
|
+
newLine = startLine + cumulativeDelta;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const match = line.match(/^([+-]) (.*)$/);
|
|
84
|
+
if (match) {
|
|
85
|
+
const [, sign, content = ""] = match;
|
|
86
|
+
let gutter = "";
|
|
87
|
+
if (sign === "-" && oldLine !== undefined) gutter = String(oldLine++);
|
|
88
|
+
if (sign === "+" && newLine !== undefined) gutter = String(newLine++);
|
|
89
|
+
if (!gutter) continue;
|
|
90
|
+
if (sign === "-") chunkRemovals++;
|
|
91
|
+
if (sign === "+") chunkAdditions++;
|
|
92
|
+
diffLines.push(`${sign} ${gutter} ${content}`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (line === "---") break;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return diffLines.length > 0 ? diffLines.join("\n") : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function quickEditFooter(theme: BoxTheme, context: BoxedToolContext, output = ""): string {
|
|
103
|
+
return formatBoxedFooterFromValues(theme, getStateElapsedMs(context.state), output);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderQuickEditResult(
|
|
107
|
+
_toolName: string,
|
|
108
|
+
result: { content?: readonly unknown[]; details?: unknown },
|
|
109
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
110
|
+
theme: BoxTheme,
|
|
111
|
+
context: BoxedToolContext,
|
|
112
|
+
config: QuickEditToolConfig,
|
|
113
|
+
) {
|
|
114
|
+
if (options.isPartial) {
|
|
115
|
+
return renderBoxedToolResult(
|
|
116
|
+
theme,
|
|
117
|
+
() => [`${theme.fg("dim", "↳")} ${theme.fg("muted", `Applying ${config.applyingLabel}...`)}`],
|
|
118
|
+
{ isPartial: true },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const output = getTextOutput(result);
|
|
123
|
+
if (context.isError) {
|
|
124
|
+
return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
|
|
125
|
+
footerLines: [quickEditFooter(theme, context, output)],
|
|
126
|
+
isError: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const diff = extractQuickEditDiff(output);
|
|
131
|
+
if (!diff) {
|
|
132
|
+
const fallback = stripAnsi(output).trim() || config.fallbackLabel;
|
|
133
|
+
return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", fallback)}`], {
|
|
134
|
+
footerLines: [quickEditFooter(theme, context, output)],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const rows = buildSplitRows(diff);
|
|
139
|
+
const expanded = options.expanded;
|
|
140
|
+
const argPath = String(context?.args?.path ?? "");
|
|
141
|
+
const language = argPath ? getLanguageFromPath(argPath) : undefined;
|
|
142
|
+
const shouldHighlight =
|
|
143
|
+
Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
|
|
144
|
+
|
|
145
|
+
const { additions, removals } = countDiffStats(diff);
|
|
146
|
+
const meter = renderDiffMeter(theme, additions, removals);
|
|
147
|
+
const summary =
|
|
148
|
+
`${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
|
|
149
|
+
` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
|
|
150
|
+
` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
|
|
151
|
+
` ${theme.fg("muted", "split")}` +
|
|
152
|
+
(meter ? ` ${meter}` : "");
|
|
153
|
+
|
|
154
|
+
const maxRows = expanded ? 160 : 36;
|
|
155
|
+
const split = new SplitDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
156
|
+
|
|
157
|
+
return renderBoxedToolResult(
|
|
158
|
+
theme,
|
|
159
|
+
{
|
|
160
|
+
render(width: number): string[] {
|
|
161
|
+
const safeWidth = Math.max(20, width);
|
|
162
|
+
const headerLines = new Text(summary, 0, 0).render(safeWidth);
|
|
163
|
+
return [...headerLines, ...split.render(safeWidth)];
|
|
164
|
+
},
|
|
165
|
+
invalidate(): void {
|
|
166
|
+
split.invalidate();
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
footerLines: [quickEditFooter(theme, context, output)],
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition {
|
|
176
|
+
return {
|
|
177
|
+
call(args, theme, context) {
|
|
178
|
+
noteExecutionStart(context);
|
|
179
|
+
const detail = displayPath(String(args?.path ?? ""), context);
|
|
180
|
+
return renderBoxedToolCall(theme, config.toolLabel, [`${theme.fg("dim", "Path: ")}${detail}`], {
|
|
181
|
+
isError: Boolean(context.isError),
|
|
182
|
+
isPartial: Boolean(context.isPartial),
|
|
183
|
+
isPending: Boolean(context.isPartial),
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
result(result, options, theme, context) {
|
|
187
|
+
return renderQuickEditResult(config.toolLabel, result, options, theme, context, config);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Boxed read tool renderer
|
|
2
|
+
// (renderCall/renderResult only; no tool re-registration).
|
|
3
|
+
|
|
4
|
+
import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { stripAnsi } from "../../../shared/ansi.js";
|
|
6
|
+
import type { BoxTheme } from "../../../shared/box.js";
|
|
7
|
+
import {
|
|
8
|
+
boxedToolWidthKey,
|
|
9
|
+
countLines,
|
|
10
|
+
extractTrailingNotice,
|
|
11
|
+
getTextOutput,
|
|
12
|
+
renderBoxedToolResult,
|
|
13
|
+
stripTrailingNotice,
|
|
14
|
+
} from "../../../shared/box.js";
|
|
15
|
+
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
16
|
+
import { getToolsRenderConfig } from "./session-config.js";
|
|
17
|
+
import {
|
|
18
|
+
type BoxedToolDefinition,
|
|
19
|
+
clearFooterState,
|
|
20
|
+
compactCall,
|
|
21
|
+
compactFooterWithState,
|
|
22
|
+
noteExecutionStart,
|
|
23
|
+
pathRangeDetail,
|
|
24
|
+
resultFooterLines,
|
|
25
|
+
truncationOutputLines,
|
|
26
|
+
} from "./shared.js";
|
|
27
|
+
|
|
28
|
+
const MAX_HIGHLIGHT_OUTPUT_CHARS = 12000;
|
|
29
|
+
const MAX_HIGHLIGHT_OUTPUT_LINES = 300;
|
|
30
|
+
|
|
31
|
+
type NumberedReadLine = {
|
|
32
|
+
lineNumber: string;
|
|
33
|
+
content: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ParsedReadOutput = {
|
|
37
|
+
fileHash: string | undefined;
|
|
38
|
+
numberedLines?: NumberedReadLine[];
|
|
39
|
+
body: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function parseReadOutput(text: string): ParsedReadOutput {
|
|
43
|
+
const fileHashMatch = text.match(/^fileHash: ([^\n]+)\n\n/);
|
|
44
|
+
const body = fileHashMatch ? text.slice(fileHashMatch[0].length) : text;
|
|
45
|
+
const rawLines = body ? body.split("\n") : [];
|
|
46
|
+
const numberedLines = rawLines.map((line) => line.match(/^\s*(\d+)\| ?(.*)$/));
|
|
47
|
+
|
|
48
|
+
if (numberedLines.length > 0 && numberedLines.every(Boolean)) {
|
|
49
|
+
return {
|
|
50
|
+
fileHash: fileHashMatch?.[1],
|
|
51
|
+
body: numberedLines.map((match) => match?.[2] ?? "").join("\n"),
|
|
52
|
+
numberedLines: numberedLines.map((match) => ({
|
|
53
|
+
lineNumber: match?.[1] ?? "",
|
|
54
|
+
content: match?.[2] ?? "",
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { fileHash: fileHashMatch?.[1], body };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderReadBody(
|
|
63
|
+
theme: BoxTheme,
|
|
64
|
+
options: { expanded: boolean },
|
|
65
|
+
parsed: ParsedReadOutput,
|
|
66
|
+
output: string,
|
|
67
|
+
truncationNotice: string | null,
|
|
68
|
+
): { render(width: number): string[]; invalidate(): void } {
|
|
69
|
+
let cacheKey = "";
|
|
70
|
+
let cacheLines: string[] | null = null;
|
|
71
|
+
return {
|
|
72
|
+
invalidate() {
|
|
73
|
+
cacheKey = "";
|
|
74
|
+
cacheLines = null;
|
|
75
|
+
},
|
|
76
|
+
render(width: number): string[] {
|
|
77
|
+
const renderWidth = Math.max(1, width);
|
|
78
|
+
const cfg = getToolsRenderConfig();
|
|
79
|
+
const maxLines = cfg.maxExpandedLines;
|
|
80
|
+
const expanded = Boolean(options.expanded);
|
|
81
|
+
const cacheId = `${renderWidth}|${expanded ? 1 : 0}|${maxLines}|${cfg.dimOutput ? 1 : 0}`;
|
|
82
|
+
if (cacheLines && cacheKey === cacheId) return cacheLines;
|
|
83
|
+
|
|
84
|
+
const linesRead = parsed.numberedLines?.length ?? countLines(parsed.body);
|
|
85
|
+
const summary = theme.fg("dim", `↳ Read ${linesRead} ${linesRead === 1 ? "line" : "lines"}.`);
|
|
86
|
+
const footer: string[] = [];
|
|
87
|
+
if (truncationNotice) footer.push(theme.fg("warning", truncationNotice));
|
|
88
|
+
footer.push("", summary);
|
|
89
|
+
const budget = maxLines > 0 ? maxLines - footer.length : 0;
|
|
90
|
+
const renderPlain = (text: string): string[] => {
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
for (const line of text.split("\n")) {
|
|
93
|
+
out.push(safeTruncateToWidth(theme.fg("toolOutput", line), renderWidth, "…"));
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
const lineCount = parsed.numberedLines?.length ?? countLines(parsed.body);
|
|
98
|
+
const shouldHighlight =
|
|
99
|
+
expanded &&
|
|
100
|
+
Boolean(getLanguageFromPath(output)) &&
|
|
101
|
+
parsed.body.length <= MAX_HIGHLIGHT_OUTPUT_CHARS &&
|
|
102
|
+
lineCount <= MAX_HIGHLIGHT_OUTPUT_LINES;
|
|
103
|
+
|
|
104
|
+
const renderBody = (): string[] => {
|
|
105
|
+
if (!parsed.numberedLines)
|
|
106
|
+
return renderPlain(parsed.fileHash ? `fileHash: ${parsed.fileHash}\n\n${parsed.body}` : parsed.body);
|
|
107
|
+
|
|
108
|
+
let bodyLines = parsed.body.split("\n").map((line) => theme.fg("toolOutput", line));
|
|
109
|
+
const lang = getLanguageFromPath(output);
|
|
110
|
+
if (shouldHighlight && lang) {
|
|
111
|
+
try {
|
|
112
|
+
bodyLines = highlightCode(parsed.body, lang);
|
|
113
|
+
} catch {
|
|
114
|
+
bodyLines = parsed.body.split("\n").map((line) => theme.fg("toolOutput", line));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const out: string[] = [];
|
|
119
|
+
if (parsed.fileHash) out.push(theme.fg("muted", `fileHash: ${parsed.fileHash}`), "");
|
|
120
|
+
|
|
121
|
+
const numberWidth = Math.max(...parsed.numberedLines.map((line) => line.lineNumber.length));
|
|
122
|
+
const gutterWidth = numberWidth + 3;
|
|
123
|
+
const contentWidth = Math.max(1, renderWidth - gutterWidth);
|
|
124
|
+
for (let i = 0; i < parsed.numberedLines.length; i++) {
|
|
125
|
+
const numberedLine = parsed.numberedLines[i] ?? { lineNumber: "", content: "" };
|
|
126
|
+
const bodyLine = safeTruncateToWidth(bodyLines[i] ?? "", contentWidth, "…");
|
|
127
|
+
const gutter = theme.fg("dim", `${numberedLine.lineNumber.padStart(numberWidth)} │ `);
|
|
128
|
+
out.push(`${gutter}${bodyLine}`);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const highlighted = renderBody();
|
|
134
|
+
if (maxLines > 0 && highlighted.length > budget) {
|
|
135
|
+
const truncated = highlighted.slice(0, budget);
|
|
136
|
+
const remaining = highlighted.length - budget;
|
|
137
|
+
truncated.push(theme.fg("dim", `… ${remaining} more lines`));
|
|
138
|
+
truncated.push(...footer);
|
|
139
|
+
cacheKey = cacheId;
|
|
140
|
+
cacheLines = truncated;
|
|
141
|
+
return cacheLines;
|
|
142
|
+
}
|
|
143
|
+
highlighted.push(...footer);
|
|
144
|
+
cacheKey = cacheId;
|
|
145
|
+
cacheLines = highlighted;
|
|
146
|
+
return cacheLines;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const readTool: BoxedToolDefinition = {
|
|
152
|
+
call(args, theme, context) {
|
|
153
|
+
noteExecutionStart(context);
|
|
154
|
+
const rawPath = String(args?.path ?? args?.file_path ?? "");
|
|
155
|
+
const detail = pathRangeDetail(rawPath, args?.offset, args?.limit, context);
|
|
156
|
+
return compactCall(theme, "Read", `${theme.fg("dim", "Path: ")}${detail}`, {
|
|
157
|
+
detailKey: detail,
|
|
158
|
+
context,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
result(result, options, theme, context) {
|
|
162
|
+
clearFooterState(context);
|
|
163
|
+
const output = stripAnsi(getTextOutput(result)).trimEnd();
|
|
164
|
+
const rawPath = String(context?.args?.path ?? context?.args?.file_path ?? "");
|
|
165
|
+
const detail = pathRangeDetail(rawPath, context?.args?.offset, context?.args?.limit, context);
|
|
166
|
+
const widthKey = boxedToolWidthKey("Read", detail);
|
|
167
|
+
|
|
168
|
+
if (context.isError) {
|
|
169
|
+
return renderBoxedToolResult(theme, () => [theme.fg("error", output || "Error")], {
|
|
170
|
+
widthKey,
|
|
171
|
+
footerLines: resultFooterLines(theme, result, context),
|
|
172
|
+
isError: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const imageCount = Array.isArray(result.content)
|
|
177
|
+
? result.content.filter((contentBlock) => {
|
|
178
|
+
if (!contentBlock || typeof contentBlock !== "object") return false;
|
|
179
|
+
return (contentBlock as { type?: unknown }).type === "image";
|
|
180
|
+
}).length
|
|
181
|
+
: 0;
|
|
182
|
+
if (imageCount > 0) {
|
|
183
|
+
if (!options.expanded) return compactFooterWithState(theme, result, context);
|
|
184
|
+
const summary = `↳ Read ${imageCount} ${imageCount === 1 ? "image" : "images"}.`;
|
|
185
|
+
return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
|
|
186
|
+
widthKey,
|
|
187
|
+
footerLines: resultFooterLines(theme, result, context),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stripped = stripTrailingNotice(output);
|
|
192
|
+
const parsed = parseReadOutput(stripped);
|
|
193
|
+
const truncationNotice = extractTrailingNotice(output);
|
|
194
|
+
const _linesRead = truncationOutputLines(result) ?? parsed.numberedLines?.length ?? countLines(parsed.body);
|
|
195
|
+
|
|
196
|
+
if (!options.expanded) return compactFooterWithState(theme, result, context);
|
|
197
|
+
|
|
198
|
+
const body = renderReadBody(theme, options, parsed, output, truncationNotice);
|
|
199
|
+
return renderBoxedToolResult(theme, body, {
|
|
200
|
+
widthKey,
|
|
201
|
+
footerLines: resultFooterLines(theme, result, context),
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Per-session render configuration for boxed tool presentation.
|
|
2
|
+
//
|
|
3
|
+
// Set once per session by the compatibility coordinator; read inside renderers.
|
|
4
|
+
// Kept out of the render path (no filesystem/config reads during render).
|
|
5
|
+
|
|
6
|
+
export interface ToolsRenderConfig {
|
|
7
|
+
maxCollapsedLines: number;
|
|
8
|
+
maxExpandedLines: number;
|
|
9
|
+
dimOutput: boolean;
|
|
10
|
+
showElapsed: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let sessionToolsConfig: ToolsRenderConfig = {
|
|
14
|
+
maxCollapsedLines: 10,
|
|
15
|
+
maxExpandedLines: 50,
|
|
16
|
+
dimOutput: false,
|
|
17
|
+
showElapsed: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function setToolsRenderConfig(config: Partial<ToolsRenderConfig>): void {
|
|
21
|
+
sessionToolsConfig = { ...sessionToolsConfig, ...config };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getToolsRenderConfig(): ToolsRenderConfig {
|
|
25
|
+
return sessionToolsConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Wall-clock elapsed tracking through the renderer context state (no tool
|
|
29
|
+
// re-registration, so result.details has no execution timing).
|
|
30
|
+
|
|
31
|
+
const STARTED_AT_KEY = "__piStyleStartedAt";
|
|
32
|
+
const ELAPSED_MS_KEY = "__piStyleElapsedMs";
|
|
33
|
+
|
|
34
|
+
export function recordExecutionStarted(state: Record<string, unknown> | undefined, executionStarted: boolean): void {
|
|
35
|
+
if (!executionStarted || !state || typeof state !== "object") return;
|
|
36
|
+
if (typeof state[STARTED_AT_KEY] !== "number") state[STARTED_AT_KEY] = performance.now();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getStateElapsedMs(state: Record<string, unknown> | undefined): number | undefined {
|
|
40
|
+
if (!state || typeof state !== "object") return undefined;
|
|
41
|
+
if (typeof state[ELAPSED_MS_KEY] !== "number" && typeof state[STARTED_AT_KEY] === "number") {
|
|
42
|
+
state[ELAPSED_MS_KEY] = performance.now() - state[STARTED_AT_KEY];
|
|
43
|
+
}
|
|
44
|
+
return typeof state[ELAPSED_MS_KEY] === "number" ? state[ELAPSED_MS_KEY] : undefined;
|
|
45
|
+
}
|