Skip to content

Commit 56e3164

Browse files
Render structured agent tool results
Translate ACP diffs and file locations into typed extension views so tool results remain compact, navigable, and consistent with the rest of the app. Keep raw output available when a result cannot be represented structurally.
1 parent 8784b72 commit 56e3164

8 files changed

Lines changed: 446 additions & 41 deletions

src/features/ai/components/chat/chat-activity-line.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface ChatActivityLineProps {
2020
state?: ActivityState;
2121
actions?: ReactNode;
2222
children?: ReactNode;
23+
detailsVariant?: "text" | "content";
2324
}
2425

2526
export function ChatActivityLine({
@@ -29,6 +30,7 @@ export function ChatActivityLine({
2930
state = "info",
3031
actions,
3132
children,
33+
detailsVariant = "text",
3234
}: ChatActivityLineProps) {
3335
const [isExpanded, setIsExpanded] = useState(false);
3436
const canExpand = Boolean(children);
@@ -61,10 +63,12 @@ export function ChatActivityLine({
6163
</Marker>
6264
{actions ? <span className="shrink-0">{actions}</span> : null}
6365
</div>
64-
{canExpand && isExpanded ? (
66+
{canExpand && isExpanded && detailsVariant === "text" ? (
6567
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap pl-6 font-mono ui-text-sm text-subtle-foreground/55">
6668
{children}
6769
</pre>
70+
) : canExpand && isExpanded ? (
71+
<div className="mt-1 min-w-0 pl-6">{children}</div>
6872
) : null}
6973
</div>
7074
);

src/features/ai/components/messages/tool-call-display.tsx

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,25 @@ import {
33
GitDiffIcon as GitDiff,
44
TerminalWindowIcon as TerminalSquare,
55
} from "@/ui/icons";
6-
import { getAcpDiffOutputs, openAcpDiffOutput } from "@/features/ai/lib/acp-diff-output";
6+
import {
7+
createAcpDiffViewNode,
8+
getAcpDiffOutputs,
9+
openAcpDiffOutput,
10+
stripAcpDiffOutputs,
11+
} from "@/features/ai/lib/acp-diff-output";
12+
import {
13+
getStructuredToolViews,
14+
isStructuredToolViewEnvelope,
15+
stripStructuredToolViews,
16+
} from "@/features/ai/lib/structured-tool-view";
717
import {
818
getAcpTerminalOutputs,
919
openAcpTerminalOutput,
1020
} from "@/features/ai/lib/acp-terminal-output";
21+
import {
22+
createAcpToolLocationTree,
23+
OPEN_TOOL_LOCATION_COMMAND,
24+
} from "@/features/ai/lib/acp-tool-location-tree";
1125
import type { ToolCall } from "@/features/ai/types/ai-chat.types";
1226
import type {
1327
AcpToolCallLocation,
@@ -19,6 +33,8 @@ import { readFileContent } from "@/features/file-system/controllers/file-operati
1933
import { getFileDiff } from "@/features/git/api/git-diff-api";
2034
import { useProjectStore } from "@/features/window/stores/project.store";
2135
import { Button } from "@/ui/button";
36+
import { GenerativeUIRenderer } from "@/extensions/ui/components/generative-ui-renderer";
37+
import { ExtensionViewRenderer } from "@/extensions/ui/components/extension-view-renderer";
2238
import { getBaseName, joinPath } from "@/utils/path-helpers";
2339
import { ChatActivityLine } from "../chat/chat-activity-line";
2440

@@ -174,12 +190,6 @@ function formatDiffText(item: Record<string, unknown>): string {
174190
.join("\n");
175191
}
176192

177-
function formatAcpDiffText(item: ReturnType<typeof getAcpDiffOutputs>[number]): string {
178-
return [`diff: ${item.path}`, "--- before", item.oldText, "+++ after", item.newText]
179-
.filter((line) => line.length > 0)
180-
.join("\n");
181-
}
182-
183193
function getOutputSummary(output: unknown): string | null {
184194
const diffItems = getDiffItems(output);
185195
if (diffItems.length > 0) {
@@ -192,14 +202,18 @@ function getOutputSummary(output: unknown): string | null {
192202
return terminalItems.length === 1 ? "terminal output" : `${terminalItems.length} terminals`;
193203
}
194204

205+
if (getStructuredToolViews(output).length > 0) return "interactive result";
206+
195207
return null;
196208
}
197209

198210
function getOutputText(output: unknown): string {
211+
if (isStructuredToolViewEnvelope(output)) return "";
199212
if (!Array.isArray(output)) return formatValue(output);
200213

201214
return output
202215
.map((item) => {
216+
if (isStructuredToolViewEnvelope(item)) return "";
203217
if (!isRecord(item)) return formatValue(item);
204218
if (item.type === "content") return getContentText(item);
205219
if (item.type === "diff") return formatDiffText(item);
@@ -251,7 +265,7 @@ export function ToolCallGroupDisplay({
251265
const detail = getLatestToolSummary(latestToolCall, isStreaming);
252266

253267
return (
254-
<ChatActivityLine title={title} detail={detail}>
268+
<ChatActivityLine title={title} detail={detail} detailsVariant="content">
255269
<div className="space-y-1">
256270
{toolCalls.map((toolCall, toolIndex) => (
257271
<ToolCallDisplay
@@ -283,17 +297,26 @@ function ToolCallDisplay({
283297
}: ToolCallDisplayProps) {
284298
const state = getStatus(isStreaming, error, protocolStatus);
285299
const detail = getToolCallDetail(toolName, input, output, state, protocolStatus);
300+
const structuredViews = getStructuredToolViews(output);
301+
const displayOutput = stripStructuredToolViews(output);
302+
const hasStructuredViews = structuredViews.length > 0;
286303
const hasDetails =
287304
Boolean(input) ||
288-
Boolean(output) ||
305+
Boolean(displayOutput) ||
306+
hasStructuredViews ||
289307
Boolean(error) ||
290308
Boolean(kind && kind !== "other") ||
291309
Boolean(locations?.length);
292-
const diffItems = getDiffItems(output);
310+
const diffItems = getDiffItems(displayOutput);
293311
const hasDiffOutput = diffItems.length > 0;
294-
const terminalItems = getTerminalItems(output);
312+
const nonDiffOutput = stripAcpDiffOutputs(displayOutput);
313+
const diffViews = diffItems.map((item) =>
314+
createAcpDiffViewNode(item, useProjectStore.getState().rootFolderPath),
315+
);
316+
const terminalItems = getTerminalItems(displayOutput);
295317
const hasTerminalOutput = terminalItems.length > 0;
296318
const toolPath = resolveToolPath(locations, input);
319+
const locationTree = locations ? createAcpToolLocationTree(locations) : undefined;
297320
const hasActions = Boolean(toolPath || hasTerminalOutput);
298321
const actionButtons = hasActions ? (
299322
<span className="flex items-center gap-1">
@@ -341,27 +364,61 @@ function ToolCallDisplay({
341364
) : null}
342365
</span>
343366
) : null;
367+
const detailText = [
368+
kind && kind !== "other" ? `kind: ${kind}\n` : "",
369+
locations?.length && !locationTree
370+
? `locations:\n${locations
371+
.map((location) => ` ${location.path}${location.line ? `:${location.line}` : ""}`)
372+
.join("\n")}\n`
373+
: "",
374+
input ? `input:\n${formatValue(input)}\n` : "",
375+
nonDiffOutput ? `output:\n${getOutputText(nonDiffOutput)}\n` : "",
376+
error ? `error:\n${error}` : "",
377+
].join("");
344378

345379
return (
346-
<ChatActivityLine title={toolName} detail={detail} state={state} actions={actionButtons}>
380+
<ChatActivityLine
381+
title={toolName}
382+
detail={detail}
383+
state={state}
384+
actions={actionButtons}
385+
detailsVariant={hasStructuredViews || hasDiffOutput || locationTree ? "content" : "text"}
386+
>
347387
{hasDetails ? (
348-
<>
349-
{kind && kind !== "other" ? `kind: ${kind}\n` : ""}
350-
{locations?.length
351-
? `locations:\n${locations
352-
.map((location) => ` ${location.path}${location.line ? `:${location.line}` : ""}`)
353-
.join("\n")}\n`
354-
: ""}
355-
{input ? `input:\n${formatValue(input)}\n` : ""}
356-
{output
357-
? `output:\n${
358-
hasDiffOutput
359-
? diffItems.map(formatAcpDiffText).join("\n\n")
360-
: getOutputText(output)
361-
}\n`
362-
: ""}
363-
{error ? `error:\n${error}` : ""}
364-
</>
388+
hasStructuredViews || hasDiffOutput || locationTree ? (
389+
<div className="flex min-w-0 flex-col gap-2">
390+
{detailText ? (
391+
<pre className="max-h-64 overflow-auto whitespace-pre-wrap font-mono ui-text-sm text-subtle-foreground/55">
392+
{detailText}
393+
</pre>
394+
) : null}
395+
{diffViews.map((view, index) => (
396+
<ExtensionViewRenderer
397+
key={`${view.filePath}-diff-${index}`}
398+
node={view}
399+
execute={() => undefined}
400+
surface="embedded"
401+
/>
402+
))}
403+
{locationTree ? (
404+
<ExtensionViewRenderer
405+
node={locationTree}
406+
execute={(action) => {
407+
const path = action.args?.[0];
408+
if (action.command === OPEN_TOOL_LOCATION_COMMAND && typeof path === "string") {
409+
return openToolPath(path);
410+
}
411+
}}
412+
surface="embedded"
413+
/>
414+
) : null}
415+
{structuredViews.map((view, index) => (
416+
<GenerativeUIRenderer key={`${toolName}-ui-${index}`} component={view} />
417+
))}
418+
</div>
419+
) : (
420+
detailText
421+
)
365422
) : null}
366423
</ChatActivityLine>
367424
);

src/features/ai/lib/acp-diff-output.ts

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import { useBufferStore } from "@/features/editor/stores/buffer.store";
2+
import { EXTENSION_VIEW_LIMITS } from "@/extensions/ui/services/extension-view-schema";
3+
import type { ExtensionViewNode } from "@/extensions/ui/types/extension-view";
24
import type { MultiFileDiff } from "@/features/git/types/git-diff.types";
35
import type { GitDiff, GitDiffLine } from "@/features/git/types/git.types";
46
import { countDiffStats } from "@/features/git/utils/git-diff-helpers";
57
import { useProjectStore } from "@/features/window/stores/project.store";
68

7-
interface AcpDiffOutput {
9+
export interface AcpDiffOutput {
810
path: string;
911
oldText: string;
1012
newText: string;
1113
}
1214

15+
type ExtensionDiffNode = Extract<ExtensionViewNode, { type: "diff" }>;
16+
1317
function isRecord(value: unknown): value is Record<string, unknown> {
1418
return Boolean(value) && typeof value === "object";
1519
}
1620

21+
function isAcpDiffOutput(value: unknown): value is Record<string, unknown> & { path: string } {
22+
return isRecord(value) && value.type === "diff" && typeof value.path === "string";
23+
}
24+
1725
function normalizePath(path: string): string {
1826
return path.replace(/\\/g, "/").replace(/^file:\/\//, "");
1927
}
@@ -31,17 +39,29 @@ function getBaseName(path: string): string {
3139
return path.split("/").pop() || path;
3240
}
3341

42+
function getLanguageHint(path: string): string | undefined {
43+
const fileName = getBaseName(path);
44+
const separator = fileName.lastIndexOf(".");
45+
return separator > 0 && separator < fileName.length - 1
46+
? fileName.slice(separator + 1)
47+
: undefined;
48+
}
49+
3450
export function getAcpDiffOutputs(output: unknown): AcpDiffOutput[] {
35-
if (!Array.isArray(output)) return [];
36-
37-
return output
38-
.filter(isRecord)
39-
.filter((item) => item.type === "diff" && typeof item.path === "string")
40-
.map((item) => ({
41-
path: item.path as string,
42-
oldText: typeof item.oldText === "string" ? item.oldText : "",
43-
newText: typeof item.newText === "string" ? item.newText : "",
44-
}));
51+
const items = Array.isArray(output) ? output : [output];
52+
53+
return items.filter(isAcpDiffOutput).map((item) => ({
54+
path: item.path as string,
55+
oldText: typeof item.oldText === "string" ? item.oldText : "",
56+
newText: typeof item.newText === "string" ? item.newText : "",
57+
}));
58+
}
59+
60+
export function stripAcpDiffOutputs(output: unknown): unknown {
61+
if (isAcpDiffOutput(output)) return undefined;
62+
if (!Array.isArray(output)) return output;
63+
const remaining = output.filter((item) => !isAcpDiffOutput(item));
64+
return remaining.length > 0 ? remaining : undefined;
4565
}
4666

4767
function createHunkHeader(oldLines: string[], newLines: string[]): GitDiffLine {
@@ -77,6 +97,56 @@ function createDiffLines(oldText: string, newText: string): GitDiffLine[] {
7797
return lines;
7898
}
7999

100+
function previewLineBudget(removedCount: number, addedCount: number) {
101+
const contentBudget = EXTENSION_VIEW_LIMITS.maxDiffLines - 1;
102+
if (removedCount + addedCount <= contentBudget) {
103+
return { removed: removedCount, added: addedCount };
104+
}
105+
106+
let removed = Math.min(removedCount, Math.floor(contentBudget / 2));
107+
let added = Math.min(addedCount, contentBudget - removed);
108+
const unused = contentBudget - removed - added;
109+
if (unused > 0) {
110+
const extraRemoved = Math.min(unused, removedCount - removed);
111+
removed += extraRemoved;
112+
added += Math.min(unused - extraRemoved, addedCount - added);
113+
}
114+
return { removed, added };
115+
}
116+
117+
export function createAcpDiffViewNode(
118+
diff: AcpDiffOutput,
119+
rootFolderPath?: string | null,
120+
): ExtensionDiffNode {
121+
const oldLines = diff.oldText.length > 0 ? diff.oldText.split("\n") : [];
122+
const newLines = diff.newText.length > 0 ? diff.newText.split("\n") : [];
123+
const budget = previewLineBudget(oldLines.length, newLines.length);
124+
const lines: ExtensionDiffNode["lines"] = [
125+
{
126+
type: "header",
127+
content: `-1,${Math.max(oldLines.length, 1)} +1,${Math.max(newLines.length, 1)}`,
128+
},
129+
...oldLines.slice(0, budget.removed).map((content, index) => ({
130+
type: "removed" as const,
131+
content,
132+
oldLine: index + 1,
133+
})),
134+
...newLines.slice(0, budget.added).map((content, index) => ({
135+
type: "added" as const,
136+
content,
137+
newLine: index + 1,
138+
})),
139+
];
140+
141+
return {
142+
type: "diff",
143+
filePath: toRelativeDisplayPath(diff.path, rootFolderPath),
144+
language: getLanguageHint(diff.path),
145+
lines,
146+
truncated: budget.removed < oldLines.length || budget.added < newLines.length,
147+
};
148+
}
149+
80150
function toGitDiff(diff: AcpDiffOutput, rootFolderPath?: string | null): GitDiff {
81151
const displayPath = toRelativeDisplayPath(diff.path, rootFolderPath);
82152
const isNew = diff.oldText.length === 0 && diff.newText.length > 0;

0 commit comments

Comments
 (0)