From 7b469d7e1fcd6f6543fc4c23bd30ac7e3af64d3f Mon Sep 17 00:00:00 2001 From: Eric Dahlseng Date: Wed, 29 Jul 2026 11:08:48 -0700 Subject: [PATCH] feat: add allowExternalDiffPreviews config option for target paths outside workspace --- README.md | 2 ++ config/config.example.json | 1 + src/config-modal.ts | 25 +++++++++++++++++ src/config-store.ts | 4 +++ src/pending-diff-preview.ts | 39 ++++++++++++++++++++------- src/presets.ts | 1 + src/tool-overrides.ts | 19 ++++++++----- src/types.ts | 2 ++ tests/tool-ui-utils.test.ts | 53 +++++++++++++++++++++++++++++++++++++ 9 files changed, 131 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6a83c8a..ed397d1 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ A starter template is included at `config/config.example.json`. | `diffSplitMinWidth` | number | `120` | Minimum width before auto mode prefers split diffs | | `diffCollapsedLines` | number | `24` | Diff lines shown before collapsing | | `diffWordWrap` | boolean | `true` | Wrap long diff lines when needed | +| `allowExternalDiffPreviews` | boolean | `false` | Allow pending edit/write diff previews for target paths outside workspace | | `showTruncationHints` | boolean | `false` | Show truncation indicators for compacted output | | `showRtkCompactionHints` | boolean | `false` | Show RTK compaction hints when RTK metadata exists | @@ -268,6 +269,7 @@ Notes: "diffSplitMinWidth": 120, "diffCollapsedLines": 24, "diffWordWrap": true, + "allowExternalDiffPreviews": false, "showTruncationHints": false, "showRtkCompactionHints": false } diff --git a/config/config.example.json b/config/config.example.json index fc650b7..2773ed7 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -34,6 +34,7 @@ "diffSplitMinWidth": 120, "diffCollapsedLines": 24, "diffWordWrap": true, + "allowExternalDiffPreviews": false, "showTruncationHints": false, "showRtkCompactionHints": false } diff --git a/src/config-modal.ts b/src/config-modal.ts index 689a90f..e743f73 100644 --- a/src/config-modal.ts +++ b/src/config-modal.ts @@ -289,6 +289,26 @@ function buildInspectorSettings( inspectorPath: configPath, searchTerms: ["diff", "indicator", "bars", "classic", "none", "marker"], }, + { + id: "allowExternalDiffPreviews", + label: "External diff previews", + currentValue: toOnOff(config.allowExternalDiffPreviews), + values: ["off", "on"], + inspectorTitle: "Allow External Diff Previews", + inspectorSummary: [ + "Allows edit and write diff previews for target files located outside the active workspace directory.", + "When enabled, pending diff previews will read existing target files across the filesystem instead of restricting previews to workspace paths.", + ], + inspectorOptions: [ + "off — restrict diff previews to target files within the active workspace", + "on — allow diff previews for target paths anywhere on disk", + ], + inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ + "This setting only affects presentation previews rendered by pi-tool-display.", + ]), + inspectorPath: configPath, + searchTerms: ["diff", "external", "preview", "workspace", "path", "outside"], + }, { id: "enableNativeUserMessageBox", label: "Native user message box", @@ -369,6 +389,11 @@ function applySetting(config: ToolDisplayConfig, id: string, value: string): Too ...config, diffIndicatorMode: value as ToolDisplayConfig["diffIndicatorMode"], }; + case "allowExternalDiffPreviews": + return { + ...config, + allowExternalDiffPreviews: value === "on", + }; default: return config; } diff --git a/src/config-store.ts b/src/config-store.ts index bf53590..ba20380 100644 --- a/src/config-store.ts +++ b/src/config-store.ts @@ -232,6 +232,10 @@ export function normalizeToolDisplayConfig(raw: unknown): ToolDisplayConfig { diffSplitMinWidth: clampNumber(source.diffSplitMinWidth, 70, 240, DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth), diffCollapsedLines: clampNumber(source.diffCollapsedLines, 4, 240, DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines), diffWordWrap: toBoolean(source.diffWordWrap, DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap), + allowExternalDiffPreviews: toBoolean( + source.allowExternalDiffPreviews, + DEFAULT_TOOL_DISPLAY_CONFIG.allowExternalDiffPreviews, + ), showTruncationHints: toBoolean(source.showTruncationHints, DEFAULT_TOOL_DISPLAY_CONFIG.showTruncationHints), showRtkCompactionHints: toBoolean( source.showRtkCompactionHints, diff --git a/src/pending-diff-preview.ts b/src/pending-diff-preview.ts index a3cf37b..6195276 100644 --- a/src/pending-diff-preview.ts +++ b/src/pending-diff-preview.ts @@ -79,11 +79,20 @@ function safeRealpath(path: string): string { } } -function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: string; error?: string } { +export interface ReadWorkspaceOptions { + allowExternalDiffPreviews?: boolean; +} + +function resolveWorkspaceReadPath( + cwd: string, + rawPath: string, + options?: ReadWorkspaceOptions, +): { resolvedPath: string; error?: string } { const workspacePath = safeRealpath(cwd); const resolvedPath = resolvePreviewPath(cwd, rawPath); + const canonicalResolvedPath = safeRealpath(resolvedPath); - if (!isWithinWorkspace(workspacePath, resolvedPath)) { + if (!options?.allowExternalDiffPreviews && !isWithinWorkspace(workspacePath, canonicalResolvedPath)) { return { resolvedPath, error: "Preview unavailable because the target path is outside the current workspace.", @@ -96,7 +105,7 @@ function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: try { const targetPath = realpathSync(resolvedPath); - if (!isWithinWorkspace(workspacePath, targetPath)) { + if (!options?.allowExternalDiffPreviews && !isWithinWorkspace(workspacePath, targetPath)) { return { resolvedPath, error: "Preview unavailable because the target path resolves outside the current workspace.", @@ -113,8 +122,12 @@ function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: return { resolvedPath }; } -export function readWorkspaceUtf8File(cwd: string, rawPath: string): FileReadResult { - const safePath = resolveWorkspaceReadPath(cwd, rawPath); +export function readWorkspaceUtf8File( + cwd: string, + rawPath: string, + options?: ReadWorkspaceOptions, +): FileReadResult { + const safePath = resolveWorkspaceReadPath(cwd, rawPath, options); if (safePath.error) { return { exists: false, error: safePath.error }; } @@ -305,14 +318,18 @@ function buildProjectedEditContent(originalContent: string, replacements: readon }; } -export function buildPendingWritePreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined { +export function buildPendingWritePreviewData( + input: unknown, + cwd: string, + options?: ReadWorkspaceOptions, +): PendingDiffPreviewData | undefined { const filePath = getToolPath(input, false); const nextContent = getWriteContent(input); if (!filePath || typeof nextContent !== "string") { return undefined; } - const existing = readWorkspaceUtf8File(cwd, filePath); + const existing = readWorkspaceUtf8File(cwd, filePath, options); return { filePath, previousContent: existing.content, @@ -323,13 +340,17 @@ export function buildPendingWritePreviewData(input: unknown, cwd: string): Pendi }; } -export function buildPendingEditPreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined { +export function buildPendingEditPreviewData( + input: unknown, + cwd: string, + options?: ReadWorkspaceOptions, +): PendingDiffPreviewData | undefined { const filePath = getToolPath(input, true); if (!filePath) { return undefined; } - const existing = readWorkspaceUtf8File(cwd, filePath); + const existing = readWorkspaceUtf8File(cwd, filePath, options); if (existing.error) { return { filePath, diff --git a/src/presets.ts b/src/presets.ts index 83436b4..fba23d4 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -76,6 +76,7 @@ function configsEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean { a.diffSplitMinWidth === b.diffSplitMinWidth && a.diffCollapsedLines === b.diffCollapsedLines && a.diffWordWrap === b.diffWordWrap && + a.allowExternalDiffPreviews === b.allowExternalDiffPreviews && a.showTruncationHints === b.showTruncationHints && a.showRtkCompactionHints === b.showRtkCompactionHints ); diff --git a/src/tool-overrides.ts b/src/tool-overrides.ts index e9b47bb..99117a4 100644 --- a/src/tool-overrides.ts +++ b/src/tool-overrides.ts @@ -43,6 +43,7 @@ import { buildPendingWritePreviewData, readWorkspaceUtf8File, type PendingDiffPreviewData, + type ReadWorkspaceOptions, } from "./pending-diff-preview.js"; import { buildPromptSnippetFromDescription, @@ -369,12 +370,13 @@ function createLazyClonedParameters(bootstrapTools: BuiltInTools): Record buildPendingEditPreviewData(args, context.cwd), + () => buildPendingEditPreviewData(args, context?.cwd ?? process.cwd(), { allowExternalDiffPreviews: config.allowExternalDiffPreviews }), ); - return buildPendingDiffCallComponent(summaryText, previewData, context, getConfig(), theme); + return buildPendingDiffCallComponent(summaryText, previewData, context, config, theme); } function renderEditDisplayResult( @@ -1761,7 +1764,10 @@ export function registerToolDisplayOverrides( parameters: clonedParameters.write, prepareArguments: getToolPrepareArguments(bootstrapTools.write), async execute(toolCallId, params, signal, onUpdate, ctx) { - const previous = captureExistingWriteContent(ctx.cwd, params.path); + const config = getConfig(); + const previous = captureExistingWriteContent(ctx.cwd, params.path, { + allowExternalDiffPreviews: config.allowExternalDiffPreviews, + }); recordWriteExecutionMeta(writeExecutionMetaByToolCallId, toolCallId, { fileExistedBeforeWrite: previous.existed, previousContent: previous.content, @@ -1790,14 +1796,15 @@ export function registerToolDisplayOverrides( return textResult(summaryText); } + const config = getConfig(); const previewKey = JSON.stringify({ path: getToolPathArg(args) ?? null, content: content ?? null }); const previewData = resolvePendingDiffPreview( context, WRITE_PENDING_PREVIEW_STATE_KEY, previewKey, - () => buildPendingWritePreviewData(args, context.cwd), + () => buildPendingWritePreviewData(args, context?.cwd ?? process.cwd(), { allowExternalDiffPreviews: config.allowExternalDiffPreviews }), ); - return buildPendingDiffCallComponent(summaryText, previewData, context, getConfig(), theme); + return buildPendingDiffCallComponent(summaryText, previewData, context, config, theme); }, renderResult(result, options, theme, context) { const content = getToolContentArg(context?.args); diff --git a/src/types.ts b/src/types.ts index eb6d39c..6af807d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export interface ToolDisplayConfig { diffSplitMinWidth: number; diffCollapsedLines: number; diffWordWrap: boolean; + allowExternalDiffPreviews: boolean; showTruncationHints: boolean; showRtkCompactionHints: boolean; } @@ -90,6 +91,7 @@ export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { diffSplitMinWidth: 120, diffCollapsedLines: 24, diffWordWrap: true, + allowExternalDiffPreviews: false, showTruncationHints: false, showRtkCompactionHints: false, }; diff --git a/tests/tool-ui-utils.test.ts b/tests/tool-ui-utils.test.ts index 6e0e575..189d23f 100644 --- a/tests/tool-ui-utils.test.ts +++ b/tests/tool-ui-utils.test.ts @@ -94,6 +94,59 @@ test("pending edit preview reports a concise notice for true edit mismatches", ( } }); +test("pending edit preview blocks external target paths when allowExternalDiffPreviews is false", () => { + const workspaceDir = mkdtempSync(join(tmpdir(), "pi-tool-display-workspace-")); + const externalDir = mkdtempSync(join(tmpdir(), "pi-tool-display-external-")); + + try { + const externalFilePath = join(externalDir, "external.txt"); + writeFileSync(externalFilePath, "outside content\n", "utf8"); + + const preview = buildPendingEditPreviewData( + { + path: externalFilePath, + edits: [{ oldText: "outside content", newText: "updated content" }], + }, + workspaceDir, + { allowExternalDiffPreviews: false }, + ); + + assert.equal( + preview?.notice, + "Preview unavailable because the target path is outside the current workspace.", + ); + } finally { + rmSync(workspaceDir, { recursive: true, force: true }); + rmSync(externalDir, { recursive: true, force: true }); + } +}); + +test("pending edit preview allows external target paths when allowExternalDiffPreviews is true", () => { + const workspaceDir = mkdtempSync(join(tmpdir(), "pi-tool-display-workspace-")); + const externalDir = mkdtempSync(join(tmpdir(), "pi-tool-display-external-")); + + try { + const externalFilePath = join(externalDir, "external.txt"); + writeFileSync(externalFilePath, "outside content\n", "utf8"); + + const preview = buildPendingEditPreviewData( + { + path: externalFilePath, + edits: [{ oldText: "outside content", newText: "updated content" }], + }, + workspaceDir, + { allowExternalDiffPreviews: true }, + ); + + assert.equal(preview?.notice, undefined); + assert.equal(preview?.previousContent, "outside content\n"); + assert.equal(preview?.nextContent, "updated content\n"); + } finally { + rmSync(workspaceDir, { recursive: true, force: true }); + rmSync(externalDir, { recursive: true, force: true }); + } +}); + test("write call summary moves metrics onto the first line when the result header omits them", () => { assert.equal( shouldRenderWriteCallSummary({