feat: show project-relative paths in tool call titles (#393)

## Summary
- Tool call titles (Read, Write, Edit) currently display absolute file
paths like `/Users/jane/my-project/src/main.ts`, which are long and
redundant since the user already knows their project root.
- This PR converts those paths to project-relative display paths (e.g.,
`src/main.ts`) by comparing against the session's `cwd`. Paths outside
the project directory remain absolute.
- The `locations.path` field is **not** changed, it stays absolute for
Zed's file navigation.

## Test plan
- [x] Added unit tests for `toDisplayPath` (relativizes paths inside
cwd, keeps absolute paths outside, handles partial directory name
matches)
- [x] Added unit test for `toolInfoFromToolUse` with `cwd` parameter
(verifies title uses relative path, locations stay absolute)
- [x] All 124 existing tests pass
This commit is contained in:
Rohan Patra
2026-03-09 09:13:08 -04:00
committed by GitHub
parent dd9fe3a98e
commit 9ecb222807
4 changed files with 83 additions and 9 deletions

View File

@@ -110,6 +110,7 @@ type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
cwd: string;
permissionMode: PermissionMode;
settingsManager: SettingsManager;
accumulatedUsage: AccumulatedUsage;
@@ -672,7 +673,10 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.client,
this.logger,
{ clientCapabilities: this.clientCapabilities },
{
clientCapabilities: this.clientCapabilities,
cwd: session.cwd,
},
)) {
await this.client.sessionUpdate(notification);
}
@@ -784,6 +788,7 @@ export class ClaudeAcpAgent implements Agent {
{
clientCapabilities: this.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
},
)) {
await this.client.sessionUpdate(notification);
@@ -958,7 +963,11 @@ export class ClaudeAcpAgent implements Agent {
toolUseCache,
this.client,
this.logger,
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
{
registerHooks: false,
clientCapabilities: this.clientCapabilities,
cwd: this.sessions[sessionId]?.cwd,
},
)) {
await this.client.sessionUpdate(notification);
}
@@ -1005,6 +1014,7 @@ export class ClaudeAcpAgent implements Agent {
...toolInfoFromToolUse(
{ name: toolName, input: toolInput, id: toolUseID },
supportsTerminalOutput,
session?.cwd,
),
},
});
@@ -1069,6 +1079,7 @@ export class ClaudeAcpAgent implements Agent {
...toolInfoFromToolUse(
{ name: toolName, input: toolInput, id: toolUseID },
supportsTerminalOutput,
session?.cwd,
),
},
});
@@ -1382,6 +1393,7 @@ export class ClaudeAcpAgent implements Agent {
query: q,
input: input,
cancelled: false,
cwd: params.cwd,
permissionMode,
settingsManager,
accumulatedUsage: {
@@ -1697,6 +1709,7 @@ export function toAcpNotifications(
registerHooks?: boolean;
clientCapabilities?: ClientCapabilities;
parentToolUseId?: string | null;
cwd?: string;
},
): SessionNotification[] {
const registerHooks = options?.registerHooks !== false;
@@ -1825,7 +1838,7 @@ export function toAcpNotifications(
toolCallId: chunk.id,
sessionUpdate: "tool_call_update",
rawInput,
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
};
} else {
// First encounter (streaming content_block_start or replay) —
@@ -1843,7 +1856,7 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
};
}
}
@@ -1949,7 +1962,10 @@ export function streamEventToAcpNotifications(
toolUseCache: ToolUseCache,
client: AgentSideConnection,
logger: Logger,
options?: { clientCapabilities?: ClientCapabilities },
options?: {
clientCapabilities?: ClientCapabilities;
cwd?: string;
},
): SessionNotification[] {
const event = message.event;
switch (event.type) {
@@ -1964,6 +1980,7 @@ export function streamEventToAcpNotifications(
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
},
);
case "content_block_delta":
@@ -1977,6 +1994,7 @@ export function streamEventToAcpNotifications(
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
},
);
// No content

View File

@@ -15,7 +15,12 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
export {
toolInfoFromToolUse,
toDisplayPath,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,

View File

@@ -20,6 +20,7 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import {
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
@@ -563,6 +564,20 @@ describe("tool conversions", () => {
});
});
it("should use relative path in title when cwd is provided", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01READ_CWD",
name: "Read",
input: { file_path: "/Users/test/project/src/main.ts" },
};
const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project");
expect(result.title).toBe("Read src/main.ts");
// locations.path stays absolute for navigation
expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]);
});
it("should handle plan entries", () => {
const received: SDKAssistantMessage = {
type: "assistant",
@@ -969,6 +984,22 @@ describe("tool conversions", () => {
});
});
describe("toDisplayPath", () => {
it("should relativize paths inside cwd and keep absolute paths outside", () => {
expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe(
"src/main.ts",
);
expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts");
expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe(
"/Users/test/project/src/main.ts",
);
// Partial directory name match should not be treated as inside cwd
expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe(
"/Users/test/project-other/file.ts",
);
});
});
describe("toolUpdateFromEditToolResponse", () => {
it("should return empty for non-object input", () => {
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
@@ -1279,6 +1310,7 @@ describe("stop reason propagation", () => {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd: "/test",
permissionMode: "default",
settingsManager: {} as any,
accumulatedUsage: {

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import {
ContentBlock,
PlanEntry,
@@ -103,9 +104,24 @@ interface ToolUpdate {
};
}
/**
* Convert an absolute file path to a project-relative path for display.
* Returns the original path if it's outside the project directory or if no cwd is provided.
*/
export function toDisplayPath(filePath: string, cwd?: string): string {
if (!cwd) return filePath;
const resolvedCwd = path.resolve(cwd);
const resolvedFile = path.resolve(filePath);
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
return path.relative(resolvedCwd, resolvedFile);
}
return filePath;
}
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
): ToolInfo {
const name = toolUse.name;
@@ -154,8 +170,9 @@ export function toolInfoFromToolUse(
} else if (input.offset) {
limit = " (from line " + input.offset + ")";
}
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
return {
title: "Read " + (input.file_path ?? "File") + limit,
title: "Read " + displayPath + limit,
kind: "read",
locations: input.file_path
? [
@@ -189,8 +206,9 @@ export function toolInfoFromToolUse(
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: displayPath ? `Write ${displayPath}` : "Write",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
@@ -210,8 +228,9 @@ export function toolInfoFromToolUse(
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
title: displayPath ? `Edit ${displayPath}` : "Edit",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],