Skip to content

Commit 1fc2d1c

Browse files
Aleexc12chenxin-yan
authored andcommitted
feat: per-session diff viewer
1 parent 954e49f commit 1fc2d1c

11 files changed

Lines changed: 565 additions & 4 deletions

File tree

apps/tui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"devDependencies": {
1616
"@types/bun": "latest",
17+
"@types/diff": "^8.0.0",
1718
"@types/react": "^19.2.4"
1819
},
1920
"peerDependencies": {
@@ -27,6 +28,7 @@
2728
"@techatnyu/ralphd": "workspace:*",
2829
"@opentui/core": "^0.1.77",
2930
"@opentui/react": "^0.1.77",
31+
"diff": "^9.0.0",
3032
"react": "^19.2.4"
3133
}
3234
}

apps/tui/src/components/chat.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { daemon } from "@techatnyu/ralphd";
66
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
77
import { createPromptTask } from "../lib/prompt-task";
88
import { ralphStore } from "../lib/store";
9+
import { DiffViewer } from "./diff-viewer";
910

1011
type Role = "user" | "assistant" | "system";
1112

@@ -62,6 +63,7 @@ export function Chat({
6263
const [errorMessage, setErrorMessage] = useState<string | null>(null);
6364
const [sessionId, setSessionId] = useState<string | null>(initialSessionId);
6465
const [hydrated, setHydrated] = useState(false);
66+
const [tab, setTab] = useState<"chat" | "diffs">("chat");
6567
const sendLockRef = useRef(false);
6668
const chatScrollRef = useRef<ScrollBoxRenderable | null>(null);
6769

@@ -235,6 +237,19 @@ export function Chat({
235237
}, [instanceId, instanceName, sessionId, consumeStream]);
236238

237239
useKeyboard((event) => {
240+
if (event.ctrl && event.name === "r") {
241+
if (tab === "chat" && sessionId) {
242+
setTab("diffs");
243+
} else if (tab === "diffs") {
244+
setTab("chat");
245+
}
246+
return;
247+
}
248+
249+
if (tab !== "chat") {
250+
return;
251+
}
252+
238253
if (event.ctrl && event.name === "c") {
239254
onQuit();
240255
}
@@ -316,14 +331,26 @@ export function Chat({
316331
[instanceId, sessionId, isLoading, hydrated, consumeStream],
317332
);
318333

334+
if (tab === "diffs" && sessionId) {
335+
return (
336+
<DiffViewer
337+
instanceId={instanceId}
338+
sessionId={sessionId}
339+
onBack={() => setTab("chat")}
340+
onQuit={onQuit}
341+
/>
342+
);
343+
}
344+
319345
return (
320346
<box flexDirection="column" flexGrow={1} width="100%">
321347
<box flexShrink={0} height={1} width="100%">
322348
<text attributes={TextAttributes.DIM}>
323349
Ralph Chat · {instanceName}
324350
{sessionId ? ` · session: ${sessionId.slice(0, 8)}` : ""}
325351
{errorMessage ? ` · error: ${errorMessage}` : ""} · PgUp/PgDn or
326-
Ctrl+U/Ctrl+D scroll · esc back · ctrl+c quit
352+
Ctrl+U/Ctrl+D scroll{sessionId ? " · ctrl+r diffs" : ""} · esc back ·
353+
ctrl+c quit
327354
</text>
328355
</box>
329356

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import type { ScrollBoxRenderable } from "@opentui/core";
2+
import { TextAttributes } from "@opentui/core";
3+
import { useKeyboard } from "@opentui/react";
4+
import type { FileDiff } from "@techatnyu/ralphd";
5+
import { daemon } from "@techatnyu/ralphd";
6+
import { useEffect, useRef, useState } from "react";
7+
8+
const DIFFS_POLL_INTERVAL_MS = 500;
9+
10+
interface DiffViewerProps {
11+
instanceId: string;
12+
sessionId: string;
13+
onBack(): void;
14+
onQuit(): void;
15+
}
16+
17+
function statusGlyph(status: FileDiff["status"]): string {
18+
switch (status) {
19+
case "added":
20+
return "A";
21+
case "deleted":
22+
return "D";
23+
case "modified":
24+
return "M";
25+
default:
26+
return " ";
27+
}
28+
}
29+
30+
function extensionOf(file: string): string {
31+
const dot = file.lastIndexOf(".");
32+
if (dot < 0 || dot === file.length - 1) {
33+
return "";
34+
}
35+
return file.slice(dot + 1);
36+
}
37+
38+
interface FileRowProps {
39+
diff: FileDiff;
40+
focused: boolean;
41+
expanded: boolean;
42+
}
43+
44+
function FileRow({ diff, focused, expanded }: FileRowProps) {
45+
return (
46+
<box flexDirection="column" marginBottom={1}>
47+
<box flexDirection="row">
48+
<text attributes={focused ? TextAttributes.BOLD : TextAttributes.NONE}>
49+
{`${focused ? "> " : " "}${statusGlyph(diff.status)} ${diff.file} `}
50+
</text>
51+
<text fg="#5fff87">{`+${diff.additions}`}</text>
52+
<text>{" "}</text>
53+
<text fg="#ff5f5f">{`-${diff.deletions}`}</text>
54+
</box>
55+
{expanded ? (
56+
<box marginLeft={2} marginTop={1}>
57+
<diff
58+
diff={diff.patch}
59+
view="unified"
60+
filetype={extensionOf(diff.file)}
61+
showLineNumbers={true}
62+
wrapMode="none"
63+
/>
64+
</box>
65+
) : null}
66+
</box>
67+
);
68+
}
69+
70+
export function DiffViewer({
71+
instanceId,
72+
sessionId,
73+
onBack,
74+
onQuit,
75+
}: DiffViewerProps) {
76+
const [diffs, setDiffs] = useState<FileDiff[]>([]);
77+
const [error, setError] = useState<string | null>(null);
78+
const [selectedIndex, setSelectedIndex] = useState(0);
79+
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
80+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
81+
82+
useEffect(() => {
83+
let cancelled = false;
84+
let inFlight = false;
85+
86+
const fetchDiffs = async () => {
87+
if (inFlight || cancelled) {
88+
return;
89+
}
90+
inFlight = true;
91+
try {
92+
const result = await daemon.sessionDiffs({ instanceId, sessionId });
93+
if (!cancelled) {
94+
setDiffs(result.diffs);
95+
setError(null);
96+
}
97+
} catch (err) {
98+
if (!cancelled) {
99+
setError(err instanceof Error ? err.message : String(err));
100+
}
101+
} finally {
102+
inFlight = false;
103+
}
104+
};
105+
106+
void fetchDiffs();
107+
const handle = setInterval(() => {
108+
void fetchDiffs();
109+
}, DIFFS_POLL_INTERVAL_MS);
110+
return () => {
111+
cancelled = true;
112+
clearInterval(handle);
113+
};
114+
}, [instanceId, sessionId]);
115+
116+
useKeyboard((event) => {
117+
if (event.ctrl && event.name === "c") {
118+
onQuit();
119+
return;
120+
}
121+
122+
if (event.name === "escape") {
123+
onBack();
124+
return;
125+
}
126+
127+
if (event.name === "pageup") {
128+
scrollRef.current?.scrollBy(-1, "viewport");
129+
return;
130+
}
131+
132+
if (event.name === "pagedown") {
133+
scrollRef.current?.scrollBy(1, "viewport");
134+
return;
135+
}
136+
137+
if (event.ctrl && event.name === "u") {
138+
scrollRef.current?.scrollBy(-0.5, "viewport");
139+
return;
140+
}
141+
142+
if (event.ctrl && event.name === "d") {
143+
scrollRef.current?.scrollBy(0.5, "viewport");
144+
return;
145+
}
146+
147+
if (event.name === "down" || event.name === "j") {
148+
setSelectedIndex((prev) =>
149+
Math.max(0, Math.min(prev + 1, diffs.length - 1)),
150+
);
151+
return;
152+
}
153+
154+
if (event.name === "up" || event.name === "k") {
155+
setSelectedIndex((prev) =>
156+
Math.max(0, Math.min(prev - 1, diffs.length - 1)),
157+
);
158+
return;
159+
}
160+
161+
if (event.name === "return" || event.name === "space") {
162+
const current = diffs[selectedIndex];
163+
if (!current) {
164+
return;
165+
}
166+
setExpanded((prev) => {
167+
const next = new Set(prev);
168+
if (next.has(current.file)) {
169+
next.delete(current.file);
170+
} else {
171+
next.add(current.file);
172+
}
173+
return next;
174+
});
175+
return;
176+
}
177+
178+
if (event.name === "e") {
179+
setExpanded((prev) => {
180+
if (prev.size < diffs.length) {
181+
return new Set(diffs.map((d) => d.file));
182+
}
183+
return new Set();
184+
});
185+
return;
186+
}
187+
});
188+
189+
let additions = 0;
190+
let deletions = 0;
191+
for (const d of diffs) {
192+
additions += d.additions;
193+
deletions += d.deletions;
194+
}
195+
196+
return (
197+
<box flexDirection="column" flexGrow={1} width="100%">
198+
<box flexShrink={0} height={1} width="100%">
199+
<text attributes={TextAttributes.DIM}>
200+
{`Diffs · ${diffs.length} file${diffs.length === 1 ? "" : "s"} · +${additions}/-${deletions} · j/k nav · enter expand · e all · esc back · ctrl+c quit`}
201+
</text>
202+
</box>
203+
204+
<scrollbox
205+
ref={scrollRef}
206+
flexGrow={1}
207+
flexShrink={1}
208+
minHeight={0}
209+
width="100%"
210+
border={true}
211+
padding={0}
212+
stickyScroll={false}
213+
>
214+
{diffs.length === 0 ? (
215+
<text attributes={TextAttributes.DIM}>No changes yet.</text>
216+
) : (
217+
diffs.map((d, i) => (
218+
<FileRow
219+
key={d.file}
220+
diff={d}
221+
focused={i === selectedIndex}
222+
expanded={expanded.has(d.file)}
223+
/>
224+
))
225+
)}
226+
</scrollbox>
227+
228+
{error ? (
229+
<box flexShrink={0} height={1} width="100%">
230+
<text fg="#ff5f5f">{`Error: ${error}`}</text>
231+
</box>
232+
) : null}
233+
</box>
234+
);
235+
}

bun.lock

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)