feat(ui): color-coded diff rendering in the permission prompt
The pre-confirmation preview for mutating tools (edit_file/multi_edit/write_file/ bash/git_commit) was rendered as plain monochrome text, throwing away the most useful signal in the thing the user evaluates before approving. - src/utils/diff.ts: looksLikeDiff() — distinguishes a unified diff from a plain preview (bash command, 'Create new file ...', JSON args). - src/ui/ink/DiffView.tsx: renders a unified diff with additions green, removals red, hunk/file headers dimmed. Optional side-by-side mode (pairHunk + clip, both exported for testing). Ported from origin/v0.6.0. - src/ui/theme.ts: DIFF_ADD_HEX / DIFF_REMOVE_HEX. - PermissionPrompt.tsx: use DiffView when the preview is a diff, keep the line-by-line render for non-diff previews. Tests: diff.test.ts (4), DiffView.test.ts (8) for pairHunk/clip/looksLikeDiff. Verified: typecheck clean, build 261.50 KB, 275 tests pass (+12).
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pairHunk, clip } from "./DiffView.js";
|
||||
|
||||
describe("pairHunk", () => {
|
||||
it("pairs a deletion immediately followed by an addition as one change row", () => {
|
||||
const rows = pairHunk(["-old", "+new"]);
|
||||
expect(rows).toEqual([{ left: "old", leftKind: "del", right: "new", rightKind: "add" }]);
|
||||
});
|
||||
|
||||
it("treats context lines as both sides", () => {
|
||||
const rows = pairHunk([" ctx"]);
|
||||
expect(rows).toEqual([{ left: "ctx", leftKind: "ctx", right: "ctx", rightKind: "ctx" }]);
|
||||
});
|
||||
|
||||
it("pads a pure deletion with an empty right", () => {
|
||||
const rows = pairHunk(["-gone", "-also"]);
|
||||
expect(rows).toEqual([
|
||||
{ left: "gone", leftKind: "del", right: "", rightKind: "empty" },
|
||||
{ left: "also", leftKind: "del", right: "", rightKind: "empty" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pads a pure insertion with an empty left", () => {
|
||||
const rows = pairHunk(["+ins"]);
|
||||
expect(rows).toEqual([{ left: "", leftKind: "empty", right: "ins", rightKind: "add" }]);
|
||||
});
|
||||
|
||||
it("renders a no-newline marker line as context", () => {
|
||||
const rows = pairHunk(["\\ No newline at end of file"]);
|
||||
expect(rows[0]?.leftKind).toBe("ctx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clip", () => {
|
||||
it("returns the string unchanged when it fits", () => {
|
||||
expect(clip("hi", 10)).toBe("hi");
|
||||
});
|
||||
it("truncates with an ellipsis when it overflows", () => {
|
||||
expect(clip("abcdefghij", 5)).toBe("abcd…");
|
||||
});
|
||||
it("returns empty for non-positive width", () => {
|
||||
expect(clip("x", 0)).toBe("");
|
||||
expect(clip("x", -1)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import type { ReactNode } from "react";
|
||||
import { DIFF_ADD_HEX, DIFF_REMOVE_HEX } from "../theme.js";
|
||||
|
||||
/** A single row of a side-by-side diff: the old (left) and new (right) versions of one line, plus
|
||||
* each side's kind so the renderer can color it. `empty` is a padding row used when one side has a
|
||||
* line the other doesn't (a pure insertion or deletion). */
|
||||
interface DiffRow {
|
||||
left: string;
|
||||
leftKind: "ctx" | "add" | "del" | "empty";
|
||||
right: string;
|
||||
rightKind: "ctx" | "add" | "del" | "empty";
|
||||
}
|
||||
|
||||
/** Splits a unified-diff hunk body (the lines after a `@@ … @@` header, each starting with ` `,
|
||||
* `+`, or `-`) into paired old/new rows. A deletion immediately followed by an addition is treated
|
||||
* as a change and shown on one row (old left, new right); runs of pure deletions or additions are
|
||||
* padded with `empty` on the opposite side so the two columns stay aligned. Exported for testing. */
|
||||
export function pairHunk(body: string[]): DiffRow[] {
|
||||
const rows: DiffRow[] = [];
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const line = body[i] ?? "";
|
||||
const tag = line[0];
|
||||
const rest = line.slice(1);
|
||||
if (tag === " ") {
|
||||
rows.push({ left: rest, leftKind: "ctx", right: rest, rightKind: "ctx" });
|
||||
} else if (tag === "-") {
|
||||
const next = body[i + 1] ?? "";
|
||||
if (next[0] === "+") {
|
||||
rows.push({ left: rest, leftKind: "del", right: next.slice(1), rightKind: "add" });
|
||||
i++; // consume the paired addition
|
||||
} else {
|
||||
rows.push({ left: rest, leftKind: "del", right: "", rightKind: "empty" });
|
||||
}
|
||||
} else if (tag === "+") {
|
||||
// An addition not preceded by a paired deletion (the deletion branch above consumes its
|
||||
// paired addition) — a pure insertion.
|
||||
rows.push({ left: "", leftKind: "empty", right: rest, rightKind: "add" });
|
||||
} else {
|
||||
// Blank or unexpected line (e.g. a trailing "\ No newline at end of file") — render as context.
|
||||
rows.push({ left: line, leftKind: "ctx", right: line, rightKind: "ctx" });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Truncates `s` to `width` columns with an ellipsis when it overflows, so the two side-by-side
|
||||
* columns stay row-aligned even when a line is longer than half the terminal. Exported for testing. */
|
||||
export function clip(s: string, width: number): string {
|
||||
if (width <= 0) return "";
|
||||
if (s.length <= width) return s;
|
||||
return s.slice(0, Math.max(0, width - 1)) + "…";
|
||||
}
|
||||
|
||||
function colorFor(kind: DiffRow["leftKind"]): string | undefined {
|
||||
if (kind === "add") return DIFF_ADD_HEX;
|
||||
if (kind === "del") return DIFF_REMOVE_HEX;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Renders a unified-diff string with color-coded lines: additions green, removals red, hunk
|
||||
* headers (`@@ … @@`) and the `Index:`/`--- `/`+++ ` file headers dimmed. In `sideBySide` mode the
|
||||
* old and new versions are shown in two columns (old left, new right, separated by a dim ` │ `),
|
||||
* with deletions red on the left and additions green on the right — easier to compare at a glance
|
||||
* than a unified diff, at the cost of horizontal space. Used both for the pre-confirmation
|
||||
* permission preview and the post-confirmation scrollback entry. */
|
||||
export function DiffView({ diff, sideBySide = false }: { diff: string; sideBySide?: boolean }) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const lines = diff.split("\n");
|
||||
|
||||
if (!sideBySide) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, i) => {
|
||||
let color: string | undefined;
|
||||
let dim = false;
|
||||
const c = line[0];
|
||||
if (c === "+" && !line.startsWith("+++")) {
|
||||
color = DIFF_ADD_HEX;
|
||||
} else if (c === "-" && !line.startsWith("---")) {
|
||||
color = DIFF_REMOVE_HEX;
|
||||
} else if (c === "@") {
|
||||
dim = true; // hunk header
|
||||
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
|
||||
dim = true; // file headers
|
||||
}
|
||||
return (
|
||||
<Text key={i} color={color} dimColor={dim}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Side-by-side: reserve 3 columns for the " │ " gutter; split the rest evenly. Guard against the
|
||||
// no-stdout / very-narrow case so a tiny terminal doesn't produce negative widths. Each cell keeps
|
||||
// a 1-column sign prefix (` ` context, `-` deletion, `+` addition) so the kind is legible without
|
||||
// relying on color alone.
|
||||
const colWidth = Math.max(10, Math.floor((columns - 3) / 2));
|
||||
|
||||
// Walk the unified diff and group lines under their hunk headers, pairing each hunk body into rows.
|
||||
const rendered: ReactNode[] = [];
|
||||
let hunkBody: string[] = [];
|
||||
let hunkIdx = 0;
|
||||
const flushHunk = () => {
|
||||
if (!hunkBody.length) return;
|
||||
const rows = pairHunk(hunkBody);
|
||||
rows.forEach((row, r) => {
|
||||
const key = `${hunkIdx}-${r}`;
|
||||
rendered.push(
|
||||
<Box key={key} flexDirection="row">
|
||||
<Text color={colorFor(row.leftKind)} dimColor={row.leftKind === "ctx"}>
|
||||
{row.leftKind === "empty" ? "" : `${row.leftKind === "del" ? "-" : " "}${clip(row.left, colWidth - 1)}`}
|
||||
</Text>
|
||||
<Text dimColor> │ </Text>
|
||||
<Text color={colorFor(row.rightKind)} dimColor={row.rightKind === "ctx"}>
|
||||
{row.rightKind === "empty" ? "" : `${row.rightKind === "add" ? "+" : " "}${clip(row.right, colWidth - 1)}`}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
hunkBody = [];
|
||||
hunkIdx++;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@")) {
|
||||
flushHunk();
|
||||
rendered.push(
|
||||
<Text key={`h${hunkIdx}`} dimColor>
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
|
||||
flushHunk();
|
||||
rendered.push(
|
||||
<Text key={`f${rendered.length}`} dimColor>
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
hunkBody.push(line);
|
||||
}
|
||||
}
|
||||
flushHunk();
|
||||
|
||||
return <Box flexDirection="column">{rendered}</Box>;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { Box, Text } from "ink";
|
||||
import SelectInput from "ink-select-input";
|
||||
import type { PermissionDecision } from "../../permissions/types.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import { looksLikeDiff } from "../../utils/diff.js";
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
@@ -33,15 +35,23 @@ function PermissionItem({ isSelected, label }: { isSelected?: boolean; label?: s
|
||||
}
|
||||
|
||||
export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
|
||||
// Color-code the preview when it is a real diff (additions green, removals red, hunk/file
|
||||
// headers dimmed) - the diff is the single most important thing the user evaluates before
|
||||
// approving a mutating tool, so rendering it as plain monochrome text throws away signal.
|
||||
// Falls back to the old line-by-line render for non-diff previews (a bash command, "Create
|
||||
// new file ...", JSON args, etc.).
|
||||
const isDiff = looksLikeDiff(preview);
|
||||
const previewLines = (preview ?? JSON.stringify(args)).split("\n");
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold>{toolName}</Text>
|
||||
<Text> </Text>
|
||||
{previewLines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
{isDiff && preview ? (
|
||||
<DiffView diff={preview} />
|
||||
) : (
|
||||
previewLines.map((line, i) => <Text key={i}>{line}</Text>)
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text>Do you want to proceed?</Text>
|
||||
<SelectInput
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const ACCENT_HEX = "#D97757";
|
||||
export const DIFF_ADD_HEX = "#2ea043";
|
||||
export const DIFF_REMOVE_HEX = "#f85149";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { looksLikeDiff } from "./diff.js";
|
||||
|
||||
describe("looksLikeDiff", () => {
|
||||
it("recognizes a createPatch unified diff", () => {
|
||||
expect(looksLikeDiff("Index: foo\n--- a\n+++ b\n@@ -1 +1 @@\n-x\n+y\n")).toBe(true);
|
||||
});
|
||||
it("recognizes a leading @@ hunk", () => {
|
||||
expect(looksLikeDiff("@@ -1,2 +1,2 @@\n ctx\n-x\n+y\n")).toBe(true);
|
||||
});
|
||||
it("returns false for a plain bash command preview", () => {
|
||||
expect(looksLikeDiff("rm -rf node_modules")).toBe(false);
|
||||
});
|
||||
it("returns false for empty / undefined", () => {
|
||||
expect(looksLikeDiff("")).toBe(false);
|
||||
expect(looksLikeDiff(undefined)).toBe(false);
|
||||
expect(looksLikeDiff(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** True if `s` looks like a unified diff (the output of `createPatch` from the `diff` package),
|
||||
* vs a plain preview like a bash command line or "Create new file …". Used to decide whether to
|
||||
* render a preview with +/- color coding or as plain text. createPatch output starts with an
|
||||
* `Index:`/`---`/`+++` header and always contains at least one `@@` hunk marker when there's a
|
||||
* real change, so the hunk marker is the most reliable discriminator (a command line containing
|
||||
* a stray `-` won't false-positive). */
|
||||
export function looksLikeDiff(s: string | undefined | null): boolean {
|
||||
if (!s) return false;
|
||||
return s.includes("\n@@") || s.startsWith("@@") || s.startsWith("Index:") || s.startsWith("--- ");
|
||||
}
|
||||
Reference in New Issue
Block a user