pi-extensions-tool-display 0.1.1 → 0.2.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/README.md +13 -3
- package/README.zh-CN.md +14 -4
- package/config.example.json +40 -0
- package/index.ts +6 -0
- package/package.json +16 -5
- package/src/agent-dir.ts +32 -0
- package/src/ansi-utils.ts +116 -0
- package/src/bash-display.ts +191 -0
- package/src/capabilities.ts +85 -0
- package/src/config-modal.ts +540 -0
- package/src/config-store.ts +303 -0
- package/src/debug-logger.ts +150 -0
- package/src/diff-presentation.ts +72 -0
- package/src/diff-renderer.ts +2517 -0
- package/src/disposable.ts +34 -0
- package/src/extension-lifecycle.ts +14 -0
- package/src/index.ts +138 -85
- package/src/line-width-safety.ts +93 -0
- package/src/modal-icons.ts +57 -0
- package/src/pending-diff-preview.ts +371 -0
- package/src/presets.ts +108 -0
- package/src/render-utils.ts +186 -0
- package/src/result-render-middleware.ts +98 -0
- package/src/settings-inspector-modal.ts +475 -0
- package/src/thinking-label.ts +343 -0
- package/src/tool-metadata.ts +139 -0
- package/src/tool-overrides.ts +2237 -0
- package/src/types.ts +105 -0
- package/src/user-message-box-markdown.ts +64 -0
- package/src/user-message-box-native.ts +66 -0
- package/src/user-message-box-patch.ts +72 -0
- package/src/user-message-box-renderer.ts +419 -0
- package/src/user-message-box-utils.ts +106 -0
- package/src/write-display-utils.ts +31 -0
- package/src/zellij-modal.ts +1027 -0
- package/tool-display-api-consumer.d.ts +68 -0
- package/tool-display-api-consumer.js +82 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface PendingDiffPreviewData {
|
|
6
|
+
filePath: string;
|
|
7
|
+
previousContent?: string;
|
|
8
|
+
nextContent?: string;
|
|
9
|
+
fileExistedBeforeWrite: boolean;
|
|
10
|
+
headerLabel: string;
|
|
11
|
+
notice?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type EditPreviewInput = {
|
|
15
|
+
path?: unknown;
|
|
16
|
+
file_path?: unknown;
|
|
17
|
+
oldText?: unknown;
|
|
18
|
+
newText?: unknown;
|
|
19
|
+
edits?: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type EditReplacement = {
|
|
23
|
+
oldText: string;
|
|
24
|
+
newText: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type FileReadResult = {
|
|
28
|
+
exists: boolean;
|
|
29
|
+
content?: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAX_PREVIEW_READ_BYTES = 1_000_000;
|
|
34
|
+
|
|
35
|
+
type ProjectedEditResult =
|
|
36
|
+
| {
|
|
37
|
+
ok: true;
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
ok: false;
|
|
42
|
+
reason: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function trimPath(value: unknown): string | undefined {
|
|
46
|
+
if (typeof value !== "string") {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const trimmed = value.trim();
|
|
51
|
+
return trimmed ? trimmed : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolvePreviewPath(cwd: string, rawPath: string): string {
|
|
55
|
+
const trimmed = rawPath.trim();
|
|
56
|
+
if (!trimmed) {
|
|
57
|
+
return cwd;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const expandedHome = trimmed === "~"
|
|
61
|
+
? homedir()
|
|
62
|
+
: trimmed.startsWith("~/") || trimmed.startsWith("~\\")
|
|
63
|
+
? `${homedir()}${trimmed.slice(1)}`
|
|
64
|
+
: trimmed;
|
|
65
|
+
|
|
66
|
+
return isAbsolute(expandedHome) ? expandedHome : resolve(cwd, expandedHome);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isWithinWorkspace(workspacePath: string, targetPath: string): boolean {
|
|
70
|
+
const relativePath = relative(workspacePath, targetPath);
|
|
71
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeRealpath(path: string): string {
|
|
75
|
+
try {
|
|
76
|
+
return realpathSync(path);
|
|
77
|
+
} catch {
|
|
78
|
+
return resolve(path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: string; error?: string } {
|
|
83
|
+
const workspacePath = safeRealpath(cwd);
|
|
84
|
+
// 相对路径必须基于规范化后的 workspace 解析。macOS 上 /var 通常会被
|
|
85
|
+
// realpath 展开为 /private/var,混用两种表示会把 workspace 内文件误判为越界。
|
|
86
|
+
const resolvedPath = resolvePreviewPath(workspacePath, rawPath);
|
|
87
|
+
|
|
88
|
+
if (!existsSync(resolvedPath)) {
|
|
89
|
+
if (!isWithinWorkspace(workspacePath, resolvedPath)) {
|
|
90
|
+
return {
|
|
91
|
+
resolvedPath,
|
|
92
|
+
error: "Preview unavailable because the target path is outside the current workspace.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { resolvedPath };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const targetPath = realpathSync(resolvedPath);
|
|
100
|
+
if (!isWithinWorkspace(workspacePath, targetPath)) {
|
|
101
|
+
return {
|
|
102
|
+
resolvedPath,
|
|
103
|
+
error: "Preview unavailable because the target path resolves outside the current workspace.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
return {
|
|
109
|
+
resolvedPath,
|
|
110
|
+
error: `Unable to resolve '${resolvedPath}': ${message}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { resolvedPath };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readWorkspaceUtf8File(cwd: string, rawPath: string): FileReadResult {
|
|
118
|
+
const safePath = resolveWorkspaceReadPath(cwd, rawPath);
|
|
119
|
+
if (safePath.error) {
|
|
120
|
+
return { exists: false, error: safePath.error };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!existsSync(safePath.resolvedPath)) {
|
|
124
|
+
return { exists: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const stats = statSync(safePath.resolvedPath);
|
|
129
|
+
if (!stats.isFile()) {
|
|
130
|
+
return {
|
|
131
|
+
exists: true,
|
|
132
|
+
error: `Preview unavailable because '${safePath.resolvedPath}' is not a regular file.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (stats.size > MAX_PREVIEW_READ_BYTES) {
|
|
136
|
+
return {
|
|
137
|
+
exists: true,
|
|
138
|
+
error: `Preview unavailable because '${safePath.resolvedPath}' exceeds the ${MAX_PREVIEW_READ_BYTES} byte preview read limit.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
exists: true,
|
|
144
|
+
content: readFileSync(safePath.resolvedPath, "utf8"),
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
return {
|
|
149
|
+
exists: true,
|
|
150
|
+
error: `Unable to read '${safePath.resolvedPath}': ${message}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function countSubstringMatches(haystack: string, needle: string): number {
|
|
156
|
+
if (!needle) {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let count = 0;
|
|
161
|
+
let cursor = 0;
|
|
162
|
+
while (cursor <= haystack.length) {
|
|
163
|
+
const index = haystack.indexOf(needle, cursor);
|
|
164
|
+
if (index === -1) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
count++;
|
|
168
|
+
cursor = index + 1;
|
|
169
|
+
}
|
|
170
|
+
return count;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stripBom(content: string): { bom: string; text: string } {
|
|
174
|
+
return content.startsWith("\uFEFF")
|
|
175
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
176
|
+
: { bom: "", text: content };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function detectLineEnding(content: string): "\r\n" | "\n" {
|
|
180
|
+
const crlfIndex = content.indexOf("\r\n");
|
|
181
|
+
const lfIndex = content.indexOf("\n");
|
|
182
|
+
if (lfIndex === -1 || crlfIndex === -1) {
|
|
183
|
+
return "\n";
|
|
184
|
+
}
|
|
185
|
+
return crlfIndex < lfIndex ? "\r\n" : "\n";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeToLf(content: string): string {
|
|
189
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function restoreLineEndings(content: string, ending: "\r\n" | "\n"): string {
|
|
193
|
+
return ending === "\r\n" ? content.replace(/\n/g, "\r\n") : content;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toEditInput(value: unknown): EditPreviewInput {
|
|
197
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return value as EditPreviewInput;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getToolPath(input: unknown, preferFilePath: boolean): string | undefined {
|
|
205
|
+
const record = toEditInput(input);
|
|
206
|
+
const filePath = trimPath(record.file_path);
|
|
207
|
+
const path = trimPath(record.path);
|
|
208
|
+
return preferFilePath ? filePath ?? path : path ?? filePath;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getWriteContent(input: unknown): string | undefined {
|
|
212
|
+
const record = toEditInput(input);
|
|
213
|
+
return typeof record.newText === "string"
|
|
214
|
+
? undefined
|
|
215
|
+
: typeof (record as { content?: unknown }).content === "string"
|
|
216
|
+
? (record as { content: string }).content
|
|
217
|
+
: undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getEditReplacements(input: unknown): EditReplacement[] {
|
|
221
|
+
const record = toEditInput(input);
|
|
222
|
+
if (Array.isArray(record.edits)) {
|
|
223
|
+
return record.edits.flatMap((entry) => {
|
|
224
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const edit = entry as { oldText?: unknown; newText?: unknown };
|
|
228
|
+
return typeof edit.oldText === "string" && typeof edit.newText === "string"
|
|
229
|
+
? [{ oldText: edit.oldText, newText: edit.newText }]
|
|
230
|
+
: [];
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return typeof record.oldText === "string" && typeof record.newText === "string"
|
|
235
|
+
? [{ oldText: record.oldText, newText: record.newText }]
|
|
236
|
+
: [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildProjectedEditContent(originalContent: string, replacements: readonly EditReplacement[]): ProjectedEditResult {
|
|
240
|
+
if (replacements.length === 0) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
reason: "Preview not shown: the edit request did not include exact replacement blocks.",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { bom, text } = stripBom(originalContent);
|
|
248
|
+
const originalLineEnding = detectLineEnding(text);
|
|
249
|
+
const normalizedContent = normalizeToLf(text);
|
|
250
|
+
const normalizedReplacements = replacements.map((replacement) => ({
|
|
251
|
+
oldText: normalizeToLf(replacement.oldText),
|
|
252
|
+
newText: normalizeToLf(replacement.newText),
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
const ranges: Array<{ start: number; end: number; replacement: string }> = [];
|
|
256
|
+
for (const [index, replacement] of normalizedReplacements.entries()) {
|
|
257
|
+
if (!replacement.oldText) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
reason: `Preview not shown: edit #${index + 1} has an empty oldText block.`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const matchCount = countSubstringMatches(normalizedContent, replacement.oldText);
|
|
265
|
+
if (matchCount !== 1) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
reason: matchCount === 0
|
|
269
|
+
? `Preview not shown: edit #${index + 1} did not match the current file contents.`
|
|
270
|
+
: `Preview not shown: edit #${index + 1} matched ${matchCount} regions instead of exactly one.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const start = normalizedContent.indexOf(replacement.oldText);
|
|
275
|
+
ranges.push({
|
|
276
|
+
start,
|
|
277
|
+
end: start + replacement.oldText.length,
|
|
278
|
+
replacement: replacement.newText,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
ranges.sort((left, right) => left.start - right.start);
|
|
283
|
+
for (let index = 1; index < ranges.length; index++) {
|
|
284
|
+
const previous = ranges[index - 1];
|
|
285
|
+
const current = ranges[index];
|
|
286
|
+
if (previous && current && current.start < previous.end) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: "Preview not shown: the requested edits overlap in the original file.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let cursor = 0;
|
|
295
|
+
let output = "";
|
|
296
|
+
for (const range of ranges) {
|
|
297
|
+
output += normalizedContent.slice(cursor, range.start);
|
|
298
|
+
output += range.replacement;
|
|
299
|
+
cursor = range.end;
|
|
300
|
+
}
|
|
301
|
+
output += normalizedContent.slice(cursor);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
content: `${bom}${restoreLineEndings(output, originalLineEnding)}`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function buildPendingWritePreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined {
|
|
310
|
+
const filePath = getToolPath(input, false);
|
|
311
|
+
const nextContent = getWriteContent(input);
|
|
312
|
+
if (!filePath || typeof nextContent !== "string") {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const existing = readWorkspaceUtf8File(cwd, filePath);
|
|
317
|
+
return {
|
|
318
|
+
filePath,
|
|
319
|
+
previousContent: existing.content,
|
|
320
|
+
nextContent,
|
|
321
|
+
fileExistedBeforeWrite: existing.exists,
|
|
322
|
+
headerLabel: existing.exists ? "pending overwrite" : "pending create",
|
|
323
|
+
notice: existing.error,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function buildPendingEditPreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined {
|
|
328
|
+
const filePath = getToolPath(input, true);
|
|
329
|
+
if (!filePath) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const existing = readWorkspaceUtf8File(cwd, filePath);
|
|
334
|
+
if (existing.error) {
|
|
335
|
+
return {
|
|
336
|
+
filePath,
|
|
337
|
+
fileExistedBeforeWrite: false,
|
|
338
|
+
headerLabel: "pending edit",
|
|
339
|
+
notice: existing.error,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!existing.exists || typeof existing.content !== "string") {
|
|
344
|
+
return {
|
|
345
|
+
filePath,
|
|
346
|
+
fileExistedBeforeWrite: false,
|
|
347
|
+
headerLabel: "pending edit",
|
|
348
|
+
notice: "Preview unavailable because the target file does not exist yet.",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const projected = buildProjectedEditContent(existing.content, getEditReplacements(input));
|
|
353
|
+
if (!projected.ok) {
|
|
354
|
+
const failedProjection = projected as Extract<ProjectedEditResult, { ok: false }>;
|
|
355
|
+
return {
|
|
356
|
+
filePath,
|
|
357
|
+
previousContent: existing.content,
|
|
358
|
+
fileExistedBeforeWrite: true,
|
|
359
|
+
headerLabel: "pending edit",
|
|
360
|
+
notice: failedProjection.reason,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
filePath,
|
|
366
|
+
previousContent: existing.content,
|
|
367
|
+
nextContent: projected.content,
|
|
368
|
+
fileExistedBeforeWrite: true,
|
|
369
|
+
headerLabel: "pending edit",
|
|
370
|
+
};
|
|
371
|
+
}
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { cloneCustomToolOverrides } from "./config-store.js";
|
|
2
|
+
import { DEFAULT_TOOL_DISPLAY_CONFIG, type CustomToolOverrideConfig, type ToolDisplayConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const TOOL_DISPLAY_PRESETS = ["opencode", "balanced", "verbose"] as const;
|
|
5
|
+
export type ToolDisplayPreset = (typeof TOOL_DISPLAY_PRESETS)[number];
|
|
6
|
+
|
|
7
|
+
const TOOL_DISPLAY_PRESET_CONFIGS: Record<ToolDisplayPreset, ToolDisplayConfig> = {
|
|
8
|
+
opencode: {
|
|
9
|
+
...DEFAULT_TOOL_DISPLAY_CONFIG,
|
|
10
|
+
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
|
|
11
|
+
},
|
|
12
|
+
balanced: {
|
|
13
|
+
...DEFAULT_TOOL_DISPLAY_CONFIG,
|
|
14
|
+
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
|
|
15
|
+
readOutputMode: "summary",
|
|
16
|
+
searchOutputMode: "count",
|
|
17
|
+
mcpOutputMode: "summary",
|
|
18
|
+
bashOutputMode: "summary",
|
|
19
|
+
},
|
|
20
|
+
verbose: {
|
|
21
|
+
...DEFAULT_TOOL_DISPLAY_CONFIG,
|
|
22
|
+
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
|
|
23
|
+
readOutputMode: "preview",
|
|
24
|
+
searchOutputMode: "preview",
|
|
25
|
+
mcpOutputMode: "preview",
|
|
26
|
+
bashOutputMode: "preview",
|
|
27
|
+
previewLines: 12,
|
|
28
|
+
bashCollapsedLines: 20,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function toolOverrideOwnershipEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean {
|
|
33
|
+
return (
|
|
34
|
+
a.registerToolOverrides.read === b.registerToolOverrides.read &&
|
|
35
|
+
a.registerToolOverrides.grep === b.registerToolOverrides.grep &&
|
|
36
|
+
a.registerToolOverrides.find === b.registerToolOverrides.find &&
|
|
37
|
+
a.registerToolOverrides.ls === b.registerToolOverrides.ls &&
|
|
38
|
+
a.registerToolOverrides.bash === b.registerToolOverrides.bash &&
|
|
39
|
+
a.registerToolOverrides.edit === b.registerToolOverrides.edit &&
|
|
40
|
+
a.registerToolOverrides.write === b.registerToolOverrides.write
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function customToolOverridesEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean {
|
|
45
|
+
const aEntries = Object.entries(a.customToolOverrides).sort(([left], [right]) => left.localeCompare(right));
|
|
46
|
+
const bEntries = Object.entries(b.customToolOverrides).sort(([left], [right]) => left.localeCompare(right));
|
|
47
|
+
if (aEntries.length !== bEntries.length) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return aEntries.every(([toolName, override], index) => {
|
|
52
|
+
const [otherToolName, otherOverride] = bEntries[index];
|
|
53
|
+
return (
|
|
54
|
+
toolName === otherToolName &&
|
|
55
|
+
override.enabled === otherOverride.enabled &&
|
|
56
|
+
override.kind === otherOverride.kind &&
|
|
57
|
+
override.outputMode === otherOverride.outputMode
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function configsEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean {
|
|
63
|
+
return (
|
|
64
|
+
toolOverrideOwnershipEqual(a, b) &&
|
|
65
|
+
customToolOverridesEqual(a, b) &&
|
|
66
|
+
a.enableNativeUserMessageBox === b.enableNativeUserMessageBox &&
|
|
67
|
+
a.readOutputMode === b.readOutputMode &&
|
|
68
|
+
a.searchOutputMode === b.searchOutputMode &&
|
|
69
|
+
a.mcpOutputMode === b.mcpOutputMode &&
|
|
70
|
+
a.previewLines === b.previewLines &&
|
|
71
|
+
a.expandedPreviewMaxLines === b.expandedPreviewMaxLines &&
|
|
72
|
+
a.bashOutputMode === b.bashOutputMode &&
|
|
73
|
+
a.bashCollapsedLines === b.bashCollapsedLines &&
|
|
74
|
+
a.diffViewMode === b.diffViewMode &&
|
|
75
|
+
a.diffIndicatorMode === b.diffIndicatorMode &&
|
|
76
|
+
a.diffSplitMinWidth === b.diffSplitMinWidth &&
|
|
77
|
+
a.diffCollapsedLines === b.diffCollapsedLines &&
|
|
78
|
+
a.diffWordWrap === b.diffWordWrap &&
|
|
79
|
+
a.showTruncationHints === b.showTruncationHints &&
|
|
80
|
+
a.showRtkCompactionHints === b.showRtkCompactionHints
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getToolDisplayPresetConfig(preset: ToolDisplayPreset): ToolDisplayConfig {
|
|
85
|
+
const config = TOOL_DISPLAY_PRESET_CONFIGS[preset];
|
|
86
|
+
return {
|
|
87
|
+
...config,
|
|
88
|
+
registerToolOverrides: { ...config.registerToolOverrides },
|
|
89
|
+
customToolOverrides: cloneCustomToolOverrides(config.customToolOverrides),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function detectToolDisplayPreset(config: ToolDisplayConfig): ToolDisplayPreset | "custom" {
|
|
94
|
+
for (const preset of TOOL_DISPLAY_PRESETS) {
|
|
95
|
+
if (configsEqual(config, TOOL_DISPLAY_PRESET_CONFIGS[preset])) {
|
|
96
|
+
return preset;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return "custom";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseToolDisplayPreset(raw: string): ToolDisplayPreset | undefined {
|
|
103
|
+
const normalized = raw.trim().toLowerCase();
|
|
104
|
+
if (!normalized) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return TOOL_DISPLAY_PRESETS.find((preset) => preset === normalized);
|
|
108
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { sanitizeAnsiForThemedOutput } from "./ansi-utils.js";
|
|
3
|
+
|
|
4
|
+
export { sanitizeAnsiForThemedOutput };
|
|
5
|
+
|
|
6
|
+
interface TextLikeContent {
|
|
7
|
+
type: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ToolResultLike {
|
|
12
|
+
content?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const QUIET_COMMAND_PREFIXES = [
|
|
16
|
+
"cd",
|
|
17
|
+
"mkdir",
|
|
18
|
+
"rmdir",
|
|
19
|
+
"rm",
|
|
20
|
+
"mv",
|
|
21
|
+
"cp",
|
|
22
|
+
"touch",
|
|
23
|
+
"chmod",
|
|
24
|
+
"chown",
|
|
25
|
+
"git add",
|
|
26
|
+
"git checkout",
|
|
27
|
+
"git switch",
|
|
28
|
+
"git restore",
|
|
29
|
+
"git reset",
|
|
30
|
+
"git clean",
|
|
31
|
+
"npm install",
|
|
32
|
+
"pnpm install",
|
|
33
|
+
"yarn install",
|
|
34
|
+
"bun install",
|
|
35
|
+
"pip install",
|
|
36
|
+
"poetry install",
|
|
37
|
+
"cargo fetch",
|
|
38
|
+
"go mod tidy",
|
|
39
|
+
"Set-Location",
|
|
40
|
+
"New-Item",
|
|
41
|
+
"Remove-Item",
|
|
42
|
+
"Move-Item",
|
|
43
|
+
"Copy-Item",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
interface CompactOutputOptions {
|
|
47
|
+
expanded: boolean;
|
|
48
|
+
maxCollapsedConsecutiveEmptyLines?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function trimTrailingEmptyLines(lines: string[]): string[] {
|
|
52
|
+
const next = [...lines];
|
|
53
|
+
while (next.length > 0 && next[next.length - 1]?.trim().length === 0) {
|
|
54
|
+
next.pop();
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function collapseConsecutiveEmptyLines(
|
|
60
|
+
lines: string[],
|
|
61
|
+
maxConsecutiveEmptyLines: number,
|
|
62
|
+
): string[] {
|
|
63
|
+
const maxAllowed = Math.max(0, maxConsecutiveEmptyLines);
|
|
64
|
+
if (maxAllowed === 0) {
|
|
65
|
+
return lines.filter((line) => line.trim().length > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const compacted: string[] = [];
|
|
69
|
+
let consecutiveEmpty = 0;
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (line.trim().length === 0) {
|
|
73
|
+
consecutiveEmpty++;
|
|
74
|
+
if (consecutiveEmpty > maxAllowed) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
consecutiveEmpty = 0;
|
|
79
|
+
}
|
|
80
|
+
compacted.push(line);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return compacted;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function shortenPath(inputPath: string | undefined): string {
|
|
87
|
+
if (!inputPath) {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
const home = homedir();
|
|
91
|
+
return inputPath.startsWith(home)
|
|
92
|
+
? `~${inputPath.slice(home.length)}`
|
|
93
|
+
: inputPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function extractTextOutput(result: ToolResultLike): string {
|
|
97
|
+
const rawBlocks = Array.isArray(result.content) ? result.content : [];
|
|
98
|
+
const blocks = rawBlocks.filter(
|
|
99
|
+
(block): block is TextLikeContent =>
|
|
100
|
+
typeof block === "object" &&
|
|
101
|
+
block !== null &&
|
|
102
|
+
"type" in block &&
|
|
103
|
+
(block as TextLikeContent).type === "text" &&
|
|
104
|
+
typeof (block as TextLikeContent).text === "string",
|
|
105
|
+
);
|
|
106
|
+
return blocks.map((block) => block.text ?? "").join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function splitLines(text: string): string[] {
|
|
110
|
+
if (!text) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
return text
|
|
114
|
+
.replace(/\r/g, "")
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => line.replace(/\t/g, " "));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function compactOutputLines(
|
|
120
|
+
lines: string[],
|
|
121
|
+
options: CompactOutputOptions,
|
|
122
|
+
): string[] {
|
|
123
|
+
const trimmed = trimTrailingEmptyLines(lines);
|
|
124
|
+
if (options.expanded) {
|
|
125
|
+
return trimmed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return collapseConsecutiveEmptyLines(
|
|
129
|
+
trimmed,
|
|
130
|
+
options.maxCollapsedConsecutiveEmptyLines ?? 1,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isLikelyQuietCommand(command: string | undefined): boolean {
|
|
135
|
+
if (!command) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const normalized = command.trim().toLowerCase();
|
|
140
|
+
if (!normalized) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const primarySegment = normalized
|
|
145
|
+
.split(/&&|\|\||;/)
|
|
146
|
+
.map((segment) => segment.trim())
|
|
147
|
+
.find((segment) => segment.length > 0);
|
|
148
|
+
|
|
149
|
+
if (!primarySegment) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const prefix of QUIET_COMMAND_PREFIXES) {
|
|
154
|
+
const normalizedPrefix = prefix.toLowerCase();
|
|
155
|
+
if (
|
|
156
|
+
primarySegment === normalizedPrefix ||
|
|
157
|
+
primarySegment.startsWith(`${normalizedPrefix} `)
|
|
158
|
+
) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function countNonEmptyLines(lines: string[]): number {
|
|
167
|
+
return lines.filter((line) => line.trim().length > 0).length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function pluralize(
|
|
171
|
+
count: number,
|
|
172
|
+
singular: string,
|
|
173
|
+
plural = `${singular}s`,
|
|
174
|
+
): string {
|
|
175
|
+
return count === 1 ? singular : plural;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function previewLines(
|
|
179
|
+
lines: string[],
|
|
180
|
+
maxLines: number,
|
|
181
|
+
): { shown: string[]; remaining: number } {
|
|
182
|
+
const limit = Math.max(0, maxLines);
|
|
183
|
+
const shown = lines.slice(0, limit);
|
|
184
|
+
const remaining = Math.max(0, lines.length - shown.length);
|
|
185
|
+
return { shown, remaining };
|
|
186
|
+
}
|