Skip to content

Commit e4abf50

Browse files
committed
feat(acp): add unified diff rendering and auth probing
- extract and render ACP content diff blocks and synthesized Cursor edits - broaden ACP registry auth detection in settings and after installs - add npx prefetch/probe helpers for registry installs - cover line diff helpers and ACP transform/registry cases
1 parent 21e9246 commit e4abf50

24 files changed

Lines changed: 917 additions & 38 deletions

src/renderer/components/thread/ChatPane/parts/items/FileChange.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
extractAcpDiffSummary,
1717
extractAcpDiffResultPart,
1818
extractAcpResultPart,
19+
readAcpContentEditTexts,
1920
type ExtractedPart,
2021
} from "./acpToolPayload";
2122
import { InlineDiffView } from "./InlineDiffView";
@@ -35,6 +36,7 @@ export const FileChange = memo(function FileChange({ item }: FileChangeProps) {
3536
const argContent = isCreate ? extractCreateContent(payload) : undefined;
3637
const diffPart = !isCreate ? extractAcpDiffResultPart(payload) : undefined;
3738
const diffText = diffPart?.text ? diffPart.text : undefined;
39+
const contentEdit = readAcpContentEditTexts(payload);
3840
const paneActions = useChatPaneActions();
3941

4042
// Some SDKs (e.g. Claude `Write`) don't surface the new file contents on
@@ -108,7 +110,11 @@ export const FileChange = memo(function FileChange({ item }: FileChangeProps) {
108110
onExpandedChange={setIsExpanded}
109111
>
110112
{diffText !== undefined ? (
111-
<InlineDiffView diffText={diffText} filePath={payload.path} />
113+
<InlineDiffView
114+
diffText={diffText}
115+
filePath={payload.path}
116+
{...(contentEdit ? { oldText: contentEdit.oldText, newText: contentEdit.newText } : {})}
117+
/>
112118
) : argContent !== undefined ? (
113119
<CommandOutputViewport text={argContent} language={language} />
114120
) : fetched.content !== undefined ? (

src/renderer/components/thread/ChatPane/parts/items/InlineDiffView.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Component, useEffect, useState, type ReactNode } from "react";
22
import { DiffView, highlighter } from "@git-diff-view/react";
33
import type { DiffFile } from "@git-diff-view/react";
44
import "@git-diff-view/react/styles/diff-view.css";
5+
import { normalizeDiffFilePath } from "@/shared/lineUnifiedDiff";
56
import {
67
buildInWorker,
78
diffFileFromBundle,
@@ -18,6 +19,9 @@ const MAX_DIFF_LENGTH = 100_000;
1819
interface InlineDiffViewProps {
1920
diffText: string;
2021
filePath: string;
22+
/** When set (Cursor ACP content diffs), passed to git-diff-view for reliable rich rendering. */
23+
oldText?: string;
24+
newText?: string;
2125
}
2226

2327
/**
@@ -26,7 +30,8 @@ interface InlineDiffViewProps {
2630
* keep the UI thread responsive. Falls back to Shiki-highlighted raw diff text
2731
* when the patch is too large or the worker build fails.
2832
*/
29-
export function InlineDiffView({ diffText, filePath }: InlineDiffViewProps) {
33+
export function InlineDiffView({ diffText, filePath, oldText, newText }: InlineDiffViewProps) {
34+
const displayPath = normalizeDiffFilePath(filePath);
3035
const theme = useDiffTheme();
3136
const [diffFile, setDiffFile] = useState<DiffFile | null>(null);
3237
const [state, setState] = useState<"building" | "ready" | "fallback">(
@@ -42,10 +47,26 @@ export function InlineDiffView({ diffText, filePath }: InlineDiffViewProps) {
4247
setState("building");
4348
setDiffFile(null);
4449

45-
const { oldName, newName } = extractDiffNames(diffText);
46-
const lang = getLang(newName || filePath);
50+
const parsedNames = extractDiffNames(diffText);
51+
const oldName = parsedNames.oldName || (oldText === "" ? "/dev/null" : `a/${displayPath}`);
52+
const newName = parsedNames.newName || `b/${displayPath}`;
53+
const lang = getLang(newName || displayPath);
4754

48-
void buildInWorker([{ key: filePath, diff: diffText, oldName, newName, fileLang: lang }], theme)
55+
void buildInWorker(
56+
[
57+
{
58+
key: displayPath,
59+
diff: diffText,
60+
oldName,
61+
newName,
62+
fileLang: lang,
63+
...(oldText !== undefined && newText !== undefined
64+
? { oldContent: oldText, newContent: newText }
65+
: {}),
66+
},
67+
],
68+
theme,
69+
)
4970
.then((results) => {
5071
if (cancelled) return;
5172
const r = results[0];
@@ -63,7 +84,7 @@ export function InlineDiffView({ diffText, filePath }: InlineDiffViewProps) {
6384
return () => {
6485
cancelled = true;
6586
};
66-
}, [diffText, filePath, theme]);
87+
}, [diffText, displayPath, oldText, newText, theme]);
6788

6889
if (state === "fallback") {
6990
return <CommandOutputViewport text={diffText} language="diff" />;

src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
extractAcpArgsPart,
3737
extractAcpDiffSummary,
3838
extractAcpDiffResultPart,
39+
readAcpContentEditTexts,
3940
extractAcpResultPart,
4041
extractAcpResultText,
4142
readAcpStringField,
@@ -264,7 +265,13 @@ function ToolCallInline({ item }: { item: RuntimeChatItem }) {
264265
)
265266
) : row.bodyText ? (
266267
row.bodyKind === "diff" ? (
267-
<InlineDiffView diffText={row.bodyText} filePath={row.bodyFilePath ?? ""} />
268+
<InlineDiffView
269+
diffText={row.bodyText}
270+
filePath={row.bodyFilePath ?? ""}
271+
{...(row.bodyOldText !== undefined && row.bodyNewText !== undefined
272+
? { oldText: row.bodyOldText, newText: row.bodyNewText }
273+
: {})}
274+
/>
268275
) : (
269276
<CommandOutputViewport
270277
text={row.bodyText}
@@ -296,6 +303,8 @@ type InlineRow = {
296303
bodyLanguage?: ViewportLanguage | undefined;
297304
bodyKind?: "text" | "diff" | undefined;
298305
bodyFilePath?: string | undefined;
306+
bodyOldText?: string | undefined;
307+
bodyNewText?: string | undefined;
299308
/**
300309
* Absolute path to lazily read from disk when the row is expanded. Set for
301310
* ACP read tools (e.g. Gemini's `read_file`) that report `locations[]` but
@@ -449,6 +458,7 @@ function getFileChangeRow(item: RuntimeChatItem, isExpanded: boolean): InlineRow
449458
const createContent = isCreate ? extractCreateContent(payload) : undefined;
450459
const diffPart = !isCreate ? extractAcpDiffResultPart(payload) : undefined;
451460
const diffText = diffPart?.text || undefined;
461+
const contentEdit = readAcpContentEditTexts(payload);
452462
const sections: ToolCallSection[] =
453463
isExpanded &&
454464
!diffText &&
@@ -488,14 +498,18 @@ function getFileChangeRow(item: RuntimeChatItem, isExpanded: boolean): InlineRow
488498
rightLabel,
489499
rightLabelClassName: isError ? "text-danger" : "text-[color:var(--muted)]",
490500
hasDetails:
491-
!!item.streams.file_change_output || createContent !== undefined || hasAuxFields(payload),
501+
!!diffText ||
502+
!!item.streams.file_change_output ||
503+
createContent !== undefined ||
504+
hasAuxFields(payload),
492505
sections,
493506
bodyText: isExpanded
494507
? (diffText ?? createContent ?? item.streams.file_change_output)
495508
: undefined,
496509
bodyLanguage: createContent !== undefined ? detectLanguageFromPath(payload.path) : undefined,
497510
bodyKind: diffText ? "diff" : "text",
498511
bodyFilePath: payload.path,
512+
...(contentEdit ? { bodyOldText: contentEdit.oldText, bodyNewText: contentEdit.newText } : {}),
499513
};
500514
}
501515

src/renderer/components/thread/ChatPane/parts/items/acpToolPayload.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* fields lack extra body details and that's fine.
1212
*/
1313

14+
import { buildLineUnifiedDiff, countLineChangeStats } from "@/shared/lineUnifiedDiff";
1415
import { detectLanguageFromPath, type ViewportLanguage } from "./languageDetect";
1516

1617
export interface AcpToolResult {
@@ -141,8 +142,28 @@ export function extractAcpArgsPart(payload: unknown): ExtractedPart {
141142
return { text: safeJson(args), language: "json" };
142143
}
143144

145+
/** Cursor ACP content-diff blocks stored on the payload by the supervisor mapper. */
146+
export function readAcpContentEditTexts(
147+
payload: unknown,
148+
): { path: string; oldText: string; newText: string } | undefined {
149+
if (!payload || typeof payload !== "object") return undefined;
150+
const record = payload as Record<string, unknown>;
151+
const path = readPayloadString(payload, "path");
152+
const newText = readString(record.editNewText);
153+
if (!path || newText === undefined) return undefined;
154+
const oldText = readString(record.editOldText) ?? "";
155+
return { path, oldText, newText };
156+
}
157+
144158
/** Return only ACP result bodies that are unified diffs, or synthesize one from edit args. */
145159
export function extractAcpDiffResultPart(payload: unknown): ExtractedPart {
160+
const contentEdit = readAcpContentEditTexts(payload);
161+
if (contentEdit) {
162+
return {
163+
text: buildLineUnifiedDiff(contentEdit.path, contentEdit.oldText, contentEdit.newText),
164+
language: "diff",
165+
};
166+
}
146167
const resultPart = extractAcpResultPart(payload);
147168
if (resultPart.language === "diff") return resultPart;
148169
const synthesized = synthesizeEditDiff(payload);
@@ -179,6 +200,11 @@ export function extractAcpPatchTargetPath(payload: unknown): string | undefined
179200
}
180201

181202
export function extractAcpDiffSummary(payload: unknown): DiffSummary | undefined {
203+
const contentEdit = readAcpContentEditTexts(payload);
204+
if (contentEdit) {
205+
const stats = countLineChangeStats(contentEdit.oldText, contentEdit.newText);
206+
return stats.added === 0 && stats.removed === 0 ? undefined : stats;
207+
}
182208
const changesSummary = summarizeStructuredFileChanges(payload);
183209
if (changesSummary) return changesSummary;
184210
const diffPart = extractAcpDiffResultPart(payload);

src/renderer/utils/acpRegistryAuth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ export function findTerminalAuthMethodForStatus(
5454
return status?.authMethods?.find(isTerminalAuthMethod);
5555
}
5656

57+
/** First agent-owned auth method advertised by any env for the same install. */
58+
export function findAgentAuthMethodInStatuses(
59+
statuses: readonly AgentStatus[],
60+
): AgentOwnedAuthMethod | undefined {
61+
for (const status of statuses) {
62+
const method = status.authMethods?.find(isAgentAuthMethod);
63+
if (method) return method;
64+
}
65+
return undefined;
66+
}
67+
68+
/** First terminal auth method advertised by any env for the same install. */
69+
export function findTerminalAuthMethodInStatuses(
70+
statuses: readonly AgentStatus[],
71+
): AgentTerminalAuthMethod | undefined {
72+
for (const status of statuses) {
73+
const method = findTerminalAuthMethodForStatus(status);
74+
if (method) return method;
75+
}
76+
return undefined;
77+
}
78+
5779
export function agentAuthTarget(status: AgentStatus): {
5880
envKind?: AgentStatus["envKind"];
5981
wslDistro?: string;

src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,28 @@ describe("SingleAgentSettings", () => {
639639
});
640640
});
641641

642+
it("shows auth controls when probe advertised methods but authState is still unknown", () => {
643+
statusesState.agentStatuses = [
644+
makeStatus("acp-generic:factory-droid", {
645+
label: "Factory Droid",
646+
authState: "unknown",
647+
authMethods: [
648+
{ id: "login", name: "Login" },
649+
{
650+
id: "factory-key",
651+
name: "Factory API Key",
652+
vars: [{ name: "FACTORY_API_KEY", label: "Factory API Key" }],
653+
} as never,
654+
],
655+
}),
656+
];
657+
658+
render(<SingleAgentSettings agentKind="acp-generic:factory-droid" />);
659+
660+
expect(screen.getByLabelText("Factory API Key")).toBeInTheDocument();
661+
expect(screen.getByRole("button", { name: "Login" })).toBeInTheDocument();
662+
});
663+
642664
it("offers logout (not re-login) for an authenticated ACP agent env", async () => {
643665
statusesState.agentStatuses = [
644666
makeStatus("acp-generic:sso-agent", {
@@ -716,6 +738,33 @@ describe("SingleAgentSettings", () => {
716738
await waitFor(() => expect(refreshAgentStatusesMock).toHaveBeenCalled());
717739
});
718740

741+
it("shows Windows login when WSL is signed in but Windows status omitted auth methods", () => {
742+
statusesState.agentStatuses = [
743+
makeStatus("acp-generic:factory-droid", {
744+
label: "Factory Droid",
745+
authState: "unknown",
746+
envKind: "windows",
747+
}),
748+
];
749+
statusesState.wslAgentStatuses = [
750+
makeStatus("acp-generic:factory-droid", {
751+
label: "Factory Droid",
752+
authState: "authenticated",
753+
authMethods: [{ id: "login", name: "Login" }],
754+
envKind: "wsl",
755+
envDistro: "Ubuntu",
756+
}),
757+
];
758+
759+
render(<SingleAgentSettings agentKind="acp-generic:factory-droid" />);
760+
761+
expect(screen.getByText(/Windows · Login required/u)).toBeInTheDocument();
762+
expect(screen.getByText(/Complete Login sign-in for Windows\./u)).toBeInTheDocument();
763+
expect(screen.getByRole("button", { name: /login windows/i })).toBeInTheDocument();
764+
expect(screen.getByText(/WSL \(Ubuntu\) · Signed in/u)).toBeInTheDocument();
765+
expect(screen.queryByText(/Windows · Authentication/u)).toBeNull();
766+
});
767+
719768
it("labels the remaining WSL auth action when Windows is already signed in", async () => {
720769
statusesState.agentStatuses = [
721770
makeStatus("acp-generic:factory-droid", {

src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ import {
3333
acpGenericInstanceId,
3434
agentAuthTarget,
3535
envLabelForStatus,
36+
findAgentAuthMethodInStatuses,
3637
findProjectForStatus,
3738
findTerminalAuthMethodForStatus,
39+
findTerminalAuthMethodInStatuses,
3840
isAgentAuthMethod,
3941
isEnvVarAuthMethod,
4042
isTerminalAuthMethod,
@@ -444,8 +446,9 @@ function AcpAgentAuthEnvRow(props: {
444446
onLogout: () => void;
445447
}) {
446448
const { status, authMethod, showEnvironmentLabel } = props;
447-
const isMissing = status.authState === "missing";
448449
const isAuthenticated = status.authState === "authenticated";
450+
const isMissing =
451+
status.authState === "missing" || (status.authState === "unknown" && authMethod !== undefined);
449452
const env = envLabel(status);
450453
const envSuffix = showEnvironmentLabel && env ? ` ${env}` : "";
451454
const envScope = env ? ` for ${env}` : "";
@@ -669,7 +672,18 @@ export function SingleAgentSettings(props: { agentKind: string }) {
669672
const method = status.authMethods?.find(isAgentAuthMethod);
670673
return method ? [{ status, method }] : [];
671674
});
672-
const agentAuth = findAgentAuthMethod(agentAuthStatuses);
675+
const sharedAgentAuthMethod = findAgentAuthMethodInStatuses(installedStatuses);
676+
const sharedTerminalAuthMethod = findTerminalAuthMethodInStatuses(installedStatuses);
677+
const agentAuth =
678+
findAgentAuthMethod(agentAuthStatuses) ??
679+
(sharedAgentAuthMethod
680+
? {
681+
status:
682+
agentAuthStatuses.find((status) => status.authMethods?.some(isAgentAuthMethod)) ??
683+
installedStatuses[0]!,
684+
method: sharedAgentAuthMethod,
685+
}
686+
: undefined);
673687
const loginStatus =
674688
findTerminalLoginStatus(installedStatuses) ??
675689
missingAuthStatuses.find((status) => status.loginCommand);
@@ -817,20 +831,27 @@ export function SingleAgentSettings(props: { agentKind: string }) {
817831
setAuthPendingEnvKey(undefined);
818832
});
819833
};
834+
const hasAdvertisedAuthMethods = installedStatuses.some(
835+
(status) => (status.authMethods?.length ?? 0) > 0,
836+
);
820837
const hasAuthSettings =
821838
envVarAuthMethod !== undefined ||
822839
agentAuth !== undefined ||
823840
loginCommand !== undefined ||
824841
missingAuthStatuses.length > 0 ||
825-
logoutStatuses.length > 0;
842+
logoutStatuses.length > 0 ||
843+
hasAdvertisedAuthMethods;
826844
const includeAuthFallbackMetadata = !hasAuthSettings;
827845
const metadataStatuses = installedStatuses.filter(
828846
(status) =>
829847
formatAgentMetadataSummary(status, {
830848
includeAuthFallback: includeAuthFallbackMetadata,
831849
}) !== undefined,
832850
);
833-
const authMissing = missingAuthStatuses.length > 0;
851+
const authMissing =
852+
missingAuthStatuses.length > 0 ||
853+
(hasAdvertisedAuthMethods &&
854+
!installedStatuses.some((status) => status.authState === "authenticated"));
834855
const missingAuthLabel = formatStatusList(missingAuthStatuses);
835856
const showAuthEnvironmentLabels = installedStatuses.length > 1;
836857
const showEnvVarOnly = envVarAuthMethod !== undefined && !authMissing;
@@ -1177,9 +1198,17 @@ export function SingleAgentSettings(props: { agentKind: string }) {
11771198
) : null}
11781199
{installedStatuses.map((status) => {
11791200
const envKey = statusEnvKey(status);
1180-
const agentMethod = status.authMethods?.find(isAgentAuthMethod);
1181-
const terminalMethod = findTerminalAuthMethodForStatus(status);
1201+
const agentMethod =
1202+
status.authMethods?.find(isAgentAuthMethod) ?? sharedAgentAuthMethod;
1203+
const terminalMethod =
1204+
findTerminalAuthMethodForStatus(status) ?? sharedTerminalAuthMethod;
11821205
const method = agentMethod ?? terminalMethod;
1206+
const isAuthenticated = status.authState === "authenticated";
1207+
const needsInteractiveRow =
1208+
isAuthenticated ||
1209+
status.authState === "missing" ||
1210+
(status.authState === "unknown" && method !== undefined);
1211+
if (!needsInteractiveRow) return null;
11831212
return (
11841213
<AcpAgentAuthEnvRow
11851214
key={`${status.kind}-${envKey}-auth-row`}

0 commit comments

Comments
 (0)