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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-disclosures.test.ts && tsx src/ui/scrollbar-styles.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
4 changes: 3 additions & 1 deletion src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com
assert.ok(Array.isArray(card.skills));
assert.ok(Array.isArray(card.agentProviders));
assert.ok(Array.isArray(card.agents));
assert.ok(Array.isArray(card.skillDiagnostics));
assert.equal(card.skillDiagnostics, undefined);
assert.equal(card.instruction, undefined);
assert.equal((card.summary as Record<string, unknown> | undefined)?.skillDiagnostics, undefined);
});

test("concurrent checkout opens return one full context and one reuse instruction", async (t) => {
Expand Down
3 changes: 0 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,16 +914,13 @@ export function createMcpServer(
skills: cardSkills,
agentProviders: cardAgentProviders,
agents: cardAgents,
skillDiagnostics: workspace.skillDiagnostics,
instruction: cardInstruction,
summary: {
mode: workspace.mode,
agentsFiles: cardAgentsFiles.length,
availableAgentsFiles: cardAvailableAgentsFiles.length,
skills: cardSkills.length,
agentProviders: cardAgentProviders.length,
agents: cardAgents.length,
skillDiagnostics: workspace.skillDiagnostics.length,
},
},
},
Expand Down
40 changes: 40 additions & 0 deletions src/ui/card-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isExpandableCard,
isPatchTool,
isShellTool,
shouldAutoExpandCard,
isToolName,
} from "./card-types.js";

Expand All @@ -31,6 +32,31 @@ test("a patch card expands only when it contains patch content", () => {
assert.equal(isExpandableCard({ tool: "apply_patch" }), false);
});

test("single-file mutation cards open their result immediately", () => {
assert.equal(
shouldAutoExpandCard({
tool: "apply_patch",
files: [{ path: "src/one.ts", operation: "update" }],
payload: { patch: "diff --git a/src/one.ts b/src/one.ts" },
}),
true,
);
assert.equal(
shouldAutoExpandCard({
tool: "apply_patch",
files: [
{ path: "src/one.ts", operation: "update" },
{ path: "src/two.ts", operation: "update" },
],
payload: { patch: "diff --git a/src/one.ts b/src/one.ts" },
}),
false,
);
assert.equal(shouldAutoExpandCard({ tool: "write", payload: { patch: "diff" } }), true);
assert.equal(shouldAutoExpandCard({ tool: "edit", payload: { diff: "diff" } }), true);
assert.equal(shouldAutoExpandCard({ tool: "read", payload: { content: [] } }), false);
});

test("a workspace card expands when it contains provider metadata", () => {
assert.equal(
isExpandableCard({
Expand Down Expand Up @@ -64,3 +90,17 @@ test("a workspace card expands when it contains available instruction files", ()
test("an empty workspace card stays collapsed", () => {
assert.equal(isExpandableCard({ tool: "open_workspace" }), false);
});

test("workspace source metadata alone does not create an empty expandable panel", () => {
assert.equal(
isExpandableCard({ tool: "open_workspace", worktree: { path: "/tmp/worktree" } }),
false,
);
});

test("workspace diagnostics do not make a card expandable", () => {
assert.equal(
isExpandableCard({ tool: "open_workspace", summary: { skillDiagnostics: 3 } }),
false,
);
});
15 changes: 8 additions & 7 deletions src/ui/card-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ export interface ToolResultCard {
providerAvailable?: boolean;
providerUnavailableReason?: string;
}>;
skillDiagnostics?: unknown[];
instruction?: string;
}

export interface ToolContent {
Expand Down Expand Up @@ -165,15 +163,11 @@ export function isExpandableCard(card: ToolResultCard): boolean {
Number(card.summary?.skills ?? 0) > 0 ||
Number(card.summary?.agentProviders ?? 0) > 0 ||
Number(card.summary?.agents ?? 0) > 0 ||
Number(card.summary?.skillDiagnostics ?? 0) > 0 ||
Boolean(card.agentsFiles?.length) ||
Boolean(card.availableAgentsFiles?.length) ||
Boolean(card.skills?.length) ||
Boolean(card.agentProviders?.length) ||
Boolean(card.agents?.length) ||
Boolean(card.worktree) ||
Boolean(card.instruction) ||
Boolean(card.skillDiagnostics?.length)
Boolean(card.agents?.length)
);
}

Expand All @@ -182,3 +176,10 @@ export function isExpandableCard(card: ToolResultCard): boolean {

return Boolean(card.payload);
}

export function shouldAutoExpandCard(card: ToolResultCard): boolean {
if (!isExpandableCard(card)) return false;
if (isReviewTool(card.tool)) return true;
if (isPatchTool(card.tool)) return card.files?.length === 1;
return isEditTool(card.tool) || isWriteTool(card.tool);
}
9 changes: 6 additions & 3 deletions src/ui/heavy-payload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type HostContext,
type ToolResultCard,
} from "./card-types.js";
import { PIERRE_SCROLLBAR_STYLES } from "./scrollbar-styles.js";

type ThemeType = "light" | "dark";

Expand Down Expand Up @@ -77,7 +78,7 @@ function HeavyPayload({
);
}

return <pre className={`text-payload ${card.tool}`}>{text}</pre>;
return <pre className={`text-payload pretty-scrollbar ${card.tool}`}>{text}</pre>;
}

function FilePayload({
Expand All @@ -100,6 +101,7 @@ function FilePayload({
},
themeType,
overflow: "scroll",
unsafeCSS: PIERRE_SCROLLBAR_STYLES,
}),
[themeType],
);
Expand Down Expand Up @@ -134,7 +136,7 @@ function FilePayload({
};
}, [fileOptions, path, startLine, text]);

return <div ref={wrapperRef} className="pierre-file" />;
return <div ref={wrapperRef} className="pierre-file pretty-scrollbar" />;
}

function DiffPayload({
Expand Down Expand Up @@ -162,8 +164,9 @@ function DiffPayload({
expansionLineCount: 20,
stickyHeader: true,
disableFileHeader: true,
unsafeCSS: PIERRE_SCROLLBAR_STYLES,
}}
className="pierre-diff"
className="pierre-diff pretty-scrollbar"
/>
);
}
Expand Down
12 changes: 12 additions & 0 deletions src/ui/icons.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import {
BookOpen,
Bot,
ChevronRight,
ChevronDown,
CircleAlert,
CircleCheck,
FileDiff,
FileMinus,
FilePenLine,
FilePlus,
FileText,
Files,
GitBranch,
FolderOpen,
FolderTree,
LoaderCircle,
Expand All @@ -17,13 +23,19 @@ import {
} from "lucide";

export const toolIcons = {
alert: CircleAlert,
agents: Bot,
chevronRight: ChevronRight,
chevronDown: ChevronDown,
check: CircleCheck,
deleteFile: FileMinus,
diff: FileDiff,
editFile: FilePenLine,
files: Files,
folderOpen: FolderOpen,
folderTree: FolderTree,
gitBranch: GitBranch,
instructions: BookOpen,
loading: LoaderCircle,
readFile: FileText,
search: Search,
Expand Down
128 changes: 102 additions & 26 deletions src/ui/review-payload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { parsePatchFiles, type FileDiffMetadata, type FileDiffOptions } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import type { HostContext, ToolResultCard } from "./card-types.js";
import { PIERRE_SCROLLBAR_STYLES } from "./scrollbar-styles.js";

type ThemeType = "light" | "dark";

Expand Down Expand Up @@ -47,7 +48,11 @@ function ReviewPayload({
const visibleFiles = typeof visibleFileCount === "number"
? files.slice(0, visibleFileCount)
: files;
const [openFiles, setOpenFiles] = useState(() => new Set<string>());
const isSingleFile = files.length === 1;
const [openFiles, setOpenFiles] = useState<Set<string>>(() => {
const onlyFile = files.length === 1 ? files[0] : undefined;
return onlyFile ? new Set([reviewFileKey(onlyFile)]) : new Set();
});

if (errorMessage) return <StatusLine message={errorMessage} tone="error" />;
if (!patch) return <StatusLine message="Diff payload is not available." />;
Expand All @@ -56,37 +61,61 @@ function ReviewPayload({
const options = diffOptions(themeType);

return (
<div className="review-diff">
<div className="review-diff pretty-scrollbar">
<div className="review-diff-files">
{visibleFiles.map((fileDiff, index) => {
const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`;
{visibleFiles.map((fileDiff) => {
const key = reviewFileKey(fileDiff);
const stats = diffStats(fileDiff);
const isOpen = openFiles.has(key);
const metadata = findReviewFile(card.files, fileDiff);
const operation = reviewOperation(metadata);
const previousPath = metadata?.previousPath ?? fileDiff.prevName;

return (
<div className="review-diff-file" key={key}>
<button
type="button"
className="review-diff-file-header"
aria-expanded={isOpen}
onClick={() => {
const next = new Set(openFiles);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
setOpenFiles(next);
}}
>
<span className="review-diff-file-name">{fileDiff.name}</span>
<span className="review-diff-file-stats">
<span className="add">+{stats.additions}</span>
<span className="remove">-{stats.removals}</span>
</span>
</button>
<div className={`review-diff-file operation-${operation}`} key={key}>
{!isSingleFile ? (
<button
type="button"
className="review-diff-file-header"
aria-expanded={isOpen}
onClick={() => {
const next = new Set(openFiles);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
setOpenFiles(next);
}}
>
<span className="review-diff-file-main">
<span
className={`review-diff-file-operation ${operation}`}
aria-label={reviewOperationLabel(operation)}
title={reviewOperationLabel(operation)}
>
{reviewOperationSymbol(operation)}
</span>
<span className="review-diff-file-name">
{fileDiff.name}
{previousPath && previousPath !== fileDiff.name ? (
<span className="review-diff-file-previous"> ← {previousPath}</span>
) : null}
</span>
</span>
<span className="review-diff-file-stats">
<span className="add">+{stats.additions}</span>
<span className="remove">-{stats.removals}</span>
<span className="review-diff-file-chevron" aria-hidden="true">›</span>
</span>
</button>
) : null}
{isOpen ? (
<FileDiff fileDiff={fileDiff} options={options} className="pierre-diff" />
<FileDiff
fileDiff={fileDiff}
options={options}
className="pierre-diff pretty-scrollbar"
/>
) : null}
</div>
);
Expand All @@ -101,6 +130,10 @@ function parseFiles(patch: string | undefined): FileDiffMetadata[] {
return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files);
}

function reviewFileKey(fileDiff: FileDiffMetadata): string {
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}`;
}

function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: number } {
return fileDiff.hunks.reduce(
(stats, hunk) => ({
Expand All @@ -111,6 +144,48 @@ function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: n
);
}

type ReviewOperation = "add" | "update" | "delete" | "move";

function findReviewFile(
files: ToolResultCard["files"],
fileDiff: FileDiffMetadata,
): NonNullable<ToolResultCard["files"]>[number] | undefined {
const name = fileDiff.name;
const previousName = fileDiff.prevName;
return files?.find((file) => {
if (file.path === name || file.previousPath === name) return true;
if (!previousName) return false;
return file.path === previousName || file.previousPath === previousName;
});
}

function reviewOperation(file: NonNullable<ToolResultCard["files"]>[number] | undefined): ReviewOperation {
if (file?.operation === "add" || file?.type === "new") return "add";
if (file?.operation === "delete" || file?.type === "deleted") return "delete";
if (
file?.operation === "move" ||
file?.type === "rename-pure" ||
file?.type === "rename-changed"
) {
return "move";
}
return "update";
}

function reviewOperationSymbol(operation: ReviewOperation): string {
if (operation === "add") return "+";
if (operation === "delete") return "−";
if (operation === "move") return "↗";
return "~";
}

function reviewOperationLabel(operation: ReviewOperation): string {
if (operation === "add") return "Added file";
if (operation === "delete") return "Deleted file";
if (operation === "move") return "Moved file";
return "Changed file";
}

function diffOptions(themeType: ThemeType): FileDiffOptions<undefined> {
return {
theme: {
Expand All @@ -127,6 +202,7 @@ function diffOptions(themeType: ThemeType): FileDiffOptions<undefined> {
expansionLineCount: 20,
stickyHeader: false,
disableFileHeader: true,
unsafeCSS: PIERRE_SCROLLBAR_STYLES,
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/ui/scrollbar-styles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import assert from "node:assert/strict";
import { PIERRE_SCROLLBAR_STYLES } from "./scrollbar-styles.js";

assert.match(PIERRE_SCROLLBAR_STYLES, /\[data-code\]::-webkit-scrollbar/);
assert.match(PIERRE_SCROLLBAR_STYLES, /scrollbar-gutter:\s*auto/);
assert.match(PIERRE_SCROLLBAR_STYLES, /background-color:\s*var\(--scrollbar-thumb/);
assert.doesNotMatch(PIERRE_SCROLLBAR_STYLES, /scrollbar-(?:width|color)/);
assert.doesNotMatch(PIERRE_SCROLLBAR_STYLES, /100 116 139|71 85 105/);
Loading
Loading