Add fetch tool details and improve sidebar shortcuts handling

This commit is contained in:
Mohamed Boudra
2026-03-19 13:04:44 +07:00
parent 675d825f6b
commit 4ab307b10f
26 changed files with 960 additions and 197 deletions
@@ -147,6 +147,30 @@ export type ToolCallDetail =
| {
type: "search";
query: string;
toolName?: "search" | "grep" | "glob" | "web_search";
content?: string;
filePaths?: string[];
webResults?: Array<{
title: string;
url: string;
}>;
annotations?: string[];
numFiles?: number;
numMatches?: number;
durationMs?: number;
durationSeconds?: number;
truncated?: boolean;
mode?: "content" | "files_with_matches" | "count";
}
| {
type: "fetch";
url: string;
prompt?: string;
result?: string;
code?: number;
codeText?: string;
bytes?: number;
durationMs?: number;
}
| {
type: "worktree_setup";
@@ -7,11 +7,17 @@ import {
ToolReadInputSchema,
ToolReadOutputSchema,
ToolSearchInputSchema,
ToolGrepOutputSchema,
ToolGlobOutputSchema,
ToolShellInputSchema,
ToolShellOutputSchema,
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
ToolWebSearchOutputSchema,
ToolWriteInputSchema,
ToolWriteOutputSchema,
toEditToolDetail,
toFetchToolDetail,
toReadToolDetail,
toSearchToolDetail,
toShellToolDetail,
@@ -87,27 +93,32 @@ const ClaudeToolDetailPass2Schema = z.union([
ToolEditOutputSchema,
toEditToolDetail
),
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "web_search" })
),
toolDetailBranchByName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toolDetailBranchByName("web_search", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "web_search" })
),
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toSearchToolDetail({ input, toolName: "search" })
),
toolDetailBranchByName("Grep", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toolDetailBranchByName("Grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "grep" })
),
toolDetailBranchByName("grep", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toolDetailBranchByName("grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "grep" })
),
toolDetailBranchByName("Glob", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toolDetailBranchByName("Glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "glob" })
),
toolDetailBranchByName("glob", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toolDetailBranchByName("glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
toSearchToolDetail({ input, output, toolName: "glob" })
),
toolDetailBranchByName("WebFetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("web_fetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("WebFetchTool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("web_fetch_tool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName("webfetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
toolDetailBranchByName(
"Skill",
z.object({ skill: z.string() }).passthrough(),
@@ -103,6 +103,7 @@ describe("claude tool-call mapper", () => {
expect(searchItem.detail).toEqual({
type: "search",
query: "tool call mapping",
toolName: "web_search",
});
});
@@ -220,6 +221,7 @@ describe("claude tool-call mapper", () => {
expect(searchItem.detail).toEqual({
type: "search",
query: "tool call mapping",
toolName: "web_search",
});
});
@@ -248,7 +250,12 @@ describe("claude tool-call mapper", () => {
callId: "claude-glob-1",
name: "Glob",
input: { pattern: "**/.claude/commands/paseo*" },
output: { output: "No files found" },
output: {
durationMs: 7,
numFiles: 2,
filenames: ["a.txt", "b.txt"],
truncated: false,
},
})
);
@@ -258,6 +265,11 @@ describe("claude tool-call mapper", () => {
expect(item.detail).toEqual({
type: "search",
query: "**/.claude/commands/paseo*",
toolName: "glob",
filePaths: ["a.txt", "b.txt"],
numFiles: 2,
durationMs: 7,
truncated: false,
});
});
@@ -272,7 +284,14 @@ describe("claude tool-call mapper", () => {
output_mode: "content",
"-n": true,
},
output: { output: "No matches found" },
output: {
mode: "content",
numFiles: 1,
filenames: ["src/main.rs"],
content: "12:const cli = true;",
numLines: 1,
numMatches: 1,
},
})
);
@@ -282,6 +301,80 @@ describe("claude tool-call mapper", () => {
expect(item.detail).toEqual({
type: "search",
query: '\\\\\\"cli\\\\\\""',
toolName: "grep",
content: "12:const cli = true;",
filePaths: ["src/main.rs"],
numFiles: 1,
numMatches: 1,
mode: "content",
});
});
it("maps WebSearch calls with structured results", () => {
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-web-search-1",
name: "WebSearch",
input: { query: "OpenAI latest news" },
output: {
query: "OpenAI latest news",
results: [
"Top results:",
{
tool_use_id: "toolu_123",
content: [
{ title: "OpenAI launches thing", url: "https://example.com/1" },
{ title: "Another result", url: "https://example.com/2" },
],
},
],
durationSeconds: 1.5,
},
})
);
expect(item.detail).toEqual({
type: "search",
query: "OpenAI latest news",
toolName: "web_search",
webResults: [
{ title: "OpenAI launches thing", url: "https://example.com/1" },
{ title: "Another result", url: "https://example.com/2" },
],
annotations: ["Top results:"],
durationSeconds: 1.5,
});
});
it("maps WebFetch calls with fetched content", () => {
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-web-fetch-1",
name: "WebFetch",
input: {
url: "https://example.com/article",
prompt: "Summarize this page",
},
output: {
bytes: 5120,
code: 200,
codeText: "OK",
result: "Summary text",
durationMs: 250,
url: "https://example.com/article",
},
})
);
expect(item.detail).toEqual({
type: "fetch",
url: "https://example.com/article",
prompt: "Summarize this page",
result: "Summary text",
code: 200,
codeText: "OK",
bytes: 5120,
durationMs: 250,
});
});
@@ -80,6 +80,13 @@ const ClaudeSearchToolNameSchema = z.union([
z.literal("Glob"),
z.literal("glob"),
]);
const ClaudeFetchToolNameSchema = z.union([
z.literal("WebFetch"),
z.literal("web_fetch"),
z.literal("WebFetchTool"),
z.literal("web_fetch_tool"),
z.literal("webfetch"),
]);
const ClaudeSpeakToolNameSchema = z
.string()
.min(1)
@@ -91,6 +98,7 @@ const ClaudeToolKindSchema = z.enum([
"write",
"edit",
"search",
"fetch",
"speak",
"unknown",
]);
@@ -146,6 +154,13 @@ const ClaudeToolCallPass2EnvelopeSchema = z.union([
name: normalized.name.trim(),
toolKind: "search" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeFetchToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "fetch" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeSpeakToolNameSchema,
}).transform((normalized) => ({
@@ -181,6 +196,10 @@ const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
toolKind: z.literal("search"),
name: ClaudeSearchToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("fetch"),
name: ClaudeFetchToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"),
name: z.literal("speak"),
@@ -203,8 +222,8 @@ function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimeli
? "write_file"
: normalized.toolKind === "edit"
? "apply_patch"
: normalized.toolKind === "search"
? "search"
: normalized.toolKind === "search" || normalized.toolKind === "fetch"
? normalized.name
: normalized.toolKind === "speak"
? "speak"
: normalized.name;
@@ -135,6 +135,10 @@ function normalizeCodexModelId(modelId: string | null | undefined): string | und
return normalized;
}
function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, 'GPT');
}
type CodexConfiguredDefaults = {
model?: string;
thinkingOptionId?: string;
@@ -3389,7 +3393,7 @@ export class CodexAppServerAgentClient implements AgentClient {
return {
provider: CODEX_PROVIDER,
id: model.id,
label: model.displayName,
label: normalizeCodexModelLabel(model.displayName),
description: model.description,
isDefault: isDefaultModel,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
@@ -150,10 +150,10 @@ const CodexToolDetailPass2Schema = z.union([
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toSearchToolDetail({ input, toolName: "search" })
),
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toSearchToolDetail({ input, toolName: "web_search" })
),
CodexSpeakToolDetailSchema,
]);
@@ -151,6 +151,7 @@ describe("codex tool-call mapper", () => {
expect(searchItem?.detail).toEqual({
type: "search",
query: "codex timeline",
toolName: "web_search",
});
});
@@ -338,6 +339,7 @@ describe("codex tool-call mapper", () => {
expect(searchItem?.detail).toEqual({
type: "search",
query: "codex timeline",
toolName: "web_search",
});
});
@@ -38,10 +38,10 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toSearchToolDetail({ input, toolName: "search" })
),
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
toSearchToolDetail({ input, toolName: "web_search" })
),
]);
@@ -86,6 +86,7 @@ describe("opencode tool-call mapper", () => {
expect(searchItem.detail).toEqual({
type: "search",
query: "opencode mapper",
toolName: "web_search",
});
});
@@ -210,6 +211,7 @@ describe("opencode tool-call mapper", () => {
expect(searchItem.detail).toEqual({
type: "search",
query: "opencode mapper",
toolName: "web_search",
});
});
@@ -512,6 +512,71 @@ export const ToolSearchInputSchema = z.union([
z.object({ pattern: z.string() }).passthrough().transform((value) => ({ query: value.pattern })),
]);
export const ToolGlobOutputSchema = z
.object({
durationMs: z.number().finite(),
numFiles: z.number().int().nonnegative(),
filenames: z.array(z.string()),
truncated: z.boolean(),
})
.passthrough();
export const ToolGrepOutputSchema = z
.object({
mode: z.enum(["content", "files_with_matches", "count"]).optional(),
numFiles: z.number().int().nonnegative(),
filenames: z.array(z.string()),
content: z.string().optional(),
numLines: z.number().int().nonnegative().optional(),
numMatches: z.number().int().nonnegative().optional(),
appliedLimit: z.number().int().nonnegative().optional(),
appliedOffset: z.number().int().nonnegative().optional(),
})
.passthrough();
export const ToolWebFetchInputSchema = z
.object({
url: z.string(),
prompt: z.string(),
})
.passthrough();
export const ToolWebFetchOutputSchema = z
.object({
bytes: z.number().int().nonnegative(),
code: z.number().int(),
codeText: z.string(),
result: z.string(),
durationMs: z.number().finite(),
url: z.string(),
})
.passthrough();
const ToolWebSearchResultHitSchema = z
.object({
title: z.string(),
url: z.string(),
})
.passthrough();
const ToolWebSearchResultEntrySchema = z.union([
z
.object({
tool_use_id: z.string(),
content: z.array(ToolWebSearchResultHitSchema),
})
.passthrough(),
z.string(),
]);
export const ToolWebSearchOutputSchema = z
.object({
query: z.string(),
results: z.array(ToolWebSearchResultEntrySchema),
durationSeconds: z.number().finite(),
})
.passthrough();
export type ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>;
@@ -522,6 +587,11 @@ export type ParsedToolWriteOutput = z.infer<typeof ToolWriteOutputSchema>;
export type ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
export type ParsedToolEditOutput = z.infer<typeof ToolEditOutputSchema>;
export type ParsedToolSearchInput = z.infer<typeof ToolSearchInputSchema>;
export type ParsedToolGlobOutput = z.infer<typeof ToolGlobOutputSchema>;
export type ParsedToolGrepOutput = z.infer<typeof ToolGrepOutputSchema>;
export type ParsedToolWebFetchInput = z.infer<typeof ToolWebFetchInputSchema>;
export type ParsedToolWebFetchOutput = z.infer<typeof ToolWebFetchOutputSchema>;
export type ParsedToolWebSearchOutput = z.infer<typeof ToolWebSearchOutputSchema>;
type NormalizePathFn = (filePath: string) => string | undefined;
@@ -539,6 +609,24 @@ function normalizeDetailPath(
return normalizePath ? normalizePath(trimmed) : trimmed;
}
function isParsedToolGlobOutput(
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
): output is ParsedToolGlobOutput {
return Boolean(output && "truncated" in output);
}
function isParsedToolGrepOutput(
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
): output is ParsedToolGrepOutput {
return Boolean(output && "filenames" in output && !("truncated" in output));
}
function isParsedToolWebSearchOutput(
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
): output is ParsedToolWebSearchOutput {
return Boolean(output && "results" in output);
}
export function toShellToolDetail(
input: ParsedToolShellInput | null,
output: ParsedToolShellOutput | null
@@ -625,15 +713,70 @@ export function toEditToolDetail(
};
}
export function toSearchToolDetail(
input: ParsedToolSearchInput | null
): ToolCallDetail | undefined {
export function toSearchToolDetail(params: {
input: ParsedToolSearchInput | null;
output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null;
toolName?: "search" | "grep" | "glob" | "web_search";
}): ToolCallDetail | undefined {
const { input, output, toolName } = params;
if (!input?.query) {
return undefined;
}
const filePaths =
isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)
? output.filenames.length > 0
? output.filenames
: undefined
: undefined;
const webResults = isParsedToolWebSearchOutput(output)
? output.results.flatMap((entry) => (typeof entry === "string" ? [] : entry.content))
: undefined;
const annotations = isParsedToolWebSearchOutput(output)
? output.results.filter((entry): entry is string => typeof entry === "string")
: undefined;
return {
type: "search",
query: input.query,
...(toolName ? { toolName } : {}),
...(isParsedToolGrepOutput(output) && output.content ? { content: output.content } : {}),
...(filePaths ? { filePaths } : {}),
...(webResults && webResults.length > 0 ? { webResults } : {}),
...(annotations && annotations.length > 0 ? { annotations } : {}),
...(isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)
? { numFiles: output.numFiles }
: {}),
...(isParsedToolGrepOutput(output) && output.numMatches !== undefined
? { numMatches: output.numMatches }
: {}),
...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}),
...(isParsedToolWebSearchOutput(output)
? { durationSeconds: output.durationSeconds }
: {}),
...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}),
...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}),
};
}
export function toFetchToolDetail(
input: ParsedToolWebFetchInput | null,
output: ParsedToolWebFetchOutput | null
): ToolCallDetail | undefined {
const url = input?.url ?? output?.url;
if (!url) {
return undefined;
}
return {
type: "fetch",
url,
...(input?.prompt ? { prompt: input.prompt } : {}),
...(output?.result ? { result: output.result } : {}),
...(output?.code !== undefined ? { code: output.code } : {}),
...(output?.codeText ? { codeText: output.codeText } : {}),
...(output?.bytes !== undefined ? { bytes: output.bytes } : {}),
...(output?.durationMs !== undefined ? { durationMs: output.durationMs } : {}),
};
}
+26
View File
@@ -181,6 +181,32 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
z.object({
type: z.literal('search'),
query: z.string(),
toolName: z.enum(['search', 'grep', 'glob', 'web_search']).optional(),
content: z.string().optional(),
filePaths: z.array(z.string()).optional(),
webResults: z.array(
z.object({
title: z.string(),
url: z.string(),
})
).optional(),
annotations: z.array(z.string()).optional(),
numFiles: z.number().optional(),
numMatches: z.number().optional(),
durationMs: z.number().optional(),
durationSeconds: z.number().optional(),
truncated: z.boolean().optional(),
mode: z.enum(['content', 'files_with_matches', 'count']).optional(),
}),
z.object({
type: z.literal('fetch'),
url: z.string(),
prompt: z.string().optional(),
result: z.string().optional(),
code: z.number().optional(),
codeText: z.string().optional(),
bytes: z.number().optional(),
durationMs: z.number().optional(),
}),
z.object({
type: z.literal('worktree_setup'),
@@ -90,6 +90,11 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
displayName: "Search",
summary: input.detail.query,
};
case "fetch":
return {
displayName: "Fetch",
summary: input.detail.url,
};
case "worktree_setup":
return {
displayName: "Worktree Setup",