diff --git a/ui/text/src/components/DiffViewer.tsx b/ui/text/src/components/DiffViewer.tsx new file mode 100644 index 000000000000..5f4619048178 --- /dev/null +++ b/ui/text/src/components/DiffViewer.tsx @@ -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 ( + + + + git diff{truncated ? " (truncated)" : ""} + + + {atStart ? "" : "↑ "}lines {scroll + 1}– + {Math.min(scroll + viewportHeight, lines.length)} / {lines.length} + {" "}[{position}] + + + + {visible.map((line, i) => { + const kind = classifyLine(line); + const padded = padLine(line, innerWidth); + switch (kind) { + case "add": + return ( + + {padded} + + ); + case "remove": + return ( + + {padded} + + ); + case "hunk": + return ( + + {padded} + + ); + case "meta": + return ( + + {padded} + + ); + default: + return ( + + {padded} + + ); + } + })} + + + q + close · + ↑↓ + / + j k + scroll · + space + / + b + page · + g + / + G + top/bottom + + + ); +} diff --git a/ui/text/src/slashCommands.tsx b/ui/text/src/slashCommands.tsx new file mode 100644 index 000000000000..a71e3bd7a79a --- /dev/null +++ b/ui/text/src/slashCommands.tsx @@ -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 = { + 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); +} diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index 778e0f91b880..d88a15ac3256 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -20,6 +20,7 @@ import { resolveGooseBinary } from "@aaif/goose-sdk/node"; import Onboarding from "./onboarding.js"; import ConfigureScreen, { ConfigureIntent } from "./configure.js"; import ExtensionsManager from "./extensions.js"; +import { DiffViewer } from "./components/DiffViewer.js"; import type { Turn } from "./types.js"; import { emptyLine, @@ -53,6 +54,7 @@ import { SCROLL_STEP, SCROLL_FAST_MULTIPLIER, } from "./constants.js"; +import { tryRunSlashCommand } from "./slashCommands.js"; const InputBar = React.memo(function InputBar({ width, @@ -512,11 +514,13 @@ function App({ const [needsOnboarding, setNeedsOnboarding] = useState(false); type Overlay = | { screen: "configure"; intent: ConfigureIntent } - | { screen: "extensions" }; + | { screen: "extensions" } + | { screen: "diff"; content: string; truncated: boolean }; const [overlay, setOverlay] = useState(null); const clientRef = useRef(null); const sessionIdRef = useRef(null); + const sessionCwdRef = useRef(process.cwd()); const streamBuf = useRef(""); const sentInitialPrompt = useRef(false); const queueRef = useRef([]); @@ -707,8 +711,10 @@ function App({ setStatus("creating session…"); setLoading(true); try { + const cwd = process.cwd(); + sessionCwdRef.current = cwd; const session = await client.newSession({ - cwd: process.cwd(), + cwd, mcpServers: [], }); sessionIdRef.current = session.sessionId; @@ -825,6 +831,52 @@ function App({ 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( (value: string) => { const trimmed = value.trim(); @@ -837,6 +889,8 @@ function App({ setToolCallExpandedScroll(0); setScrollOffset(0); + if (trimmed.startsWith("/") && runSlashCommand(trimmed)) return; + if (loading || isProcessingRef.current) { queueRef.current.push(trimmed); setQueuedMessages([...queueRef.current]); @@ -844,7 +898,7 @@ function App({ sendPrompt(trimmed); } }, - [loading, sendPrompt], + [loading, sendPrompt, runSlashCommand], ); const PAD_X = 2; @@ -1087,6 +1141,18 @@ function App({ ); } + if (overlay && overlay.screen === "diff") { + return ( + setOverlay(null)} + /> + ); + } + if (overlay && clientRef.current && sessionIdRef.current) { if (overlay.screen === "configure") { const intent = overlay.intent;