feat(app): improve tool call sheet with word-level diff and better UX
- Add word-level highlighting using LCS algorithm for edit diffs - Match diff colors to explorer sidebar (GitHub-style rgba backgrounds) - Use discriminated union for tool call parsing (shell, edit, generic) - Add horizontal scroll for bash/command output instead of wrapping - Remove status badge from bottom sheet header - Fix sheet snap points (60%/95%) and disable dynamic sizing - Add min-width to diff viewer for full-width backgrounds 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
extractKeyValuePairs,
|
||||
parseToolCallDisplay,
|
||||
type ToolCallDisplay,
|
||||
} from "./tool-call-parsers";
|
||||
|
||||
describe("tool-call-parsers - real runtime shapes", () => {
|
||||
// Real data captured from Claude agent test: "shows the command inside pending tool calls"
|
||||
// Run: npx vitest run claude-agent.test.ts -t "shows the command"
|
||||
|
||||
test("bash tool call - input shape", () => {
|
||||
// REAL shape from Claude SDK timeline event (status: pending/completed)
|
||||
const bashInput = {
|
||||
command: "pwd",
|
||||
description: "Print working directory",
|
||||
};
|
||||
|
||||
const pairs = extractKeyValuePairs(bashInput);
|
||||
expect(pairs).toContainEqual({ key: "command", value: "pwd" });
|
||||
expect(pairs).toContainEqual({ key: "description", value: "Print working directory" });
|
||||
});
|
||||
|
||||
test("bash tool call - output shape (completed)", () => {
|
||||
// REAL shape from Claude SDK timeline event (status: completed)
|
||||
// NOTE: output already has type: "command" discriminator!
|
||||
const bashOutput = {
|
||||
type: "command",
|
||||
command: "pwd",
|
||||
output: "/private/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/claude-agent-e2e-9tnmUm",
|
||||
};
|
||||
|
||||
const pairs = extractKeyValuePairs(bashOutput);
|
||||
expect(pairs).toContainEqual({ key: "type", value: "command" });
|
||||
expect(pairs).toContainEqual({ key: "command", value: "pwd" });
|
||||
expect(pairs).toContainEqual({ key: "output", value: expect.stringContaining("claude-agent") });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay", () => {
|
||||
test("parses completed bash tool call into shell type", () => {
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const result = { type: "command", command: "pwd", output: "/some/path" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("pwd");
|
||||
expect(display.output).toBe("/some/path");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending bash tool call into shell type with empty output", () => {
|
||||
// When tool is pending, we have input but no result yet
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const result = undefined;
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("pwd");
|
||||
expect(display.output).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
test("handles command as array", () => {
|
||||
const input = { command: ["git", "status"] };
|
||||
const result = { type: "command", output: "On branch main" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("git status");
|
||||
expect(display.output).toBe("On branch main");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses non-command tool call into generic type", () => {
|
||||
const input = { file_path: "/some/file.txt" };
|
||||
const result = { content: "file contents here", lineCount: 42 };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("generic");
|
||||
if (display.type === "generic") {
|
||||
expect(display.input).toContainEqual({ key: "file_path", value: "/some/file.txt" });
|
||||
expect(display.output).toContainEqual({ key: "content", value: "file contents here" });
|
||||
expect(display.output).toContainEqual({ key: "lineCount", value: "42" });
|
||||
}
|
||||
});
|
||||
|
||||
test("handles file_write output as generic", () => {
|
||||
const input = { file_path: "/some/file.txt", content: "new content" };
|
||||
const result = { type: "file_write", filePath: "/some/file.txt" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("generic");
|
||||
});
|
||||
|
||||
test("handles undefined input and result gracefully", () => {
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(undefined, undefined);
|
||||
expect(display.type).toBe("generic");
|
||||
if (display.type === "generic") {
|
||||
expect(display.input).toEqual([]);
|
||||
expect(display.output).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("parses edit tool call into edit type with old_string/new_string", () => {
|
||||
const input = {
|
||||
file_path: "/some/file.txt",
|
||||
old_string: "const foo = 1;",
|
||||
new_string: "const foo = 2;",
|
||||
};
|
||||
const result = {
|
||||
type: "file_edit",
|
||||
filePath: "/some/file.txt",
|
||||
};
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
expect(display.oldString).toBe("const foo = 1;");
|
||||
expect(display.newString).toBe("const foo = 2;");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses edit tool call with old_str/new_str variants", () => {
|
||||
const input = {
|
||||
file_path: "/some/file.txt",
|
||||
old_str: "line 1",
|
||||
new_str: "line 2",
|
||||
};
|
||||
const result = undefined;
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
expect(display.oldString).toBe("line 1");
|
||||
expect(display.newString).toBe("line 2");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending edit tool call (no result yet)", () => {
|
||||
const input = {
|
||||
file_path: "/some/file.txt",
|
||||
old_string: "old content",
|
||||
new_string: "new content",
|
||||
};
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, undefined);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
expect(display.oldString).toBe("old content");
|
||||
expect(display.newString).toBe("new content");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type DiffSegment = {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
export type DiffLine = {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
segments?: DiffSegment[];
|
||||
};
|
||||
|
||||
export type EditEntry = {
|
||||
@@ -38,7 +44,112 @@ function splitIntoLines(text: string): string[] {
|
||||
return text.replace(/\r\n/g, "\n").split("\n");
|
||||
}
|
||||
|
||||
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
function splitIntoWords(text: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inWord = false;
|
||||
|
||||
for (const char of text) {
|
||||
const isWordChar = /\w/.test(char);
|
||||
if (isWordChar) {
|
||||
if (!inWord && current) {
|
||||
result.push(current);
|
||||
current = "";
|
||||
}
|
||||
inWord = true;
|
||||
current += char;
|
||||
} else {
|
||||
if (inWord && current) {
|
||||
result.push(current);
|
||||
current = "";
|
||||
}
|
||||
inWord = false;
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
result.push(current);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
|
||||
const oldWords = splitIntoWords(oldLine);
|
||||
const newWords = splitIntoWords(newLine);
|
||||
|
||||
const m = oldWords.length;
|
||||
const n = newWords.length;
|
||||
|
||||
// LCS to find common words
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
||||
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
if (oldWords[i] === newWords[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark which words are in LCS (unchanged)
|
||||
const oldInLCS = new Set<number>();
|
||||
const newInLCS = new Set<number>();
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (oldWords[i] === newWords[j]) {
|
||||
oldInLCS.add(i);
|
||||
newInLCS.add(j);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Build segments: consecutive unchanged or changed words merged
|
||||
const buildSegments = (words: string[], inLCS: Set<number>): DiffSegment[] => {
|
||||
if (words.length === 0) return [];
|
||||
|
||||
const segments: DiffSegment[] = [];
|
||||
let currentText = "";
|
||||
let currentChanged: boolean | null = null;
|
||||
|
||||
for (let idx = 0; idx < words.length; idx++) {
|
||||
const word = words[idx];
|
||||
const changed = !inLCS.has(idx);
|
||||
|
||||
if (currentChanged === null) {
|
||||
currentText = word;
|
||||
currentChanged = changed;
|
||||
} else if (changed === currentChanged) {
|
||||
currentText += word;
|
||||
} else {
|
||||
segments.push({ text: currentText, changed: currentChanged });
|
||||
currentText = word;
|
||||
currentChanged = changed;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, changed: currentChanged ?? false });
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
return {
|
||||
oldSegments: buildSegments(oldWords, oldInLCS),
|
||||
newSegments: buildSegments(newWords, newInLCS),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
const originalLines = splitIntoLines(originalText);
|
||||
const updatedLines = splitIntoLines(updatedText);
|
||||
|
||||
@@ -91,6 +202,22 @@ function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Post-process to add word-level segments for adjacent remove/add pairs
|
||||
for (let idx = 0; idx < diff.length - 1; idx++) {
|
||||
const curr = diff[idx];
|
||||
const next = diff[idx + 1];
|
||||
|
||||
if (curr.type === "remove" && next.type === "add") {
|
||||
// Strip the leading -/+ from content for comparison
|
||||
const oldLineText = curr.content.slice(1);
|
||||
const newLineText = next.content.slice(1);
|
||||
|
||||
const { oldSegments, newSegments } = computeWordLevelDiff(oldLineText, newLineText);
|
||||
curr.segments = oldSegments;
|
||||
next.segments = newSegments;
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
@@ -753,6 +880,118 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Tool Call Display Discriminated Union ----
|
||||
|
||||
const KeyValuePairsSchema = z.record(z.unknown()).transform((data) =>
|
||||
Object.entries(data).map(([key, value]) => ({
|
||||
key,
|
||||
value: stringifyValue(value),
|
||||
}))
|
||||
);
|
||||
|
||||
// Shell input: { command: "pwd", description?: "..." }
|
||||
const ShellInputSchema = z.object({
|
||||
command: z.union([z.string(), z.array(z.string())]),
|
||||
}).passthrough();
|
||||
|
||||
// Shell result (when completed): { type: "command", command: "pwd", output: "..." }
|
||||
const ShellResultSchema = z.object({
|
||||
type: z.literal("command"),
|
||||
output: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Shell tool call display schema
|
||||
const ShellToolCallSchema = z
|
||||
.object({
|
||||
input: ShellInputSchema,
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const command = Array.isArray(data.input.command)
|
||||
? data.input.command.join(" ")
|
||||
: data.input.command;
|
||||
|
||||
const resultParsed = ShellResultSchema.safeParse(data.result);
|
||||
const output = resultParsed.success ? resultParsed.data.output : "";
|
||||
|
||||
return {
|
||||
type: "shell" as const,
|
||||
command,
|
||||
output,
|
||||
};
|
||||
});
|
||||
|
||||
// Edit input: { file_path: string, old_string: string, new_string: string }
|
||||
// Also supports old_str/new_str variants
|
||||
const EditInputSchema = z.union([
|
||||
z.object({
|
||||
file_path: z.string(),
|
||||
old_string: z.string(),
|
||||
new_string: z.string(),
|
||||
}).passthrough(),
|
||||
z.object({
|
||||
file_path: z.string(),
|
||||
old_str: z.string(),
|
||||
new_str: z.string(),
|
||||
}).passthrough(),
|
||||
]);
|
||||
|
||||
// Edit result: { type: "file_edit", filePath: string, oldContent?: string, newContent?: string }
|
||||
const EditResultSchema = z.object({
|
||||
type: z.literal("file_edit"),
|
||||
filePath: z.string(),
|
||||
oldContent: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
// Edit tool call display schema
|
||||
const EditToolCallSchema = z
|
||||
.object({
|
||||
input: EditInputSchema,
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string } => {
|
||||
const filePath = data.input.file_path;
|
||||
const oldString = "old_string" in data.input
|
||||
? (data.input as { old_string: string }).old_string
|
||||
: (data.input as { old_str: string }).old_str;
|
||||
const newString = "new_string" in data.input
|
||||
? (data.input as { new_string: string }).new_string
|
||||
: (data.input as { new_str: string }).new_str;
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
oldString,
|
||||
newString,
|
||||
};
|
||||
});
|
||||
|
||||
// Generic tool call display schema (fallback)
|
||||
const GenericToolCallSchema = z
|
||||
.object({
|
||||
input: z.unknown(),
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const inputPairs = KeyValuePairsSchema.safeParse(data.input);
|
||||
const resultPairs = KeyValuePairsSchema.safeParse(data.result);
|
||||
|
||||
return {
|
||||
type: "generic" as const,
|
||||
input: inputPairs.success ? inputPairs.data : [],
|
||||
output: resultPairs.success ? resultPairs.data : [],
|
||||
};
|
||||
});
|
||||
|
||||
const ToolCallDisplaySchema = z.union([ShellToolCallSchema, EditToolCallSchema, GenericToolCallSchema]);
|
||||
|
||||
export type ToolCallDisplay = z.infer<typeof ToolCallDisplaySchema>;
|
||||
|
||||
export function parseToolCallDisplay(input: unknown, result: unknown): ToolCallDisplay {
|
||||
return ToolCallDisplaySchema.parse({ input, result });
|
||||
}
|
||||
|
||||
// ---- Principal Parameter Extraction ----
|
||||
// Re-export from server to avoid drift
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user