Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -268,6 +269,7 @@ Notes:
"diffSplitMinWidth": 120,
"diffCollapsedLines": 24,
"diffWordWrap": true,
"allowExternalDiffPreviews": false,
"showTruncationHints": false,
"showRtkCompactionHints": false
}
Expand Down
1 change: 1 addition & 0 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"diffSplitMinWidth": 120,
"diffCollapsedLines": 24,
"diffWordWrap": true,
"allowExternalDiffPreviews": false,
"showTruncationHints": false,
"showRtkCompactionHints": false
}
25 changes: 25 additions & 0 deletions src/config-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 30 additions & 9 deletions src/pending-diff-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
19 changes: 13 additions & 6 deletions src/tool-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
buildPendingWritePreviewData,
readWorkspaceUtf8File,
type PendingDiffPreviewData,
type ReadWorkspaceOptions,
} from "./pending-diff-preview.js";
import {
buildPromptSnippetFromDescription,
Expand Down Expand Up @@ -369,12 +370,13 @@ function createLazyClonedParameters(bootstrapTools: BuiltInTools): Record<keyof
function captureExistingWriteContent(
cwd: string,
rawPath: unknown,
options?: ReadWorkspaceOptions,
): { existed: boolean; content?: string } {
if (typeof rawPath !== "string" || !rawPath.trim()) {
return { existed: false };
}

const existing = readWorkspaceUtf8File(cwd, rawPath);
const existing = readWorkspaceUtf8File(cwd, rawPath, options);
return {
existed: existing.exists,
content: existing.content,
Expand Down Expand Up @@ -1397,6 +1399,7 @@ function renderEditDisplayCall(
return textResult(summaryText);
}

const config = getConfig();
const previewKey = JSON.stringify({
path: getAdapterPath(args, adapter) ?? null,
edits: toRecord(args).edits ?? null,
Expand All @@ -1407,9 +1410,9 @@ function renderEditDisplayCall(
context,
EDIT_PENDING_PREVIEW_STATE_KEY,
previewKey,
() => 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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface ToolDisplayConfig {
diffSplitMinWidth: number;
diffCollapsedLines: number;
diffWordWrap: boolean;
allowExternalDiffPreviews: boolean;
showTruncationHints: boolean;
showRtkCompactionHints: boolean;
}
Expand Down Expand Up @@ -90,6 +91,7 @@ export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
diffSplitMinWidth: 120,
diffCollapsedLines: 24,
diffWordWrap: true,
allowExternalDiffPreviews: false,
showTruncationHints: false,
showRtkCompactionHints: false,
};
Expand Down
53 changes: 53 additions & 0 deletions tests/tool-ui-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down