feat(tui): diff viewer (#9260)
Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import {
|
||||||
|
TEXT_DIM,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
GOLD,
|
||||||
|
TEAL,
|
||||||
|
CRANBERRY,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
} from "../colors.js";
|
||||||
|
import { SCROLL_FAST_MULTIPLIER } from "../constants.js";
|
||||||
|
|
||||||
|
const PAD_X = 2;
|
||||||
|
const PAD_Y = 1;
|
||||||
|
const HEADER_LINES = 1;
|
||||||
|
const FOOTER_LINES = 1;
|
||||||
|
|
||||||
|
type LineKind = "add" | "remove" | "hunk" | "meta" | "context";
|
||||||
|
|
||||||
|
function classifyLine(line: string): LineKind {
|
||||||
|
if (line.startsWith("+++") || line.startsWith("---")) return "meta";
|
||||||
|
if (
|
||||||
|
line.startsWith("diff ") ||
|
||||||
|
line.startsWith("index ") ||
|
||||||
|
line.startsWith("new file") ||
|
||||||
|
line.startsWith("deleted file") ||
|
||||||
|
line.startsWith("rename ") ||
|
||||||
|
line.startsWith("similarity ") ||
|
||||||
|
line.startsWith("Binary ")
|
||||||
|
) {
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
if (line.startsWith("@@")) return "hunk";
|
||||||
|
if (line.startsWith("+")) return "add";
|
||||||
|
if (line.startsWith("-")) return "remove";
|
||||||
|
return "context";
|
||||||
|
}
|
||||||
|
|
||||||
|
function padLine(line: string, width: number): string {
|
||||||
|
if (line.length >= width) return line.slice(0, width);
|
||||||
|
return line + " ".repeat(width - line.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffViewer({
|
||||||
|
content,
|
||||||
|
truncated,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const lines = useMemo(() => {
|
||||||
|
const split = content.split("\n");
|
||||||
|
if (split.length > 0 && split[split.length - 1] === "") split.pop();
|
||||||
|
return split;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const innerWidth = Math.max(width - PAD_X * 2, 10);
|
||||||
|
const innerHeight = Math.max(height - PAD_Y * 2, 3);
|
||||||
|
const viewportHeight = Math.max(
|
||||||
|
innerHeight - HEADER_LINES - FOOTER_LINES,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const maxScroll = Math.max(lines.length - viewportHeight, 0);
|
||||||
|
|
||||||
|
const [scroll, setScroll] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setScroll((prev) => Math.min(prev, maxScroll));
|
||||||
|
}, [maxScroll]);
|
||||||
|
|
||||||
|
useInput((ch, key) => {
|
||||||
|
if (ch === "q" || ch === "Q" || key.escape) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.ctrl && (ch === "c" || ch === "C")) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow || ch === "j") {
|
||||||
|
const step = key.meta ? SCROLL_FAST_MULTIPLIER : 1;
|
||||||
|
setScroll((s) => Math.min(s + step, maxScroll));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.upArrow || ch === "k") {
|
||||||
|
const step = key.meta ? SCROLL_FAST_MULTIPLIER : 1;
|
||||||
|
setScroll((s) => Math.max(s - step, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.pageDown || ch === " " || (key.ctrl && ch === "d")) {
|
||||||
|
setScroll((s) => Math.min(s + viewportHeight, maxScroll));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.pageUp || ch === "b" || (key.ctrl && ch === "u")) {
|
||||||
|
setScroll((s) => Math.max(s - viewportHeight, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ch === "g") {
|
||||||
|
setScroll(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ch === "G") {
|
||||||
|
setScroll(maxScroll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = lines.slice(scroll, scroll + viewportHeight);
|
||||||
|
|
||||||
|
const atEnd = scroll >= maxScroll;
|
||||||
|
const atStart = scroll === 0;
|
||||||
|
const position = maxScroll === 0
|
||||||
|
? "ALL"
|
||||||
|
: atEnd
|
||||||
|
? "END"
|
||||||
|
: `${Math.round((scroll / maxScroll) * 100)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
paddingX={PAD_X}
|
||||||
|
paddingY={PAD_Y}
|
||||||
|
>
|
||||||
|
<Box width={innerWidth} justifyContent="space-between" flexShrink={0}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>
|
||||||
|
git diff{truncated ? " (truncated)" : ""}
|
||||||
|
</Text>
|
||||||
|
<Text color={TEXT_DIM}>
|
||||||
|
{atStart ? "" : "↑ "}lines {scroll + 1}–
|
||||||
|
{Math.min(scroll + viewportHeight, lines.length)} / {lines.length}
|
||||||
|
{" "}[{position}]
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column" width={innerWidth} height={viewportHeight}>
|
||||||
|
{visible.map((line, i) => {
|
||||||
|
const kind = classifyLine(line);
|
||||||
|
const padded = padLine(line, innerWidth);
|
||||||
|
switch (kind) {
|
||||||
|
case "add":
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
wrap="truncate-end"
|
||||||
|
color={TEXT_PRIMARY}
|
||||||
|
backgroundColor={TEAL}
|
||||||
|
>
|
||||||
|
{padded}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case "remove":
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
wrap="truncate-end"
|
||||||
|
color={TEXT_PRIMARY}
|
||||||
|
backgroundColor={CRANBERRY}
|
||||||
|
>
|
||||||
|
{padded}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case "hunk":
|
||||||
|
return (
|
||||||
|
<Text key={i} wrap="truncate-end" color={GOLD} bold>
|
||||||
|
{padded}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case "meta":
|
||||||
|
return (
|
||||||
|
<Text key={i} wrap="truncate-end" color={TEXT_SECONDARY} bold>
|
||||||
|
{padded}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Text key={i} wrap="truncate-end" color={TEXT_PRIMARY}>
|
||||||
|
{padded}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Box width={innerWidth} flexShrink={0}>
|
||||||
|
<Text color={GOLD}>q</Text>
|
||||||
|
<Text color={TEXT_DIM}> close · </Text>
|
||||||
|
<Text color={GOLD}>↑↓</Text>
|
||||||
|
<Text color={TEXT_DIM}>/</Text>
|
||||||
|
<Text color={GOLD}>j k</Text>
|
||||||
|
<Text color={TEXT_DIM}> scroll · </Text>
|
||||||
|
<Text color={GOLD}>space</Text>
|
||||||
|
<Text color={TEXT_DIM}>/</Text>
|
||||||
|
<Text color={GOLD}>b</Text>
|
||||||
|
<Text color={TEXT_DIM}> page · </Text>
|
||||||
|
<Text color={GOLD}>g</Text>
|
||||||
|
<Text color={TEXT_DIM}>/</Text>
|
||||||
|
<Text color={GOLD}>G</Text>
|
||||||
|
<Text color={TEXT_DIM}> top/bottom</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
export interface SlashCommandContext {
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlashCommandResult =
|
||||||
|
| { handled: true; message?: string }
|
||||||
|
| { handled: true; overlay: "diff"; content: string; truncated: boolean }
|
||||||
|
| { handled: false };
|
||||||
|
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
run: (ctx: SlashCommandContext) => SlashCommandResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitRepo(cwd: string): boolean {
|
||||||
|
const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
||||||
|
cwd,
|
||||||
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DIFF_BYTES = 2_000_000;
|
||||||
|
|
||||||
|
function readDiff(cwd: string): { text: string; truncated: boolean } | null {
|
||||||
|
const result = spawnSync(
|
||||||
|
"git",
|
||||||
|
["--no-pager", "diff", "--no-color"],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status !== 0 && result.status !== null) return null;
|
||||||
|
const stdout = result.stdout ?? "";
|
||||||
|
if (stdout.length > MAX_DIFF_BYTES) {
|
||||||
|
return { text: stdout.slice(0, MAX_DIFF_BYTES), truncated: true };
|
||||||
|
}
|
||||||
|
return { text: stdout, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffCommand: SlashCommand = {
|
||||||
|
name: "diff",
|
||||||
|
description: "show unstaged changes",
|
||||||
|
run: (ctx) => {
|
||||||
|
if (!isGitRepo(ctx.cwd)) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
message: `not a git repository: ${ctx.cwd}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = readDiff(ctx.cwd);
|
||||||
|
if (diff === null) {
|
||||||
|
return { handled: true, message: "failed to run `git diff`" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff.text.trim().length === 0) {
|
||||||
|
return { handled: true, message: "no unstaged changes" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
overlay: "diff",
|
||||||
|
content: diff.text,
|
||||||
|
truncated: diff.truncated,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMANDS: Record<string, SlashCommand> = {
|
||||||
|
diff: diffCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tryRunSlashCommand(
|
||||||
|
input: string,
|
||||||
|
ctx: SlashCommandContext,
|
||||||
|
): SlashCommandResult {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed.startsWith("/")) return { handled: false };
|
||||||
|
const name = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||||
|
const cmd = COMMANDS[name];
|
||||||
|
if (!cmd) return { handled: false };
|
||||||
|
return cmd.run(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSlashCommands(): SlashCommand[] {
|
||||||
|
return Object.values(COMMANDS);
|
||||||
|
}
|
||||||
+69
-3
@@ -20,6 +20,7 @@ import { resolveGooseBinary } from "@aaif/goose-sdk/node";
|
|||||||
import Onboarding from "./onboarding.js";
|
import Onboarding from "./onboarding.js";
|
||||||
import ConfigureScreen, { ConfigureIntent } from "./configure.js";
|
import ConfigureScreen, { ConfigureIntent } from "./configure.js";
|
||||||
import ExtensionsManager from "./extensions.js";
|
import ExtensionsManager from "./extensions.js";
|
||||||
|
import { DiffViewer } from "./components/DiffViewer.js";
|
||||||
import type { Turn } from "./types.js";
|
import type { Turn } from "./types.js";
|
||||||
import {
|
import {
|
||||||
emptyLine,
|
emptyLine,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
SCROLL_STEP,
|
SCROLL_STEP,
|
||||||
SCROLL_FAST_MULTIPLIER,
|
SCROLL_FAST_MULTIPLIER,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
import { tryRunSlashCommand } from "./slashCommands.js";
|
||||||
|
|
||||||
const InputBar = React.memo(function InputBar({
|
const InputBar = React.memo(function InputBar({
|
||||||
width,
|
width,
|
||||||
@@ -512,11 +514,13 @@ function App({
|
|||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
type Overlay =
|
type Overlay =
|
||||||
| { screen: "configure"; intent: ConfigureIntent }
|
| { screen: "configure"; intent: ConfigureIntent }
|
||||||
| { screen: "extensions" };
|
| { screen: "extensions" }
|
||||||
|
| { screen: "diff"; content: string; truncated: boolean };
|
||||||
const [overlay, setOverlay] = useState<Overlay | null>(null);
|
const [overlay, setOverlay] = useState<Overlay | null>(null);
|
||||||
|
|
||||||
const clientRef = useRef<GooseClient | null>(null);
|
const clientRef = useRef<GooseClient | null>(null);
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const sessionCwdRef = useRef<string>(process.cwd());
|
||||||
const streamBuf = useRef("");
|
const streamBuf = useRef("");
|
||||||
const sentInitialPrompt = useRef(false);
|
const sentInitialPrompt = useRef(false);
|
||||||
const queueRef = useRef<string[]>([]);
|
const queueRef = useRef<string[]>([]);
|
||||||
@@ -707,8 +711,10 @@ function App({
|
|||||||
setStatus("creating session…");
|
setStatus("creating session…");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
sessionCwdRef.current = cwd;
|
||||||
const session = await client.newSession({
|
const session = await client.newSession({
|
||||||
cwd: process.cwd(),
|
cwd,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
});
|
});
|
||||||
sessionIdRef.current = session.sessionId;
|
sessionIdRef.current = session.sessionId;
|
||||||
@@ -825,6 +831,52 @@ function App({
|
|||||||
exit,
|
exit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const addLocalTurn = useCallback(
|
||||||
|
(userText: string, message?: string) => {
|
||||||
|
setTurns((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
userText,
|
||||||
|
responseItems: message
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
itemType: "content_chunk",
|
||||||
|
content: { type: "text", text: message },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
toolCallsById: new Map(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setViewTurnIdx(-1);
|
||||||
|
setSelectedToolCallIdx(null);
|
||||||
|
setToolCallExpanded(false);
|
||||||
|
setToolCallExpandedScroll(0);
|
||||||
|
setScrollOffset(0);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runSlashCommand = useCallback(
|
||||||
|
(raw: string): boolean => {
|
||||||
|
const result = tryRunSlashCommand(raw, {
|
||||||
|
cwd: sessionCwdRef.current,
|
||||||
|
});
|
||||||
|
if (!result.handled) return false;
|
||||||
|
if ("overlay" in result && result.overlay === "diff") {
|
||||||
|
setOverlay({
|
||||||
|
screen: "diff",
|
||||||
|
content: result.content,
|
||||||
|
truncated: result.truncated,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
addLocalTurn(raw, "message" in result ? result.message : undefined);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[addLocalTurn],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -837,6 +889,8 @@ function App({
|
|||||||
setToolCallExpandedScroll(0);
|
setToolCallExpandedScroll(0);
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
|
|
||||||
|
if (trimmed.startsWith("/") && runSlashCommand(trimmed)) return;
|
||||||
|
|
||||||
if (loading || isProcessingRef.current) {
|
if (loading || isProcessingRef.current) {
|
||||||
queueRef.current.push(trimmed);
|
queueRef.current.push(trimmed);
|
||||||
setQueuedMessages([...queueRef.current]);
|
setQueuedMessages([...queueRef.current]);
|
||||||
@@ -844,7 +898,7 @@ function App({
|
|||||||
sendPrompt(trimmed);
|
sendPrompt(trimmed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loading, sendPrompt],
|
[loading, sendPrompt, runSlashCommand],
|
||||||
);
|
);
|
||||||
|
|
||||||
const PAD_X = 2;
|
const PAD_X = 2;
|
||||||
@@ -1087,6 +1141,18 @@ function App({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overlay && overlay.screen === "diff") {
|
||||||
|
return (
|
||||||
|
<DiffViewer
|
||||||
|
content={overlay.content}
|
||||||
|
truncated={overlay.truncated}
|
||||||
|
width={safeTermWidth}
|
||||||
|
height={safeTermHeight}
|
||||||
|
onClose={() => setOverlay(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (overlay && clientRef.current && sessionIdRef.current) {
|
if (overlay && clientRef.current && sessionIdRef.current) {
|
||||||
if (overlay.screen === "configure") {
|
if (overlay.screen === "configure") {
|
||||||
const intent = overlay.intent;
|
const intent = overlay.intent;
|
||||||
|
|||||||
Reference in New Issue
Block a user