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;