Browse Source

feat(tui): initial impl of diff viewer (#28476)

James Long 3 months ago
parent
commit
17d66ee4fe

+ 22 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -59,6 +59,17 @@ export const Definitions = {
   command_list: keybind("ctrl+p", "List available commands"),
   help_show: keybind("none", "Open help dialog"),
   docs_open: keybind("none", "Open documentation"),
+  diff_close: keybind("escape,q", "Close diff viewer"),
+  diff_toggle: keybind("enter,space", "Toggle diff viewer item"),
+  diff_expand: keybind("right", "Expand diff viewer item"),
+  diff_collapse: keybind("left", "Collapse diff viewer item"),
+  diff_switch_focus: keybind("tab", "Switch diff viewer focus"),
+  diff_next_file: keybind("n", "Jump to next diff file"),
+  diff_previous_file: keybind("p", "Jump to previous diff file"),
+  diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"),
+  diff_single_patch: keybind("s", "Toggle single patch view"),
+  diff_switch_diff: keybind("d", "Switch diff viewer source"),
+  diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"),
 
   editor_open: keybind("<leader>e", "Open external editor"),
   theme_list: keybind("<leader>t", "List available themes"),
@@ -245,6 +256,17 @@ export const CommandMap = {
   command_list: "command.palette.show",
   help_show: "help.show",
   docs_open: "docs.open",
+  diff_close: "diff.close",
+  diff_toggle: "diff.toggle",
+  diff_expand: "diff.expand",
+  diff_collapse: "diff.collapse",
+  diff_switch_focus: "diff.switch_focus",
+  diff_next_file: "diff.next_file",
+  diff_previous_file: "diff.previous_file",
+  diff_toggle_file_tree: "diff.toggle_file_tree",
+  diff_single_patch: "diff.single_patch",
+  diff_switch_diff: "diff.switch_diff",
+  diff_toggle_view: "diff.toggle_view",
   editor_open: "prompt.editor",
   theme_list: "theme.switch",
   theme_switch_mode: "theme.switch_mode",

+ 155 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts

@@ -0,0 +1,155 @@
+// Paths branch softly through the screen,
+// A quiet tree of changed designs;
+// Each leaf remembers what has been,
+// And waits where careful light aligns.
+
+export type FileTreeItem = {
+  readonly file: string
+}
+
+export type FileTreeNode = {
+  readonly id: number
+  readonly name: string
+  readonly parent: number | undefined
+  readonly children: number[]
+  readonly depth: number
+  readonly kind: "directory" | "file"
+  readonly fileIndex?: number
+}
+
+export type FileTree = {
+  readonly roots: number[]
+  readonly nodes: FileTreeNode[]
+}
+
+export type FileTreeRow = {
+  readonly id: number
+  readonly depth: number
+  readonly kind: "directory" | "file"
+  readonly name: string
+  readonly fileIndex?: number
+}
+
+export function buildFileTree(files: readonly FileTreeItem[]): FileTree {
+  const roots: number[] = []
+  const nodes: FileTreeNode[] = []
+  const directoryByPath = new Map<string, number>()
+
+  files.forEach((file, fileIndex) => {
+    const segments = file.file.split("/").filter(Boolean)
+    if (segments.length === 0) return
+
+    const parent = segments.slice(0, -1).reduce(
+      (state, segment) => {
+        const directoryPath = state.path ? `${state.path}/${segment}` : segment
+        const existing = directoryByPath.get(directoryPath)
+        if (existing !== undefined) return { id: existing, path: directoryPath, depth: state.depth + 1 }
+
+        const id = addFileTreeNode(nodes, roots, {
+          name: segment,
+          parent: state.id,
+          depth: state.depth,
+          kind: "directory",
+        })
+        directoryByPath.set(directoryPath, id)
+        return { id, path: directoryPath, depth: state.depth + 1 }
+      },
+      { id: undefined as number | undefined, path: "", depth: 0 },
+    )
+
+    addFileTreeNode(nodes, roots, {
+      name: segments[segments.length - 1]!,
+      parent: parent.id,
+      depth: parent.depth,
+      kind: "file",
+      fileIndex,
+    })
+  })
+
+  const tree = { roots, nodes }
+  tree.roots.sort((left, right) => compareFileTreeNodes(tree, left, right))
+  tree.nodes.forEach((node) => node.children.sort((left, right) => compareFileTreeNodes(tree, left, right)))
+  return tree
+}
+
+export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
+  const rows: FileTreeRow[] = []
+  const visit = (id: number) => {
+    const node = tree.nodes[id]!
+    rows.push({
+      id: node.id,
+      depth: node.depth,
+      kind: node.kind,
+      name: node.name,
+      fileIndex: node.fileIndex,
+    })
+    if (node.kind === "directory" && (!expanded || expanded.has(node.id))) node.children.forEach(visit)
+  }
+  tree.roots.forEach(visit)
+  return rows
+}
+
+export function compareFileTreeNodes(tree: FileTree, left: number, right: number) {
+  const leftNode = tree.nodes[left]!
+  const rightNode = tree.nodes[right]!
+  if (leftNode.kind !== rightNode.kind) return leftNode.kind === "directory" ? -1 : 1
+  if (leftNode.name < rightNode.name) return -1
+  if (leftNode.name > rightNode.name) return 1
+  return left - right
+}
+
+export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
+  if (rows.length === 0) return undefined
+  const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
+  if (index === -1) return rows[0]!.id
+  return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
+}
+
+export function moveFileTreeSelectionToFile(
+  rows: readonly FileTreeRow[],
+  selected: number | undefined,
+  offset: number,
+) {
+  const fileRows = rows.filter((row) => row.fileIndex !== undefined)
+  if (fileRows.length === 0) return undefined
+  const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
+  if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
+  const next =
+    offset < 0
+      ? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
+      : fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
+  return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
+}
+
+export function allExpandedFileTreeDirectories(tree: FileTree) {
+  return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
+}
+
+export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<number>, selected: number | undefined) {
+  if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
+  const next = new Set(expanded)
+  if (next.has(selected)) next.delete(selected)
+  else next.add(selected)
+  return next
+}
+
+export function setFileTreeDirectoryExpanded(
+  tree: FileTree,
+  expanded: ReadonlySet<number>,
+  selected: number | undefined,
+  value: boolean,
+) {
+  if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
+  const next = new Set(expanded)
+  if (value) next.add(selected)
+  else next.delete(selected)
+  return next
+}
+
+function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
+  const id = nodes.length
+  nodes.push({ ...input, id, children: [] })
+  if (input.parent === undefined) roots.push(id)
+  else nodes[input.parent]!.children.push(id)
+  return id
+}

+ 103 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx

@@ -0,0 +1,103 @@
+/** @jsxImportSource @opentui/solid */
+import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
+import { createEffect, createMemo, For, Match, Switch } from "solid-js"
+import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
+
+export type DiffViewerFileTreeTheme = {
+  readonly background: ColorInput
+  readonly backgroundPanel: ColorInput
+  readonly backgroundElement: ColorInput
+  readonly primary: ColorInput
+  readonly selectedListItemText: ColorInput
+  readonly text: ColorInput
+  readonly textMuted: ColorInput
+  readonly error: ColorInput
+}
+
+export type DiffViewerFileTreeProps = {
+  readonly files: readonly FileTreeItem[]
+  readonly loading: boolean
+  readonly error: unknown
+  readonly theme: DiffViewerFileTreeTheme
+  readonly focused?: boolean
+  readonly highlightedNode?: number
+  readonly expandedNodes?: ReadonlySet<number>
+}
+
+export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
+  const tree = createMemo(() => buildFileTree(props.files))
+  const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
+  let scroll: ScrollBoxRenderable | undefined
+
+  createEffect(() => {
+    const node = props.highlightedNode
+    if (node === undefined) return
+    const selectedIndex = rows().findIndex((row) => row.id === node)
+    if (selectedIndex === -1) return
+    const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex)
+    scrollSelectedIntoView()
+    requestAnimationFrame(scrollSelectedIntoView)
+  })
+
+  return (
+    <box
+      width={32}
+      flexShrink={0}
+      backgroundColor={props.theme.backgroundPanel}
+      paddingLeft={1}
+      paddingRight={1}
+      paddingTop={1}
+      gap={1}
+      minHeight={0}
+    >
+      <scrollbox
+        ref={(element: ScrollBoxRenderable) => (scroll = element)}
+        flexGrow={1}
+        minHeight={0}
+        verticalScrollbarOptions={{ visible: false }}
+        horizontalScrollbarOptions={{ visible: false }}
+      >
+        <Switch>
+          <Match when={props.loading || props.error}>
+            <text />
+          </Match>
+          <Match when={props.files.length === 0}>
+            <text fg={props.theme.text}>No files</text>
+          </Match>
+          <Match when={props.files.length > 0}>
+            <For each={rows()}>
+              {(row) => {
+                const highlighted = () => props.focused && props.highlightedNode === row.id
+                return (
+                  <box flexDirection="row">
+                    <text fg={row.kind === "directory" ? props.theme.textMuted : props.theme.text} wrapMode="none">
+                      {`${"  ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : "  "}`}
+                    </text>
+                    <text
+                      fg={highlighted() ? props.theme.background : row.kind === "directory" ? props.theme.textMuted : props.theme.text}
+                      bg={highlighted() ? props.theme.primary : undefined}
+                      wrapMode="none"
+                    >
+                      {row.name}
+                    </text>
+                  </box>
+                )
+              }}
+            </For>
+          </Match>
+        </Switch>
+      </scrollbox>
+    </box>
+  )
+}
+
+function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
+  if (!scroll) return
+  if (index < scroll.scrollTop) {
+    scroll.scrollTo(index)
+    return
+  }
+  if (index >= scroll.scrollTop + scroll.viewport.height) {
+    scroll.scrollTo(index - scroll.viewport.height + 1)
+  }
+}

+ 686 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx

@@ -0,0 +1,686 @@
+/** @jsxImportSource @opentui/solid */
+import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
+import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import { useBindings, useCommandShortcut } from "@tui/keymap"
+import { useTheme } from "@tui/context/theme"
+import { useTerminalDimensions } from "@opentui/solid"
+import path from "path"
+import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
+import { DiffViewerFileTree } from "./diff-viewer-file-tree"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import {
+  allExpandedFileTreeDirectories,
+  buildFileTree,
+  flattenFileTree,
+  moveFileTreeSelection,
+  moveFileTreeSelectionToFile,
+  setFileTreeDirectoryExpanded,
+  toggleFileTreeDirectory,
+} from "./diff-viewer-file-tree-utils"
+
+const ROUTE = "diff"
+const MIN_SPLIT_WIDTH = 100
+type DiffMode = "git" | "last-turn"
+type DiffViewerFocus = "patches" | "files"
+
+type DiffFile = {
+  readonly file: string
+  readonly patch?: string
+  readonly additions: number
+  readonly deletions: number
+  readonly status: "added" | "deleted" | "modified"
+}
+
+const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] =>
+  diffs.flatMap((item) =>
+    item.file
+      ? [
+          {
+            file: item.file,
+            patch: item.patch,
+            additions: item.additions,
+            deletions: item.deletions,
+            status: item.status ?? "modified",
+          } satisfies DiffFile,
+        ]
+      : [],
+  )
+
+function filetype(input?: string) {
+  if (!input) return "none"
+  const language = LANGUAGE_EXTENSIONS[path.extname(input)]
+  if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
+  return language
+}
+
+function DiffViewer(props: { api: TuiPluginApi }) {
+  const dimensions = useTerminalDimensions()
+  const themeState = useTheme()
+  const theme = () => props.api.theme.current
+  const params = () =>
+    ("params" in props.api.route.current ? props.api.route.current.params : undefined) as
+      | { mode?: DiffMode; sessionID?: string; messageID?: string }
+      | undefined
+  const mode = () => params()?.mode ?? "git"
+  const diffInput = createMemo(() => ({
+    mode: mode(),
+    sessionID: params()?.sessionID,
+    messageID: params()?.messageID,
+  }))
+  const [diff] = createResource(diffInput, async (input) => {
+    if (input.mode === "last-turn") {
+      const sessionID = input.sessionID
+      if (!sessionID) return []
+      const result = await props.api.client.session.diff(
+        { sessionID, messageID: input.messageID },
+        { throwOnError: true },
+      )
+      return normalizeDiffs(result.data ?? [])
+    }
+
+    const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true })
+    return normalizeDiffs(result.data ?? [])
+  })
+  const files = createMemo(() => diff() ?? [])
+  const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
+  const [showFileTree, setShowFileTree] = createSignal(true)
+  const [singlePatch, setSinglePatch] = createSignal(false)
+  const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
+  const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
+  const defaultView = createMemo(() => {
+    if (props.api.tuiConfig.diff_style === "stacked") return "unified"
+    return splitAvailable() ? "split" : "unified"
+  })
+  const [viewOverride, setViewOverride] = createSignal<"split" | "unified">()
+  const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
+  const fileTree = createMemo(() => buildFileTree(files()))
+  const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
+  const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
+  const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
+  const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
+  const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
+  const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
+  const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
+  const nextFileShortcut = useCommandShortcut("diff.next_file")
+  const previousFileShortcut = useCommandShortcut("diff.previous_file")
+  const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree")
+  const singlePatchShortcut = useCommandShortcut("diff.single_patch")
+  const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
+  const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
+  let scroll: ScrollBoxRenderable | undefined
+  const patchNodeByFileIndex = new Map<number, BoxRenderable>()
+  const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
+
+  createEffect(() => {
+    setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
+    setHighlightedFileNode(undefined)
+    setLastHighlightedFileNode(undefined)
+    setActivePatchFileIndex(undefined)
+  })
+
+  const ensureHighlightedFileNode = () => {
+    const highlighted = highlightedFileNode()
+    if (highlighted !== undefined && fileRows().some((row) => row.id === highlighted)) return
+    const lastHighlighted = lastHighlightedFileNode()
+    const next =
+      lastHighlighted !== undefined && fileRows().some((row) => row.id === lastHighlighted)
+        ? lastHighlighted
+        : fileRows()[0]?.id
+    setHighlightedFileNode(next)
+  }
+
+  const setHighlighted = (node: number | undefined) => {
+    setHighlightedFileNode(node)
+    if (node !== undefined) setLastHighlightedFileNode(node)
+  }
+
+  const moveFileSelection = (offset: number) =>
+    setHighlighted(moveFileTreeSelection(fileRows(), highlightedFileNode(), offset))
+
+  const clearFileTreePatchState = () => {
+    setHighlightedFileNode(undefined)
+    setActivePatchFileIndex(undefined)
+  }
+
+  const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
+    if (!scroll) return
+    scroll.scrollBy(patchNode.y - scroll.viewport.y)
+    requestAnimationFrame(() => {
+      if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
+    })
+  }
+
+  const revealFileTreeFile = (fileIndex: number) => {
+    const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
+    if (!node) return
+    setExpandedFileNodes((expanded) => {
+      const next = new Set(expanded)
+      for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) {
+        next.add(parent)
+      }
+      return next
+    })
+    setHighlighted(node.id)
+  }
+
+  const scrollToFileIndex = (fileIndex: number | undefined) => {
+    if (fileIndex === undefined) return
+    setActivePatchFileIndex(fileIndex)
+    const patchNode = patchNodeByFileIndex.get(fileIndex)
+    if (patchNode) scrollPatchNodeToTop(patchNode)
+  }
+
+  const jumpToFileIndex = (fileIndex: number | undefined) => {
+    if (fileIndex === undefined) return
+    revealFileTreeFile(fileIndex)
+    scrollToFileIndex(fileIndex)
+  }
+
+  const currentPatchFileIndex = () => {
+    if (!scroll) return undefined
+    const entries = files()
+      .map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
+      .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
+      .sort((left, right) => left.node.y - right.node.y)
+    return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex
+  }
+
+  const jumpRelativePatchFile = (offset: number) => {
+    const current = focus() === "files" ? highlightedFileNode() : undefined
+    const nextFromSelection =
+      current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset)
+    if (nextFromSelection !== undefined) {
+      jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex)
+      return
+    }
+    const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex()
+    const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex)
+    scrollToFileIndex(
+      fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex,
+    )
+  }
+
+  const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
+  const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
+  const visiblePatchFiles = createMemo(() => {
+    if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex }))
+    const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex()
+    const file = fileIndex === undefined ? undefined : files()[fileIndex]
+    return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
+  })
+
+  const ensureHighlightedPatchFile = () => {
+    if (activePatchFileIndex() !== undefined) return
+    const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex()
+    if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex)
+  }
+
+  const scrollToHighlightedPatchFile = () => {
+    const fileIndex = activePatchFileIndex()
+    if (fileIndex === undefined) return
+    setPendingPatchScrollFileIndex(fileIndex)
+  }
+
+  const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
+    patchNodeByFileIndex.set(fileIndex, element)
+    if (pendingPatchScrollFileIndex() !== fileIndex) return
+    requestAnimationFrame(() => {
+      scrollPatchNodeToTop(element)
+      requestAnimationFrame(() => {
+        scrollPatchNodeToTop(element)
+        setPendingPatchScrollFileIndex(undefined)
+      })
+    })
+  }
+
+  const toggleSelectedFileTreeRow = () => {
+    const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
+    if (highlighted?.fileIndex !== undefined) {
+      jumpToFileIndex(highlighted.fileIndex)
+      return
+    }
+    setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
+  }
+
+  const commands = [
+    {
+      name: "diff.close",
+      title: "Close diff viewer",
+      category: "VCS",
+      run() {
+        props.api.route.navigate("home")
+      },
+    },
+    {
+      name: "diff.down",
+      title: "Move diff viewer down",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          moveFileSelection(1)
+        },
+        patches() {
+          clearFileTreePatchState()
+          scroll?.scrollBy(1)
+        },
+      }),
+    },
+    {
+      name: "diff.up",
+      title: "Move diff viewer up",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          moveFileSelection(-1)
+        },
+        patches() {
+          clearFileTreePatchState()
+          scroll?.scrollBy(-1)
+        },
+      }),
+    },
+    {
+      name: "diff.page.down",
+      title: "Page diff viewer down",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          moveFileSelection(8)
+        },
+        patches() {
+          clearFileTreePatchState()
+          if (scroll) scroll.scrollBy(scroll.height)
+        },
+      }),
+    },
+    {
+      name: "diff.page.up",
+      title: "Page diff viewer up",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          moveFileSelection(-8)
+        },
+        patches() {
+          clearFileTreePatchState()
+          if (scroll) scroll.scrollBy(-scroll.height)
+        },
+      }),
+    },
+    {
+      name: "diff.toggle",
+      title: "Toggle diff viewer item",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          toggleSelectedFileTreeRow()
+        },
+        patches() {},
+      }),
+    },
+    {
+      name: "diff.expand",
+      title: "Expand diff viewer item",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          setExpandedFileNodes((expanded) =>
+            setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
+          )
+        },
+        patches() {},
+      }),
+    },
+    {
+      name: "diff.collapse",
+      title: "Collapse diff viewer item",
+      category: "VCS",
+      run: focusRunner({
+        files() {
+          setExpandedFileNodes((expanded) =>
+            setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
+          )
+        },
+        patches() {},
+      }),
+    },
+    {
+      name: "diff.next_file",
+      title: "Jump to next diff file",
+      category: "VCS",
+      run() {
+        jumpRelativePatchFile(1)
+      },
+    },
+    {
+      name: "diff.previous_file",
+      title: "Jump to previous diff file",
+      category: "VCS",
+      run() {
+        jumpRelativePatchFile(-1)
+      },
+    },
+    {
+      name: "diff.switch_focus",
+      title: "Switch diff viewer focus",
+      category: "VCS",
+      run() {
+        if (!showFileTree()) return
+        setFocus((current) => {
+          if (current === "files") return "patches"
+          ensureHighlightedFileNode()
+          return "files"
+        })
+      },
+    },
+    {
+      name: "diff.toggle_file_tree",
+      title: "Toggle diff viewer file tree",
+      category: "VCS",
+      run() {
+        setShowFileTree((value) => {
+          if (value) setFocus("patches")
+          return !value
+        })
+      },
+    },
+    {
+      name: "diff.single_patch",
+      title: "Toggle single patch view",
+      category: "VCS",
+      run() {
+        setSinglePatch((value) => {
+          const next = !value
+          if (next) ensureHighlightedPatchFile()
+          else scrollToHighlightedPatchFile()
+          return next
+        })
+      },
+    },
+    {
+      name: "diff.switch_diff",
+      title: "Switch diff viewer source",
+      category: "VCS",
+      run() {
+        openSwitchDiffDialog()
+      },
+    },
+    {
+      name: "diff.toggle_view",
+      title: "Toggle diff viewer split or unified view",
+      category: "VCS",
+      run() {
+        if (!splitAvailable()) return
+        setViewOverride(view() === "split" ? "unified" : "split")
+      },
+    },
+  ]
+
+  const switchDiffOptions = createMemo(() => [
+    {
+      title: "Working tree",
+      value: "git" as const,
+      description: "Show current git changes",
+    },
+    {
+      title: "Last turn",
+      value: "last-turn" as const,
+      description: "Show changes from the last assistant turn",
+    },
+  ])
+
+  const openSwitchDiffDialog = () => {
+    props.api.ui.dialog.replace(() => (
+      <DialogSelect
+        title="Switch diff"
+        skipFilter={true}
+        renderFilter={false}
+        current={mode()}
+        options={switchDiffOptions().map((option) => ({
+          ...option,
+          onSelect(dialog) {
+            dialog.clear()
+            props.api.route.navigate(ROUTE, {
+              mode: option.value,
+              sessionID: params()?.sessionID,
+              messageID: params()?.messageID,
+            })
+          },
+        }))}
+      />
+    ))
+  }
+
+  useBindings(() => ({
+    commands,
+    bindings: [
+      { key: "j,down", cmd: "diff.down", desc: "Move diff viewer down" },
+      { key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
+      { key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
+      { key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
+      ...props.api.tuiConfig.keybinds.gather(
+        "diff",
+        commands.map((command) => command.name),
+      ),
+    ],
+  }))
+
+  return (
+    <box
+      position="absolute"
+      zIndex={2500}
+      left={0}
+      top={0}
+      width={dimensions().width}
+      height={dimensions().height}
+      backgroundColor={theme().background}
+      paddingLeft={1}
+      paddingRight={1}
+      paddingTop={1}
+      paddingBottom={1}
+      gap={1}
+    >
+      <box flexDirection="row" justifyContent="space-between" flexShrink={0}>
+        <box flexDirection="row" gap={1}>
+          <text fg={theme().text}>Diff</text>
+          <text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
+        </box>
+      </box>
+
+      <Switch>
+        <Match when={diff.loading}>
+          <box flexGrow={1} alignItems="center" justifyContent="center">
+            <text fg={theme().textMuted}>Loading diff...</text>
+          </box>
+        </Match>
+        <Match when={!diff.loading}>
+          <box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
+            <Show when={showFileTree()}>
+              <DiffViewerFileTree
+                files={files()}
+                loading={diff.loading}
+                error={diff.error}
+                theme={theme()}
+                focused={focus() === "files"}
+                highlightedNode={highlightedFileNode()}
+                expandedNodes={expandedFileNodes()}
+              />
+            </Show>
+
+            <box
+              flexGrow={1}
+              minWidth={0}
+              backgroundColor={theme().background}
+              paddingLeft={0}
+              paddingRight={2}
+              gap={1}
+            >
+              <Switch>
+                <Match when={diff.error}>
+                  <box paddingTop={1}>
+                    <text fg={theme().error}>Failed to load diff</text>
+                  </box>
+                </Match>
+                <Match when={files().length === 0}>
+                  <box paddingTop={1}>
+                    <text fg={theme().textMuted}>No diff to show</text>
+                  </box>
+                </Match>
+                <Match when={files().length > 0}>
+                  <scrollbox
+                    ref={(element: ScrollBoxRenderable) => (scroll = element)}
+                    flexGrow={1}
+                    minHeight={0}
+                    verticalScrollbarOptions={{ visible: false }}
+                    horizontalScrollbarOptions={{ visible: false }}
+                  >
+                    <For each={visiblePatchFiles()}>
+                      {(entry) => (
+                        <box
+                          ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
+                          marginBottom={1}
+                          backgroundColor={theme().backgroundPanel}
+                        >
+                          <box
+                            flexDirection="row"
+                            gap={2}
+                            flexShrink={0}
+                            paddingTop={1}
+                            paddingBottom={1}
+                            paddingLeft={2}
+                            paddingRight={1}
+                            backgroundColor={theme().backgroundPanel}
+                          >
+                            <text fg={theme().text}>{entry.file.file}</text>
+                            <text fg={theme().diffAdded}>+{entry.file.additions}</text>
+                            <text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
+                          </box>
+                          <Show
+                            when={entry.file.patch}
+                            fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
+                          >
+                            {(patch) => (
+                              <diff
+                                diff={patch()}
+                                view={view()}
+                                filetype={filetype(entry.file.file)}
+                                syntaxStyle={themeState.syntax()}
+                                showLineNumbers={true}
+                                width="100%"
+                                wrapMode="word"
+                                fg={theme().text}
+                                addedBg={theme().diffAddedBg}
+                                removedBg={theme().diffRemovedBg}
+                                contextBg={theme().diffContextBg}
+                                addedSignColor={theme().diffHighlightAdded}
+                                removedSignColor={theme().diffHighlightRemoved}
+                                lineNumberFg={theme().diffLineNumber}
+                                lineNumberBg={theme().diffContextBg}
+                                addedLineNumberBg={theme().diffAddedLineNumberBg}
+                                removedLineNumberBg={theme().diffRemovedLineNumberBg}
+                              />
+                            )}
+                          </Show>
+                        </box>
+                      )}
+                    </For>
+                  </scrollbox>
+                </Match>
+              </Switch>
+            </box>
+          </box>
+        </Match>
+      </Switch>
+
+      <box flexDirection="row" gap={2} flexShrink={0}>
+        <Show when={switchFocusShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
+            </text>
+          )}
+        </Show>
+        <Show when={nextFileShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
+            </text>
+          )}
+        </Show>
+        <Show when={previousFileShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
+            </text>
+          )}
+        </Show>
+        <Show when={toggleFileTreeShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()}{" "}
+              <span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
+            </text>
+          )}
+        </Show>
+        <Show when={singlePatchShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()}{" "}
+              <span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
+            </text>
+          )}
+        </Show>
+        <Show when={switchDiffShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
+            </text>
+          )}
+        </Show>
+        <Show when={toggleViewShortcut()}>
+          {(shortcut) => (
+            <text fg={theme().text}>
+              {shortcut()}{" "}
+              <span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
+            </text>
+          )}
+        </Show>
+      </box>
+    </box>
+  )
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.route.register([
+    {
+      name: ROUTE,
+      render: () => <DiffViewer api={api} />,
+    },
+  ])
+
+  api.keymap.registerLayer({
+    commands: [
+      {
+        name: "diff.open",
+        title: "Open diff viewer",
+        slashName: "diff",
+        category: "VCS",
+        namespace: "palette",
+        run() {
+          api.route.navigate(ROUTE, {
+            mode: "git",
+            sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
+          })
+          api.ui.dialog.clear()
+        },
+      },
+    ],
+  })
+}
+
+export default {
+  id: "diff-viewer",
+  tui,
+}

+ 3 - 1
packages/opencode/src/cli/cmd/tui/plugin/internal.ts

@@ -10,6 +10,7 @@ import PluginManager from "../feature-plugins/system/plugins"
 import Notifications from "../feature-plugins/system/notifications"
 import SessionV2Debug from "../feature-plugins/system/session-v2"
 import WhichKey from "../feature-plugins/system/which-key"
+import DiffViewer from "../feature-plugins/system/diff-viewer"
 import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import type { RuntimeFlags } from "@/effect/runtime-flags"
 
@@ -19,7 +20,7 @@ export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
   enabled?: boolean
 }
 
-export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
+export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">): InternalTuiPlugin[] {
   return [
     HomeFooter,
     HomeTips,
@@ -32,6 +33,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
     Notifications,
     PluginManager,
     WhichKey,
+    ...(flags.diffViewer ? [DiffViewer] : []),
     ...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
   ]
 }

+ 1 - 0
packages/opencode/src/effect/runtime-flags.ts

@@ -15,6 +15,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
   autoShare: bool("OPENCODE_AUTO_SHARE"),
   pure: bool("OPENCODE_PURE"),
   disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
+  diffViewer: bool("OPENCODE_DIFF_VIEWER"),
   disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
   disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
   disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),

+ 185 - 0
packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts

@@ -0,0 +1,185 @@
+import { describe, expect, test } from "bun:test"
+import {
+  allExpandedFileTreeDirectories,
+  buildFileTree,
+  flattenFileTree,
+  moveFileTreeSelection,
+  moveFileTreeSelectionToFile,
+  setFileTreeDirectoryExpanded,
+  toggleFileTreeDirectory,
+} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
+
+describe("diff viewer file tree utilities", () => {
+  test("builds a nested tree with deduplicated directories and file indexes", () => {
+    const tree = buildFileTree([
+      { file: "src/config/tui.ts" },
+      { file: "src/config/keybind.ts" },
+      { file: "src/session/index.ts" },
+    ])
+
+    expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "src")).toHaveLength(1)
+    expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "config")).toHaveLength(1)
+    expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "session")).toHaveLength(1)
+    expect(
+      tree.nodes
+        .filter((node) => node.kind === "file")
+        .map((node) => ({ name: node.name, fileIndex: node.fileIndex, depth: node.depth })),
+    ).toEqual([
+      { name: "tui.ts", fileIndex: 0, depth: 2 },
+      { name: "keybind.ts", fileIndex: 1, depth: 2 },
+      { name: "index.ts", fileIndex: 2, depth: 2 },
+    ])
+  })
+
+  test("sorts directories before files and alphabetically within each group", () => {
+    const rows = flattenFileTree(
+      buildFileTree([
+        { file: "z-file.ts" },
+        { file: "b/file.ts" },
+        { file: "a/zeta.ts" },
+        { file: "b/alpha.ts" },
+        { file: "a/alpha.ts" },
+      ]),
+    )
+
+    expect(rows.map((row) => `${"  ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
+      "directory:a",
+      "  file:alpha.ts",
+      "  file:zeta.ts",
+      "directory:b",
+      "  file:alpha.ts",
+      "  file:file.ts",
+      "file:z-file.ts",
+    ])
+  })
+
+  test("sorts root-level files without creating directories", () => {
+    const tree = buildFileTree([{ file: "zeta.ts" }, { file: "alpha.ts" }, { file: "beta.ts" }])
+
+    expect(tree.nodes.every((node) => node.kind === "file")).toBe(true)
+    expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
+  })
+
+  test("keeps same directory names under different parents separate", () => {
+    const rows = flattenFileTree(
+      buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
+    )
+
+    expect(rows.map((row) => `${"  ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
+      "directory:components",
+      "  file:button.ts",
+      "directory:docs",
+      "  directory:components",
+      "    file:usage.md",
+    ])
+  })
+
+  test("flattens all-expanded rows depth-first with depths and file references", () => {
+    const rows = flattenFileTree(
+      buildFileTree([
+        { file: "src/config/tui.ts" },
+        { file: "src/config/keybind.ts" },
+        { file: "README.md" },
+      ]),
+    )
+
+    expect(rows.map((row) => ({ name: row.name, kind: row.kind, depth: row.depth, fileIndex: row.fileIndex }))).toEqual([
+      { name: "src", kind: "directory", depth: 0, fileIndex: undefined },
+      { name: "config", kind: "directory", depth: 1, fileIndex: undefined },
+      { name: "keybind.ts", kind: "file", depth: 2, fileIndex: 1 },
+      { name: "tui.ts", kind: "file", depth: 2, fileIndex: 0 },
+      { name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
+    ])
+  })
+
+  test("flattens only expanded directory descendants when expansion is provided", () => {
+    const tree = buildFileTree([
+      { file: "src/config/tui.ts" },
+      { file: "src/session/index.ts" },
+      { file: "README.md" },
+    ])
+    const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
+    const config = tree.nodes.find((node) => node.kind === "directory" && node.name === "config")!
+
+    expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["src", "README.md"])
+    expect(flattenFileTree(tree, new Set([src.id])).map((row) => row.name)).toEqual([
+      "src",
+      "config",
+      "session",
+      "README.md",
+    ])
+    expect(flattenFileTree(tree, new Set([src.id, config.id])).map((row) => row.name)).toEqual([
+      "src",
+      "config",
+      "tui.ts",
+      "session",
+      "README.md",
+    ])
+  })
+
+  test("moves selection across visible rows and clamps to bounds", () => {
+    const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
+
+    expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
+    expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
+    expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
+    expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
+    expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
+  })
+
+  test("moves file selection relative to the highlighted row", () => {
+    const rows = flattenFileTree(
+      buildFileTree([
+        { file: "src/config/tui.ts" },
+        { file: "src/session/index.ts" },
+        { file: "README.md" },
+      ]),
+    )
+    const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
+    const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
+    const tui = rows.find((row) => row.name === "tui.ts")!
+    const index = rows.find((row) => row.name === "index.ts")!
+    const readme = rows.find((row) => row.name === "README.md")!
+
+    expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
+    expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
+    expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
+    expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
+    expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
+    expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
+    expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
+  })
+
+  test("toggles only selected directory expansion", () => {
+    const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
+    const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
+    const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
+    const expanded = allExpandedFileTreeDirectories(tree)
+
+    const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
+    expect(collapsed.has(src.id)).toBe(false)
+    expect(flattenFileTree(tree, collapsed).map((row) => row.name)).toEqual(["src", "README.md"])
+
+    const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
+    expect(reopened.has(src.id)).toBe(true)
+
+    expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
+    expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
+  })
+
+  test("sets only selected directory expansion", () => {
+    const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
+    const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
+    const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
+    const expanded = allExpandedFileTreeDirectories(tree)
+
+    const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
+    expect(collapsed.has(src.id)).toBe(false)
+
+    const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
+    expect(reopened.has(src.id)).toBe(true)
+
+    expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
+    expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
+  })
+})

+ 143 - 0
packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -0,0 +1,143 @@
+/** @jsxImportSource @opentui/solid */
+import { describe, expect, test } from "bun:test"
+import { RGBA } from "@opentui/core"
+import { testRender } from "@opentui/solid"
+import type { JSX } from "solid-js"
+import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
+import {
+  allExpandedFileTreeDirectories,
+  buildFileTree,
+} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
+
+const theme = {
+  background: RGBA.fromHex("#000000"),
+  backgroundPanel: RGBA.fromHex("#111111"),
+  backgroundElement: RGBA.fromHex("#333333"),
+  primary: RGBA.fromHex("#00ffff"),
+  selectedListItemText: RGBA.fromHex("#ffffff"),
+  text: RGBA.fromHex("#ffffff"),
+  textMuted: RGBA.fromHex("#888888"),
+  error: RGBA.fromHex("#ff0000"),
+}
+
+describe("DiffViewerFileTree", () => {
+  test("renders sorted hierarchical file rows", async () => {
+    const app = await testRender(
+      () => (
+        <DiffViewerFileTree
+          files={[
+            { file: "z-file.ts" },
+            { file: "b/file.ts" },
+            { file: "a/zeta.ts" },
+            { file: "b/alpha.ts" },
+            { file: "a/alpha.ts" },
+          ]}
+          loading={false}
+          error={undefined}
+          theme={theme}
+          focused={true}
+        />
+      ),
+      { width: 40, height: 20 },
+    )
+
+    try {
+      await app.renderOnce()
+      const lines = visibleLines(app.captureCharFrame())
+
+      expect(lines).toEqual([
+        "▾ a",
+        "    alpha.ts",
+        "    zeta.ts",
+        "▾ b",
+        "    alpha.ts",
+        "    file.ts",
+        "  z-file.ts",
+      ])
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+
+  test("keeps loading and error quiet while rendering an empty settled state", async () => {
+    const loading = await renderFrame(() => <DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />)
+    const failed = await renderFrame(() => (
+      <DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} />
+    ))
+    const empty = await renderFrame(() => <DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />)
+
+    expect(loading).not.toContain("Loading diff...")
+    expect(loading).not.toContain("No files")
+    expect(failed).not.toContain("Failed to load diff")
+    expect(failed).not.toContain("No files")
+    expect(empty).toContain("No files")
+  })
+
+  test("does not render text markers for highlighted rows", async () => {
+    const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
+    const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
+
+    const focused = visibleLines(
+      await renderFrame(() => (
+        <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} focused highlightedNode={src.id} />
+      )),
+    )
+    const unfocused = visibleLines(
+      await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
+    )
+
+    expect(focused).toContain("▾ src")
+    expect(unfocused).toContain("▾ src")
+    expect(focused.some((line) => line.includes("*"))).toBe(false)
+    expect(unfocused.some((line) => line.includes("*"))).toBe(false)
+  })
+
+  test("renders collapsed and expanded directory rows", async () => {
+    const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
+    const tree = buildFileTree(files)
+    const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
+    const collapsed = allExpandedFileTreeDirectories(tree)
+    collapsed.delete(src.id)
+
+    expect(
+      visibleLines(
+        await renderFrame(() => (
+          <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
+        )),
+      ),
+    ).toEqual(["▸ src", "  README.md"])
+
+    expect(
+      visibleLines(
+        await renderFrame(() => (
+          <DiffViewerFileTree
+            files={files}
+            loading={false}
+            error={undefined}
+            theme={theme}
+            expandedNodes={allExpandedFileTreeDirectories(tree)}
+          />
+        )),
+      ),
+    ).toEqual(["▾ src", "  ▾ config", "      tui.ts", "  README.md"])
+  })
+})
+
+async function renderFrame(component: () => JSX.Element) {
+  const app = await testRender(component, { width: 40, height: 10 })
+  try {
+    await app.renderOnce()
+    return app.captureCharFrame()
+  } finally {
+    app.renderer.destroy()
+  }
+}
+
+function visibleLines(frame: string) {
+  return frame
+    .split("\n")
+    .map((line) => line.trimEnd())
+    .map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
+    .map((line) => (line.startsWith(" ") ? line.slice(1) : line))
+    .filter((line) => line.length > 0 && !/^┌|^└/.test(line))
+}