pi-extensions-tool-display 0.1.1 → 0.2.0

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.
@@ -0,0 +1,303 @@
1
+ import { resolvePiAgentDir } from "./agent-dir.js";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ BUILT_IN_TOOL_OVERRIDE_NAMES,
6
+ BASH_OUTPUT_MODES,
7
+ CUSTOM_TOOL_OUTPUT_MODES,
8
+ CUSTOM_TOOL_OVERRIDE_KINDS,
9
+ DEFAULT_TOOL_DISPLAY_CONFIG,
10
+ type ConfigLoadResult,
11
+ type ConfigSaveResult,
12
+ type CustomToolOverrideConfig,
13
+ DIFF_INDICATOR_MODES,
14
+ DIFF_VIEW_MODES,
15
+ MCP_OUTPUT_MODES,
16
+ READ_OUTPUT_MODES,
17
+ SEARCH_OUTPUT_MODES,
18
+ type ToolDisplayConfig,
19
+ type ToolOverrideOwnership,
20
+ } from "./types.js";
21
+ import { toRecord } from "./tool-metadata.js";
22
+
23
+ const CONFIG_DIR = join(resolvePiAgentDir(), "extensions", "pi-tool-display");
24
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
25
+
26
+ interface LegacyToolDisplayConfigSource extends Partial<ToolDisplayConfig> {
27
+ registerReadToolOverride?: unknown;
28
+ }
29
+
30
+ function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
31
+ if (typeof value !== "number" || Number.isNaN(value)) {
32
+ return fallback;
33
+ }
34
+ const rounded = Math.floor(value);
35
+ if (rounded < min) return min;
36
+ if (rounded > max) return max;
37
+ return rounded;
38
+ }
39
+
40
+ function toBoolean(value: unknown, fallback: boolean): boolean {
41
+ return typeof value === "boolean" ? value : fallback;
42
+ }
43
+
44
+ function toReadOutputMode(value: unknown): ToolDisplayConfig["readOutputMode"] {
45
+ return READ_OUTPUT_MODES.includes(value as ToolDisplayConfig["readOutputMode"])
46
+ ? (value as ToolDisplayConfig["readOutputMode"])
47
+ : DEFAULT_TOOL_DISPLAY_CONFIG.readOutputMode;
48
+ }
49
+
50
+ function toSearchOutputMode(value: unknown): ToolDisplayConfig["searchOutputMode"] {
51
+ return SEARCH_OUTPUT_MODES.includes(value as ToolDisplayConfig["searchOutputMode"])
52
+ ? (value as ToolDisplayConfig["searchOutputMode"])
53
+ : DEFAULT_TOOL_DISPLAY_CONFIG.searchOutputMode;
54
+ }
55
+
56
+ function toMcpOutputMode(value: unknown): ToolDisplayConfig["mcpOutputMode"] {
57
+ return MCP_OUTPUT_MODES.includes(value as ToolDisplayConfig["mcpOutputMode"])
58
+ ? (value as ToolDisplayConfig["mcpOutputMode"])
59
+ : DEFAULT_TOOL_DISPLAY_CONFIG.mcpOutputMode;
60
+ }
61
+
62
+ function toBashOutputMode(value: unknown): ToolDisplayConfig["bashOutputMode"] {
63
+ return BASH_OUTPUT_MODES.includes(value as ToolDisplayConfig["bashOutputMode"])
64
+ ? (value as ToolDisplayConfig["bashOutputMode"])
65
+ : DEFAULT_TOOL_DISPLAY_CONFIG.bashOutputMode;
66
+ }
67
+
68
+ function toDiffViewMode(value: unknown): ToolDisplayConfig["diffViewMode"] {
69
+ if (value === "stacked") {
70
+ // Backward compatibility with older config naming.
71
+ return "unified";
72
+ }
73
+
74
+ return DIFF_VIEW_MODES.includes(value as ToolDisplayConfig["diffViewMode"])
75
+ ? (value as ToolDisplayConfig["diffViewMode"])
76
+ : DEFAULT_TOOL_DISPLAY_CONFIG.diffViewMode;
77
+ }
78
+
79
+ function toDiffIndicatorMode(value: unknown): ToolDisplayConfig["diffIndicatorMode"] {
80
+ return DIFF_INDICATOR_MODES.includes(value as ToolDisplayConfig["diffIndicatorMode"])
81
+ ? (value as ToolDisplayConfig["diffIndicatorMode"])
82
+ : DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode;
83
+ }
84
+
85
+ export function cloneCustomToolOverrides(
86
+ overrides: Record<string, CustomToolOverrideConfig>,
87
+ ): Record<string, CustomToolOverrideConfig> {
88
+ return Object.fromEntries(
89
+ Object.entries(overrides).map(([toolName, override]) => [
90
+ toolName,
91
+ { ...override },
92
+ ]),
93
+ );
94
+ }
95
+
96
+ function cloneDefaultConfig(): ToolDisplayConfig {
97
+ return {
98
+ ...DEFAULT_TOOL_DISPLAY_CONFIG,
99
+ registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
100
+ customToolOverrides: cloneCustomToolOverrides(DEFAULT_TOOL_DISPLAY_CONFIG.customToolOverrides),
101
+ };
102
+ }
103
+
104
+ let cachedConfigFile: string | undefined;
105
+ let cachedConfigFingerprint: string | undefined;
106
+ let cachedConfigResult: ConfigLoadResult | undefined;
107
+
108
+ function cloneConfig(config: ToolDisplayConfig): ToolDisplayConfig {
109
+ return normalizeToolDisplayConfig(config);
110
+ }
111
+
112
+ function cloneLoadResult(result: ConfigLoadResult): ConfigLoadResult {
113
+ return {
114
+ ...result,
115
+ config: cloneConfig(result.config),
116
+ };
117
+ }
118
+
119
+ function getConfigFingerprint(configFile: string): string {
120
+ try {
121
+ const stats = statSync(configFile);
122
+ return `${stats.mtimeMs}:${stats.size}`;
123
+ } catch {
124
+ return "missing";
125
+ }
126
+ }
127
+
128
+ function normalizeToolOverrideOwnership(
129
+ rawOverrides: unknown,
130
+ legacyRegisterReadToolOverride: unknown,
131
+ ): ToolOverrideOwnership {
132
+ const source = toRecord(rawOverrides);
133
+ const defaults = DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides;
134
+ const legacyReadDefault = toBoolean(legacyRegisterReadToolOverride, defaults.read);
135
+
136
+ const overrides = { ...defaults };
137
+ for (const toolName of BUILT_IN_TOOL_OVERRIDE_NAMES) {
138
+ const fallback = toolName === "read" ? legacyReadDefault : defaults[toolName];
139
+ overrides[toolName] = toBoolean(source[toolName], fallback);
140
+ }
141
+
142
+ return overrides;
143
+ }
144
+
145
+ function isBuiltInToolOverrideName(toolName: string): boolean {
146
+ return (BUILT_IN_TOOL_OVERRIDE_NAMES as readonly string[]).includes(toolName);
147
+ }
148
+
149
+ export function toCustomToolOverrideKind(value: unknown): CustomToolOverrideConfig["kind"] {
150
+ return CUSTOM_TOOL_OVERRIDE_KINDS.includes(value as CustomToolOverrideConfig["kind"])
151
+ ? (value as CustomToolOverrideConfig["kind"])
152
+ : "generic";
153
+ }
154
+
155
+ export function toCustomToolOutputMode(value: unknown): CustomToolOverrideConfig["outputMode"] {
156
+ return CUSTOM_TOOL_OUTPUT_MODES.includes(value as CustomToolOverrideConfig["outputMode"])
157
+ ? (value as CustomToolOverrideConfig["outputMode"])
158
+ : "summary";
159
+ }
160
+
161
+ export function normalizeCustomToolOverrideEntry(rawEntry: unknown): CustomToolOverrideConfig | undefined {
162
+ if (typeof rawEntry === "boolean") {
163
+ return {
164
+ enabled: rawEntry,
165
+ kind: "generic",
166
+ outputMode: "summary",
167
+ };
168
+ }
169
+
170
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
171
+ return undefined;
172
+ }
173
+
174
+ const source = toRecord(rawEntry);
175
+ return {
176
+ enabled: toBoolean(source.enabled, true),
177
+ kind: toCustomToolOverrideKind(source.kind),
178
+ outputMode: toCustomToolOutputMode(source.outputMode),
179
+ };
180
+ }
181
+
182
+ function normalizeCustomToolOverrides(rawOverrides: unknown): Record<string, CustomToolOverrideConfig> {
183
+ const source = toRecord(rawOverrides);
184
+ const overrides: Record<string, CustomToolOverrideConfig> = {};
185
+
186
+ for (const [rawToolName, rawEntry] of Object.entries(source)) {
187
+ const toolName = rawToolName.trim();
188
+ if (!toolName || isBuiltInToolOverrideName(toolName)) {
189
+ continue;
190
+ }
191
+
192
+ const normalized = normalizeCustomToolOverrideEntry(rawEntry);
193
+ if (!normalized) {
194
+ continue;
195
+ }
196
+
197
+ overrides[toolName] = normalized;
198
+ }
199
+
200
+ return overrides;
201
+ }
202
+
203
+ export function normalizeToolDisplayConfig(raw: unknown): ToolDisplayConfig {
204
+ const source =
205
+ typeof raw === "object" && raw !== null ? (raw as LegacyToolDisplayConfigSource) : ({} as LegacyToolDisplayConfigSource);
206
+
207
+ return {
208
+ enabled: toBoolean(source.enabled, DEFAULT_TOOL_DISPLAY_CONFIG.enabled),
209
+ registerToolOverrides: normalizeToolOverrideOwnership(
210
+ source.registerToolOverrides,
211
+ source.registerReadToolOverride,
212
+ ),
213
+ customToolOverrides: normalizeCustomToolOverrides(source.customToolOverrides),
214
+ enableNativeUserMessageBox: toBoolean(
215
+ source.enableNativeUserMessageBox,
216
+ DEFAULT_TOOL_DISPLAY_CONFIG.enableNativeUserMessageBox,
217
+ ),
218
+ readOutputMode: toReadOutputMode(source.readOutputMode),
219
+ searchOutputMode: toSearchOutputMode(source.searchOutputMode),
220
+ mcpOutputMode: toMcpOutputMode(source.mcpOutputMode),
221
+ previewLines: clampNumber(source.previewLines, 1, 80, DEFAULT_TOOL_DISPLAY_CONFIG.previewLines),
222
+ expandedPreviewMaxLines: clampNumber(
223
+ source.expandedPreviewMaxLines,
224
+ 0,
225
+ 20_000,
226
+ DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
227
+ ),
228
+ bashOutputMode: toBashOutputMode(source.bashOutputMode),
229
+ bashCollapsedLines: clampNumber(source.bashCollapsedLines, 0, 80, DEFAULT_TOOL_DISPLAY_CONFIG.bashCollapsedLines),
230
+ diffViewMode: toDiffViewMode(source.diffViewMode),
231
+ diffIndicatorMode: toDiffIndicatorMode(source.diffIndicatorMode),
232
+ diffSplitMinWidth: clampNumber(source.diffSplitMinWidth, 70, 240, DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth),
233
+ diffCollapsedLines: clampNumber(source.diffCollapsedLines, 4, 240, DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines),
234
+ diffWordWrap: toBoolean(source.diffWordWrap, DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap),
235
+ showTruncationHints: toBoolean(source.showTruncationHints, DEFAULT_TOOL_DISPLAY_CONFIG.showTruncationHints),
236
+ showRtkCompactionHints: toBoolean(
237
+ source.showRtkCompactionHints,
238
+ DEFAULT_TOOL_DISPLAY_CONFIG.showRtkCompactionHints,
239
+ ),
240
+ };
241
+ }
242
+
243
+ export function loadToolDisplayConfig(configFile = CONFIG_FILE): ConfigLoadResult {
244
+ const fingerprint = getConfigFingerprint(configFile);
245
+ if (cachedConfigResult && cachedConfigFile === configFile && cachedConfigFingerprint === fingerprint) {
246
+ return cloneLoadResult(cachedConfigResult);
247
+ }
248
+
249
+ let result: ConfigLoadResult;
250
+ if (!existsSync(configFile)) {
251
+ result = { config: cloneDefaultConfig() };
252
+ } else {
253
+ try {
254
+ const rawText = readFileSync(configFile, "utf-8");
255
+ const rawConfig = JSON.parse(rawText) as unknown;
256
+ result = { config: normalizeToolDisplayConfig(rawConfig) };
257
+ } catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ result = {
260
+ config: cloneDefaultConfig(),
261
+ error: `Failed to parse ${configFile}: ${message}`,
262
+ };
263
+ }
264
+ }
265
+
266
+ cachedConfigFile = configFile;
267
+ cachedConfigFingerprint = fingerprint;
268
+ cachedConfigResult = cloneLoadResult(result);
269
+ return result;
270
+ }
271
+
272
+ export function saveToolDisplayConfig(config: ToolDisplayConfig, configFile = CONFIG_FILE): ConfigSaveResult {
273
+ const normalized = normalizeToolDisplayConfig(config);
274
+ const tmpFile = `${configFile}.tmp`;
275
+
276
+ try {
277
+ mkdirSync(dirname(configFile), { recursive: true });
278
+ writeFileSync(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
279
+ renameSync(tmpFile, configFile);
280
+ cachedConfigFile = undefined;
281
+ cachedConfigFingerprint = undefined;
282
+ cachedConfigResult = undefined;
283
+ return { success: true };
284
+ } catch (error) {
285
+ try {
286
+ if (existsSync(tmpFile)) {
287
+ unlinkSync(tmpFile);
288
+ }
289
+ } catch (cleanupError) {
290
+ // Ignore cleanup errors.
291
+ void cleanupError;
292
+ }
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ return {
295
+ success: false,
296
+ error: `Failed to save ${configFile}: ${message}`,
297
+ };
298
+ }
299
+ }
300
+
301
+ export function getToolDisplayConfigPath(): string {
302
+ return CONFIG_FILE;
303
+ }
@@ -0,0 +1,150 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { toRecord } from "./tool-metadata.js";
6
+
7
+ const EXTENSION_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const DEFAULT_DEBUG_CONFIG_FILE = join(EXTENSION_ROOT, "config.json");
9
+ const DEFAULT_DEBUG_DIR = join(EXTENSION_ROOT, "debug");
10
+ const DEFAULT_DEBUG_LOG_FILE = join(DEFAULT_DEBUG_DIR, "debug.log");
11
+
12
+ const DEFAULT_DEBUG_CONFIG_CACHE_TTL_MS = 1_000;
13
+
14
+ const SECRET_VALUE_PATTERN = /\b(?:sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{12,})\b/g;
15
+
16
+ interface ToolDisplayDebugLoggerFileSystem {
17
+ existsSync: typeof existsSync;
18
+ mkdirSync: typeof mkdirSync;
19
+ readFileSync: typeof readFileSync;
20
+ statSync: typeof statSync;
21
+ appendFile: typeof appendFile;
22
+ }
23
+
24
+ export interface ToolDisplayDebugLoggerOptions {
25
+ configFile?: string;
26
+ debugDir?: string;
27
+ debugLogFile?: string;
28
+ cacheTtlMs?: number;
29
+ now?: () => number;
30
+ createDate?: () => Date;
31
+ fileSystem?: ToolDisplayDebugLoggerFileSystem;
32
+ }
33
+
34
+ export interface ToolDisplayDebugLogger {
35
+ log(message: string, error?: unknown): void;
36
+ flush(): Promise<void>;
37
+ }
38
+
39
+ const DEFAULT_FILE_SYSTEM: ToolDisplayDebugLoggerFileSystem = {
40
+ existsSync,
41
+ mkdirSync,
42
+ readFileSync,
43
+ statSync,
44
+ appendFile,
45
+ };
46
+
47
+ function redactMessage(value: string): string {
48
+ return value.replace(SECRET_VALUE_PATTERN, "[REDACTED]");
49
+ }
50
+
51
+ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOptions = {}): ToolDisplayDebugLogger {
52
+ const configFile = options.configFile ?? DEFAULT_DEBUG_CONFIG_FILE;
53
+ const debugDir = options.debugDir ?? DEFAULT_DEBUG_DIR;
54
+ const debugLogFile = options.debugLogFile ?? DEFAULT_DEBUG_LOG_FILE;
55
+ const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_DEBUG_CONFIG_CACHE_TTL_MS;
56
+ const now = options.now ?? Date.now;
57
+ const createDate = options.createDate ?? (() => new Date());
58
+ const fileSystem = options.fileSystem ?? DEFAULT_FILE_SYSTEM;
59
+
60
+ let cachedDebugFingerprint: string | undefined;
61
+ let cachedDebugEnabled = false;
62
+ let cachedDebugCheckedAt = 0;
63
+ let debugDirectoryReady = false;
64
+ let writeQueue: Promise<void> = Promise.resolve();
65
+
66
+ function getDebugConfigFingerprint(): string {
67
+ try {
68
+ const stats = fileSystem.statSync(configFile);
69
+ return `${stats.mtimeMs}:${stats.size}`;
70
+ } catch {
71
+ return "missing";
72
+ }
73
+ }
74
+
75
+ function isDebugEnabled(): boolean {
76
+ const checkedAt = now();
77
+ if (cachedDebugFingerprint !== undefined && checkedAt - cachedDebugCheckedAt < cacheTtlMs) {
78
+ return cachedDebugEnabled;
79
+ }
80
+
81
+ cachedDebugCheckedAt = checkedAt;
82
+ const fingerprint = getDebugConfigFingerprint();
83
+ if (fingerprint === cachedDebugFingerprint) {
84
+ return cachedDebugEnabled;
85
+ }
86
+
87
+ cachedDebugFingerprint = fingerprint;
88
+ cachedDebugEnabled = false;
89
+ try {
90
+ if (!fileSystem.existsSync(configFile)) {
91
+ return cachedDebugEnabled;
92
+ }
93
+
94
+ const rawConfig = JSON.parse(fileSystem.readFileSync(configFile, "utf8") as string) as unknown;
95
+ cachedDebugEnabled = toRecord(rawConfig).debug === true;
96
+ return cachedDebugEnabled;
97
+ } catch {
98
+ return cachedDebugEnabled;
99
+ }
100
+ }
101
+
102
+ function ensureDebugDirectory(): void {
103
+ if (debugDirectoryReady) {
104
+ return;
105
+ }
106
+
107
+ fileSystem.mkdirSync(debugDir, { recursive: true });
108
+ debugDirectoryReady = true;
109
+ }
110
+
111
+ function appendLine(line: string): Promise<void> {
112
+ return fileSystem.appendFile(debugLogFile, line, "utf8");
113
+ }
114
+
115
+ return {
116
+ log(message: string, error?: unknown): void {
117
+ if (!isDebugEnabled()) {
118
+ return;
119
+ }
120
+
121
+ try {
122
+ ensureDebugDirectory();
123
+ const errorText = error instanceof Error
124
+ ? `${error.name}: ${error.message}`
125
+ : error === undefined
126
+ ? ""
127
+ : String(error);
128
+ const suffix = errorText ? ` ${redactMessage(errorText)}` : "";
129
+ const line = `${createDate().toISOString()} ${redactMessage(message)}${suffix}\n`;
130
+ writeQueue = writeQueue.then(
131
+ () => appendLine(line),
132
+ () => appendLine(line),
133
+ );
134
+ void writeQueue.catch(() => undefined);
135
+ } catch (logError) {
136
+ // Debug logging must never affect extension behavior.
137
+ void logError;
138
+ }
139
+ },
140
+ flush(): Promise<void> {
141
+ return writeQueue.catch(() => undefined);
142
+ },
143
+ };
144
+ }
145
+
146
+ const defaultDebugLogger = createToolDisplayDebugLogger();
147
+
148
+ export function logToolDisplayDebug(message: string, error?: unknown): void {
149
+ defaultDebugLogger.log(message, error);
150
+ }
@@ -0,0 +1,72 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { pluralize } from "./render-utils.js";
3
+ import type { ToolDisplayConfig } from "./types.js";
4
+
5
+ export interface DiffSummaryStats {
6
+ added: number;
7
+ removed: number;
8
+ hunks: number;
9
+ files: number;
10
+ }
11
+
12
+ export type DiffPresentationMode = "split" | "unified" | "compact" | "summary";
13
+
14
+ const MIN_COMPACT_DIFF_WIDTH = 8;
15
+ const MIN_UNIFIED_DIFF_WIDTH = 18;
16
+
17
+ export function normalizeDiffRenderWidth(width: number): number {
18
+ if (!Number.isFinite(width)) {
19
+ return 0;
20
+ }
21
+ return Math.max(0, Math.floor(width));
22
+ }
23
+
24
+ export function resolveDiffPresentationMode(
25
+ config: Pick<ToolDisplayConfig, "diffViewMode" | "diffSplitMinWidth">,
26
+ width: number,
27
+ canRenderSplitLayout: boolean,
28
+ ): DiffPresentationMode {
29
+ const safeWidth = normalizeDiffRenderWidth(width);
30
+ if (safeWidth < MIN_COMPACT_DIFF_WIDTH) {
31
+ return "summary";
32
+ }
33
+ if (safeWidth < MIN_UNIFIED_DIFF_WIDTH) {
34
+ return "compact";
35
+ }
36
+
37
+ switch (config.diffViewMode) {
38
+ case "split":
39
+ return canRenderSplitLayout ? "split" : "unified";
40
+ case "unified":
41
+ return "unified";
42
+ case "auto":
43
+ default:
44
+ return safeWidth >= config.diffSplitMinWidth && canRenderSplitLayout
45
+ ? "split"
46
+ : "unified";
47
+ }
48
+ }
49
+
50
+ export function buildDiffSummaryText(stats: DiffSummaryStats, width: number): string {
51
+ const safeWidth = normalizeDiffRenderWidth(width);
52
+ if (safeWidth === 0) {
53
+ return "";
54
+ }
55
+
56
+ const summaryCandidates = [
57
+ `↳ diff +${stats.added} -${stats.removed} • ${stats.hunks} ${pluralize(stats.hunks, "hunk")} • ${stats.files} ${pluralize(stats.files, "file")}`,
58
+ `↳ diff +${stats.added} -${stats.removed} • ${stats.hunks}h • ${stats.files}f`,
59
+ `↳ diff +${stats.added} -${stats.removed}`,
60
+ `+${stats.added} -${stats.removed}`,
61
+ "diff",
62
+ "…",
63
+ ];
64
+
65
+ for (const candidate of summaryCandidates) {
66
+ if (visibleWidth(candidate) <= safeWidth) {
67
+ return candidate;
68
+ }
69
+ }
70
+
71
+ return truncateToWidth(summaryCandidates[summaryCandidates.length - 1] ?? "", safeWidth, "");
72
+ }