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: 2 additions & 0 deletions apps/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
"@types/react": "^19.2.4"
},
"peerDependencies": {
Expand All @@ -27,6 +28,7 @@
"@techatnyu/ralphd": "workspace:*",
"@opentui/core": "^0.1.77",
"@opentui/react": "^0.1.77",
"diff": "^9.0.0",
"react": "^19.2.4"
}
}
29 changes: 28 additions & 1 deletion apps/tui/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { daemon } from "@techatnyu/ralphd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPromptTask } from "../lib/prompt-task";
import { ralphStore } from "../lib/store";
import { DiffViewer } from "./diff-viewer";

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

Expand Down Expand Up @@ -99,6 +100,7 @@ export function Chat({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId);
const [hydrated, setHydrated] = useState(false);
const [tab, setTab] = useState<"chat" | "diffs">("chat");
const [pendingQuestion, setPendingQuestion] =
useState<PendingQuestion | null>(null);
const sendLockRef = useRef(false);
Expand Down Expand Up @@ -309,6 +311,19 @@ export function Chat({
);

useKeyboard((event) => {
if (event.ctrl && event.name === "r") {
if (tab === "chat" && sessionId) {
setTab("diffs");
} else if (tab === "diffs") {
setTab("chat");
}
return;
}

if (tab !== "chat") {
return;
}

if (event.ctrl && event.name === "c") {
onQuit();
}
Expand Down Expand Up @@ -487,14 +502,26 @@ export function Chat({
const activeAnswers =
pendingQuestion?.answers[pendingQuestion.currentIndex] ?? [];

if (tab === "diffs" && sessionId) {
return (
<DiffViewer
instanceId={instanceId}
sessionId={sessionId}
onBack={() => setTab("chat")}
onQuit={onQuit}
/>
);
}

return (
<box flexDirection="column" flexGrow={1} width="100%">
<box flexShrink={0} height={1} width="100%">
<text attributes={TextAttributes.DIM}>
Ralph Chat · {instanceName}
{sessionId ? ` · session: ${sessionId.slice(0, 8)}` : ""}
{errorMessage ? ` · error: ${errorMessage}` : ""} · PgUp/PgDn or
Ctrl+U/Ctrl+D scroll · esc back · ctrl+c quit
Ctrl+U/Ctrl+D scroll{sessionId ? " · ctrl+r diffs" : ""} · esc back ·
ctrl+c quit
</text>
</box>

Expand Down
235 changes: 235 additions & 0 deletions apps/tui/src/components/diff-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import type { ScrollBoxRenderable } from "@opentui/core";
import { TextAttributes } from "@opentui/core";
import { useKeyboard } from "@opentui/react";
import type { FileDiff } from "@techatnyu/ralphd";
import { daemon } from "@techatnyu/ralphd";
import { useEffect, useRef, useState } from "react";

const DIFFS_POLL_INTERVAL_MS = 500;

interface DiffViewerProps {
instanceId: string;
sessionId: string;
onBack(): void;
onQuit(): void;
}

function statusGlyph(status: FileDiff["status"]): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "modified":
return "M";
default:
return " ";
}
}

function extensionOf(file: string): string {
const dot = file.lastIndexOf(".");
if (dot < 0 || dot === file.length - 1) {
return "";
}
return file.slice(dot + 1);
}

interface FileRowProps {
diff: FileDiff;
focused: boolean;
expanded: boolean;
}

function FileRow({ diff, focused, expanded }: FileRowProps) {
return (
<box flexDirection="column" marginBottom={1}>
<box flexDirection="row">
<text attributes={focused ? TextAttributes.BOLD : TextAttributes.NONE}>
{`${focused ? "> " : " "}${statusGlyph(diff.status)} ${diff.file} `}
</text>
<text fg="#5fff87">{`+${diff.additions}`}</text>
<text>{" "}</text>
<text fg="#ff5f5f">{`-${diff.deletions}`}</text>
</box>
{expanded ? (
<box marginLeft={2} marginTop={1}>
<diff
diff={diff.patch}
view="unified"
filetype={extensionOf(diff.file)}
showLineNumbers={true}
wrapMode="none"
/>
</box>
) : null}
</box>
);
}

export function DiffViewer({
instanceId,
sessionId,
onBack,
onQuit,
}: DiffViewerProps) {
const [diffs, setDiffs] = useState<FileDiff[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const scrollRef = useRef<ScrollBoxRenderable | null>(null);

useEffect(() => {
let cancelled = false;
let inFlight = false;

const fetchDiffs = async () => {
if (inFlight || cancelled) {
return;
}
inFlight = true;
try {
const result = await daemon.sessionDiffs({ instanceId, sessionId });
if (!cancelled) {
setDiffs(result.diffs);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
inFlight = false;
}
};

void fetchDiffs();
const handle = setInterval(() => {
void fetchDiffs();
}, DIFFS_POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(handle);
};
}, [instanceId, sessionId]);

useKeyboard((event) => {
if (event.ctrl && event.name === "c") {
onQuit();
return;
}

if (event.name === "escape") {
onBack();
return;
}

if (event.name === "pageup") {
scrollRef.current?.scrollBy(-1, "viewport");
return;
}

if (event.name === "pagedown") {
scrollRef.current?.scrollBy(1, "viewport");
return;
}

if (event.ctrl && event.name === "u") {
scrollRef.current?.scrollBy(-0.5, "viewport");
return;
}

if (event.ctrl && event.name === "d") {
scrollRef.current?.scrollBy(0.5, "viewport");
return;
}

if (event.name === "down" || event.name === "j") {
setSelectedIndex((prev) =>
Math.max(0, Math.min(prev + 1, diffs.length - 1)),
);
return;
}

if (event.name === "up" || event.name === "k") {
setSelectedIndex((prev) =>
Math.max(0, Math.min(prev - 1, diffs.length - 1)),
);
return;
}

if (event.name === "return" || event.name === "space") {
const current = diffs[selectedIndex];
if (!current) {
return;
}
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(current.file)) {
next.delete(current.file);
} else {
next.add(current.file);
}
return next;
});
return;
}

if (event.name === "e") {
setExpanded((prev) => {
if (prev.size < diffs.length) {
return new Set(diffs.map((d) => d.file));
}
return new Set();
});
return;
}
});

let additions = 0;
let deletions = 0;
for (const d of diffs) {
additions += d.additions;
deletions += d.deletions;
}

return (
<box flexDirection="column" flexGrow={1} width="100%">
<box flexShrink={0} height={1} width="100%">
<text attributes={TextAttributes.DIM}>
{`Diffs · ${diffs.length} file${diffs.length === 1 ? "" : "s"} · +${additions}/-${deletions} · j/k nav · enter expand · e all · esc back · ctrl+c quit`}
</text>
</box>

<scrollbox
ref={scrollRef}
flexGrow={1}
flexShrink={1}
minHeight={0}
width="100%"
border={true}
padding={0}
stickyScroll={false}
>
{diffs.length === 0 ? (
<text attributes={TextAttributes.DIM}>No changes yet.</text>
) : (
diffs.map((d, i) => (
<FileRow
key={d.file}
diff={d}
focused={i === selectedIndex}
expanded={expanded.has(d.file)}
/>
))
)}
</scrollbox>

{error ? (
<box flexShrink={0} height={1} width="100%">
<text fg="#ff5f5f">{`Error: ${error}`}</text>
</box>
) : null}
</box>
);
}
12 changes: 9 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading