Skip to content

Commit 0367cc2

Browse files
committed
add a diff viewer view, hook up to edit_text_file and write_text_file.
1 parent b9e2365 commit 0367cc2

File tree

18 files changed

+351
-31
lines changed

18 files changed

+351
-31
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Wave Terminal's AI assistant is already powerful and continues to evolve. Here's
3434

3535
- 🔷 BYOK (Bring Your Own Key) - Use your own API keys for any supported provider
3636
- 🔧 Enhanced provider configuration options
37+
- 🔷 Context (add markdown files to give persistent system context)
3738

3839
### Expanded Provider Support
3940

docs/docs/config.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ wsh editconfig
6464
| editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false |
6565
| editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) |
6666
| editor:fontsize | float64 | set the font size for the editor (defaults to 12px) |
67+
| editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) |
6768
| preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) |
6869
| markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) |
6970
| markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) |

frontend/app/aipanel/aitooluse.tsx

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { BlockModel } from "@/app/block/block-model";
5-
import { cn } from "@/util/util";
5+
import { cn, fireAndForget } from "@/util/util";
66
import { memo, useEffect, useRef, useState } from "react";
77
import { WaveUIMessagePart } from "./aitypes";
88
import { WaveAIModel } from "./waveai-model";
@@ -141,6 +141,8 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
141141
const baseApproval = userApprovalOverride || toolData.approval;
142142
const effectiveApproval = !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval;
143143

144+
const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file";
145+
144146
useEffect(() => {
145147
if (!isStreaming || effectiveApproval !== "needs-approval") return;
146148

@@ -204,6 +206,10 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
204206
}
205207
};
206208

209+
const handleOpenDiff = () => {
210+
fireAndForget(() => WaveAIModel.getInstance().openDiff(toolData.inputfilename, toolData.toolcallid));
211+
};
212+
207213
return (
208214
<div
209215
className={cn("flex items-start gap-2 p-2 rounded bg-gray-800 border border-gray-700", statusColor)}
@@ -221,6 +227,16 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
221227
<AIToolApprovalButtons count={1} onApprove={handleApprove} onDeny={handleDeny} />
222228
)}
223229
</div>
230+
{isFileWriteTool && toolData.inputfilename && (
231+
<button
232+
onClick={handleOpenDiff}
233+
className="flex-shrink-0 px-2 py-1 border border-gray-600 hover:border-gray-500 hover:bg-gray-700 rounded cursor-pointer transition-colors flex items-center gap-1.5 text-gray-400"
234+
title="Open in diff viewer"
235+
>
236+
<span className="text-sm">Show Diff</span>
237+
<i className="fa fa-arrow-up-right-from-square text-sm"></i>
238+
</button>
239+
)}
224240
</div>
225241
);
226242
});
@@ -232,47 +248,50 @@ interface AIToolUseGroupProps {
232248
isStreaming: boolean;
233249
}
234250

251+
type ToolGroupItem =
252+
| { type: "batch"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> }
253+
| { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } };
254+
235255
export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => {
236256
const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => {
237257
const toolName = part.data?.toolname;
238258
return toolName === "read_text_file" || toolName === "read_dir";
239259
};
240260

241-
const fileOpsNeedApproval: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
242-
const fileOpsNoApproval: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
243-
const otherTools: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
261+
const groupedItems: ToolGroupItem[] = [];
262+
let currentBatch: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
244263

245264
for (const part of parts) {
246265
if (isFileOp(part)) {
247-
if (part.data.approval === "needs-approval") {
248-
fileOpsNeedApproval.push(part);
249-
} else {
250-
fileOpsNoApproval.push(part);
251-
}
266+
currentBatch.push(part);
252267
} else {
253-
otherTools.push(part);
268+
if (currentBatch.length > 0) {
269+
groupedItems.push({ type: "batch", parts: currentBatch });
270+
currentBatch = [];
271+
}
272+
groupedItems.push({ type: "single", part });
254273
}
255274
}
256275

276+
if (currentBatch.length > 0) {
277+
groupedItems.push({ type: "batch", parts: currentBatch });
278+
}
279+
257280
return (
258281
<>
259-
{fileOpsNoApproval.length > 0 && (
260-
<div className="mt-2">
261-
<AIToolUseBatch parts={fileOpsNoApproval} isStreaming={isStreaming} />
262-
</div>
282+
{groupedItems.map((item, idx) =>
283+
item.type === "batch" ? (
284+
<div key={idx} className="mt-2">
285+
<AIToolUseBatch parts={item.parts} isStreaming={isStreaming} />
286+
</div>
287+
) : (
288+
<div key={idx} className="mt-2">
289+
<AIToolUse part={item.part} isStreaming={isStreaming} />
290+
</div>
291+
)
263292
)}
264-
{fileOpsNeedApproval.length > 0 && (
265-
<div className="mt-2">
266-
<AIToolUseBatch parts={fileOpsNeedApproval} isStreaming={isStreaming} />
267-
</div>
268-
)}
269-
{otherTools.map((tool, idx) => (
270-
<div key={idx} className="mt-2">
271-
<AIToolUse part={tool} isStreaming={isStreaming} />
272-
</div>
273-
))}
274293
</>
275294
);
276295
});
277296

278-
AIToolUseGroup.displayName = "AIToolUseGroup";
297+
AIToolUseGroup.displayName = "AIToolUseGroup";

frontend/app/aipanel/aitypes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import { ChatRequestOptions, FileUIPart, UIMessage, UIMessagePart } from "ai";
55

66
type WaveUIDataTypes = {
7+
// pkg/aiusechat/uctypes/usechat-types.go UIMessageDataUserFile
78
userfile: {
89
filename: string;
910
size: number;
1011
mimetype: string;
1112
previewurl?: string;
1213
};
14+
// pkg/aiusechat/uctypes/usechat-types.go UIMessageDataToolUse
1315
tooluse: {
1416
toolcallid: string;
1517
toolname: string;
@@ -18,6 +20,7 @@ type WaveUIDataTypes = {
1820
errormessage?: string;
1921
approval?: "needs-approval" | "user-approved" | "user-denied" | "auto-approved" | "timeout";
2022
blockid?: string;
23+
inputfilename?: string;
2124
};
2225
};
2326

frontend/app/aipanel/waveai-model.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
WaveUIMessagePart,
99
} from "@/app/aipanel/aitypes";
1010
import { FocusManager } from "@/app/store/focusManager";
11-
import { atoms, getOrefMetaKeyAtom } from "@/app/store/global";
11+
import { atoms, createBlock, getOrefMetaKeyAtom } from "@/app/store/global";
1212
import { globalStore } from "@/app/store/jotaiStore";
1313
import * as WOS from "@/app/store/wos";
1414
import { RpcApi } from "@/app/store/wshclientapi";
@@ -412,6 +412,10 @@ export class WaveAIModel {
412412
}
413413
}
414414

415+
getChatId(): string {
416+
return globalStore.get(this.chatId);
417+
}
418+
415419
toolUseKeepalive(toolcallid: string) {
416420
RpcApi.WaveAIToolApproveCommand(
417421
TabRpcClient,
@@ -429,4 +433,23 @@ export class WaveAIModel {
429433
approval: approval,
430434
});
431435
}
436+
437+
async openDiff(fileName: string, toolcallid: string) {
438+
const chatId = this.getChatId();
439+
440+
if (!chatId || !fileName) {
441+
console.error("Missing chatId or fileName for opening diff", chatId, fileName);
442+
return;
443+
}
444+
445+
const blockDef: BlockDef = {
446+
meta: {
447+
view: "aifilediff",
448+
file: fileName,
449+
"aifilediff:chatid": chatId,
450+
"aifilediff:toolcallid": toolcallid,
451+
},
452+
};
453+
await createBlock(blockDef, false, true);
454+
}
432455
}

frontend/app/block/block.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
FullSubBlockProps,
1010
SubBlockProps,
1111
} from "@/app/block/blocktypes";
12+
import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff";
1213
import { LauncherViewModel } from "@/app/view/launcher/launcher";
1314
import { PreviewModel } from "@/app/view/preview/preview-model";
1415
import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
@@ -50,6 +51,7 @@ BlockRegistry.set("tips", QuickTipsViewModel);
5051
BlockRegistry.set("help", HelpViewModel);
5152
BlockRegistry.set("launcher", LauncherViewModel);
5253
BlockRegistry.set("tsunami", TsunamiViewModel);
54+
BlockRegistry.set("aifilediff", AiFileDiffViewModel);
5355

5456
function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel {
5557
const ctor = BlockRegistry.get(blockView);

frontend/app/store/keymodel.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,19 @@ function uxCloseBlock(blockId: string) {
152152
return;
153153
}
154154
}
155+
156+
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId));
157+
const blockData = globalStore.get(blockAtom);
158+
const isAIFileDiff = blockData?.meta?.view === "aifilediff";
159+
155160
const layoutModel = getLayoutModelForStaticTab();
156161
const node = layoutModel.getNodeByBlockId(blockId);
157162
if (node) {
158163
fireAndForget(() => layoutModel.closeNode(node.id));
164+
165+
if (isAIFileDiff && isAIPanelOpen) {
166+
setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);
167+
}
159168
}
160169
}
161170

@@ -190,8 +199,19 @@ function genericClose() {
190199
simpleCloseStaticTab();
191200
return;
192201
}
202+
193203
const layoutModel = getLayoutModelForStaticTab();
204+
const focusedNode = globalStore.get(layoutModel.focusedNode);
205+
const blockId = focusedNode?.data?.blockId;
206+
const blockAtom = blockId ? WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId)) : null;
207+
const blockData = blockAtom ? globalStore.get(blockAtom) : null;
208+
const isAIFileDiff = blockData?.meta?.view === "aifilediff";
209+
194210
fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel));
211+
212+
if (isAIFileDiff && isAIPanelOpen) {
213+
setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);
214+
}
195215
}
196216

197217
function switchBlockByBlockNum(index: number) {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { RpcApi } from "@/app/store/wshclientapi";
5+
import { TabRpcClient } from "@/app/store/wshrpcutil";
6+
import { DiffViewer } from "@/app/view/codeeditor/diffviewer";
7+
import { globalStore, WOS } from "@/store/global";
8+
import * as jotai from "jotai";
9+
import { useEffect } from "react";
10+
11+
type DiffData = {
12+
original: string;
13+
modified: string;
14+
fileName: string;
15+
};
16+
17+
export class AiFileDiffViewModel implements ViewModel {
18+
blockId: string;
19+
viewType = "aifilediff";
20+
blockAtom: jotai.Atom<Block>;
21+
diffDataAtom: jotai.PrimitiveAtom<DiffData | null>;
22+
errorAtom: jotai.PrimitiveAtom<string | null>;
23+
loadingAtom: jotai.PrimitiveAtom<boolean>;
24+
viewIcon: jotai.Atom<string>;
25+
viewName: jotai.Atom<string>;
26+
viewText: jotai.Atom<string>;
27+
28+
constructor(blockId: string) {
29+
this.blockId = blockId;
30+
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
31+
this.diffDataAtom = jotai.atom(null) as jotai.PrimitiveAtom<DiffData | null>;
32+
this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
33+
this.loadingAtom = jotai.atom<boolean>(true);
34+
this.viewIcon = jotai.atom("file-lines");
35+
this.viewName = jotai.atom("AI Diff Viewer");
36+
this.viewText = jotai.atom((get) => {
37+
const diffData = get(this.diffDataAtom);
38+
return diffData?.fileName ?? "";
39+
});
40+
}
41+
42+
get viewComponent(): ViewComponent {
43+
return AiFileDiffView;
44+
}
45+
}
46+
47+
const AiFileDiffView: React.FC<ViewComponentProps<AiFileDiffViewModel>> = ({ blockId, model }) => {
48+
const blockData = jotai.useAtomValue(model.blockAtom);
49+
const diffData = jotai.useAtomValue(model.diffDataAtom);
50+
const error = jotai.useAtomValue(model.errorAtom);
51+
const loading = jotai.useAtomValue(model.loadingAtom);
52+
53+
useEffect(() => {
54+
async function loadDiffData() {
55+
const chatId = blockData?.meta?.["aifilediff:chatid"];
56+
const toolCallId = blockData?.meta?.["aifilediff:toolcallid"];
57+
const fileName = blockData?.meta?.file;
58+
59+
if (!chatId || !toolCallId) {
60+
globalStore.set(model.errorAtom, "Missing chatId or toolCallId in block metadata");
61+
globalStore.set(model.loadingAtom, false);
62+
return;
63+
}
64+
65+
if (!fileName) {
66+
globalStore.set(model.errorAtom, "Missing file name in block metadata");
67+
globalStore.set(model.loadingAtom, false);
68+
return;
69+
}
70+
71+
try {
72+
const result = await RpcApi.WaveAIGetToolDiffCommand(TabRpcClient, {
73+
chatid: chatId,
74+
toolcallid: toolCallId,
75+
});
76+
77+
if (!result) {
78+
globalStore.set(model.errorAtom, "No diff data returned from server");
79+
globalStore.set(model.loadingAtom, false);
80+
return;
81+
}
82+
83+
const originalContent = atob(result.originalcontents64);
84+
const modifiedContent = atob(result.modifiedcontents64);
85+
86+
globalStore.set(model.diffDataAtom, {
87+
original: originalContent,
88+
modified: modifiedContent,
89+
fileName: fileName,
90+
});
91+
globalStore.set(model.loadingAtom, false);
92+
} catch (e) {
93+
console.error("Error loading diff data:", e);
94+
globalStore.set(model.errorAtom, `Error loading diff data: ${e.message}`);
95+
globalStore.set(model.loadingAtom, false);
96+
}
97+
}
98+
99+
loadDiffData();
100+
}, [blockData?.meta?.["aifilediff:chatid"], blockData?.meta?.["aifilediff:toolcallid"], blockData?.meta?.file]);
101+
102+
if (loading) {
103+
return (
104+
<div className="flex items-center justify-center w-full h-full">
105+
<div className="text-secondary">Loading diff...</div>
106+
</div>
107+
);
108+
}
109+
110+
if (error) {
111+
return (
112+
<div className="flex items-center justify-center w-full h-full">
113+
<div className="text-red-500">{error}</div>
114+
</div>
115+
);
116+
}
117+
118+
if (!diffData) {
119+
return (
120+
<div className="flex items-center justify-center w-full h-full">
121+
<div className="text-secondary">No diff data available</div>
122+
</div>
123+
);
124+
}
125+
126+
return (
127+
<DiffViewer
128+
blockId={blockId}
129+
original={diffData.original}
130+
modified={diffData.modified}
131+
fileName={diffData.fileName}
132+
/>
133+
);
134+
};
135+
136+
export default AiFileDiffView;

0 commit comments

Comments
 (0)