Просмотр исходного кода

feat(tui): design revamp of diff viewer (#28728)

James Long 3 месяцев назад
Родитель
Сommit
ee008923f3

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

@@ -5,6 +5,7 @@
 
 export type FileTreeItem = {
   readonly file: string
+  readonly status?: "added" | "deleted" | "modified"
 }
 
 export type FileTreeNode = {
@@ -125,6 +126,21 @@ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: nu
   return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
 }
 
+export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
+  const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
+  const row = index === -1 ? undefined : rows[index]
+  if (row?.kind !== "directory") return selected
+  const child = rows[index + 1]
+  return child && child.depth > row.depth ? child.id : selected
+}
+
+export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
+  const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
+  const row = index === -1 ? undefined : rows[index]
+  if (!row || row.depth === 0) return selected
+  return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
+}
+
 export function moveFileTreeSelectionToFile(
   rows: readonly FileTreeRow[],
   selected: number | undefined,

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

@@ -1,30 +1,36 @@
 /** @jsxImportSource @opentui/solid */
-import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
+import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
 import { Locale } from "@/util/locale"
+import { tint } from "@tui/context/theme"
 import { createEffect, createMemo, For, Match, Switch } from "solid-js"
-import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
+import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
+import { Panel } from "./diff-viewer-ui"
 
-const FILE_TREE_WIDTH = 32
 const FILE_TREE_HORIZONTAL_PADDING = 2
+const FILE_TREE_STATUS_WIDTH = 2
 
 export type DiffViewerFileTreeTheme = {
-  readonly background: ColorInput
+  readonly background: RGBA
   readonly backgroundPanel: ColorInput
   readonly backgroundElement: ColorInput
   readonly primary: ColorInput
+  readonly secondary: ColorInput
   readonly selectedListItemText: ColorInput
-  readonly text: ColorInput
-  readonly textMuted: ColorInput
+  readonly text: RGBA
+  readonly textMuted: RGBA
   readonly error: ColorInput
 }
 
 export type DiffViewerFileTreeProps = {
+  readonly width: number
   readonly files: readonly FileTreeItem[]
   readonly loading: boolean
   readonly error: unknown
   readonly theme: DiffViewerFileTreeTheme
   readonly focused?: boolean
   readonly highlightedNode?: number
+  readonly selectedFileIndex?: number
+  readonly reviewedFileNames?: ReadonlySet<string>
   readonly expandedNodes?: ReadonlySet<number>
 }
 
@@ -43,21 +49,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
     requestAnimationFrame(scrollSelectedIntoView)
   })
 
+  const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
+
   return (
-    <box
-      width={FILE_TREE_WIDTH}
-      flexShrink={0}
-      backgroundColor={props.theme.backgroundPanel}
-      paddingLeft={1}
-      paddingRight={1}
-      paddingTop={1}
-      gap={1}
-      minHeight={0}
-    >
+    <Panel border="both" width={props.width}>
       <scrollbox
         ref={(element: ScrollBoxRenderable) => (scroll = element)}
-        flexGrow={1}
-        minHeight={0}
         verticalScrollbarOptions={{ visible: false }}
         horizontalScrollbarOptions={{ visible: false }}
       >
@@ -70,26 +67,24 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
           </Match>
           <Match when={props.files.length > 0}>
             <For each={rows()}>
-              {(row) => {
+              {(row, index) => {
                 const highlighted = () => props.focused && props.highlightedNode === row.id
-                const prefix = () =>
-                  `${"  ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : "  "}`
+                const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
+                const reviewed = () => {
+                  const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
+                  return file !== undefined && props.reviewedFileNames?.has(file)
+                }
+                const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
+                const status = () => fileTreeRowStatus(row, props.files)
                 const name = () =>
                   Locale.truncate(
                     row.name,
-                    Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length),
+                    Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length),
                   )
                 return (
-                  <box flexDirection="row" width="100%">
+                  <box flexDirection="row" width="100%" backgroundColor={highlighted() ? props.theme.primary : undefined}>
                     <text
-                      fg={
-                        highlighted()
-                          ? props.theme.background
-                          : row.kind === "directory"
-                            ? props.theme.textMuted
-                            : props.theme.text
-                      }
-                      bg={highlighted() ? props.theme.primary : undefined}
+                      fg={highlighted() ? props.theme.background : fadedColor()}
                       wrapMode="none"
                       flexShrink={0}
                     >
@@ -100,16 +95,22 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
                         fg={
                           highlighted()
                             ? props.theme.background
-                            : row.kind === "directory"
+                            : reviewed()
                               ? props.theme.textMuted
-                              : props.theme.text
+                              : selected()
+                                ? props.theme.primary
+                                : row.kind === "directory"
+                                  ? tint(props.theme.text, props.theme.background, 0.35)
+                                  : props.theme.text
                         }
-                        bg={highlighted() ? props.theme.primary : undefined}
                         wrapMode="none"
                       >
                         {name()}
                       </text>
                     </box>
+                    <text fg={highlighted() ? props.theme.background : props.theme.textMuted} wrapMode="none" flexShrink={0}>
+                      {status()}
+                    </text>
                   </box>
                 )
               }}
@@ -117,7 +118,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
           </Match>
         </Switch>
       </scrollbox>
-    </box>
+    </Panel>
   )
 }
 
@@ -131,3 +132,33 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde
     scroll.scrollTo(index - scroll.viewport.height + 1)
   }
 }
+
+function fileTreeRowPrefix(
+  rows: readonly FileTreeRow[],
+  index: number,
+  row: FileTreeRow,
+  expandedNodes: ReadonlySet<number> | undefined,
+) {
+  const indentation = Array.from({ length: row.depth }, (_, depth) => {
+    if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
+    return hasLaterSibling(rows, index, depth) ? "│  " : "   "
+  }).join("")
+  const topRoot = index === 0 && row.depth === 0
+  const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
+  const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
+
+  return `${indentation}${branch}${marker}`
+}
+
+function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
+  return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
+}
+
+function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) {
+  if (row.fileIndex === undefined) return ""
+  const status = files[row.fileIndex]?.status
+  if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH)
+  if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH)
+  if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH)
+  return "?".padStart(FILE_TREE_STATUS_WIDTH)
+}

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

@@ -0,0 +1,94 @@
+import type { BorderSides, ColorInput } from "@opentui/core"
+import type { JSX } from "@opentui/solid"
+import { useTheme } from "@tui/context/theme"
+import { createContext, splitProps, useContext } from "solid-js"
+
+export type Axis = "x" | "y"
+export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
+export type PanelBorder = "start" | "end" | "both" | "none"
+
+const PanelGroupContext = createContext<{ axis: Axis }>()
+
+function crossAxis(axis: Axis) {
+  return axis === "x" ? "y" : "x"
+}
+
+function usePanelGroup() {
+  return useContext(PanelGroupContext)
+}
+
+export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
+  const [local, boxProps] = splitProps(props, ["axis", "children"])
+  return (
+    <PanelGroupContext.Provider value={{ axis: local.axis }}>
+      <box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
+        {local.children}
+      </box>
+    </PanelGroupContext.Provider>
+  )
+}
+
+export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
+  const group = usePanelGroup()
+  const { theme } = useTheme()
+  const [local, boxProps] = splitProps(props, ["border"])
+  const border = local.border ?? "start"
+  const borderProps = border === "none"
+    ? {}
+    : {
+        border: panelBorderSides(group?.axis ?? "y", border),
+        borderColor: theme.border,
+      }
+
+  return (
+    <box
+      minWidth={0}
+      minHeight={0}
+      flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
+      {...borderProps}
+      {...boxProps}
+    />
+  )
+}
+
+function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
+  if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
+  return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
+}
+
+export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
+  const group = usePanelGroup()
+  const { theme } = useTheme()
+  const color = () => props.color ?? theme.border
+  const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
+  if (axis() === "y") {
+    if (!props.start && !props.end) return <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
+    return (
+      <box width={1} flexShrink={0} flexDirection="column">
+        {props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
+        <box flexGrow={1} border={["left"]} borderColor={color()} />
+        {props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
+      </box>
+    )
+  }
+  if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
+  return (
+    <box height={1} flexShrink={0} flexDirection="row">
+      {props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
+      <box flexGrow={1} border={["top"]} borderColor={color()} />
+      {props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
+    </box>
+  )
+}
+
+function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
+  if (edge === "edge") return side === "start" ? "├" : "┤"
+  if (edge === "edge-in") return "┴"
+  return "┬"
+}
+
+function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
+  if (edge === "edge") return side === "start" ? "┬" : "┴"
+  if (edge === "edge-in") return "┤"
+  return "├"
+}

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

@@ -9,19 +9,24 @@ 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 { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
 import { DialogSelect } from "@tui/ui/dialog-select"
 import {
   allExpandedFileTreeDirectories,
   buildFileTree,
   flattenFileTree,
   moveFileTreeSelection,
+  moveFileTreeSelectionToFirstChild,
   moveFileTreeSelectionToFile,
+  moveFileTreeSelectionToParent,
   setFileTreeDirectoryExpanded,
   toggleFileTreeDirectory,
 } from "./diff-viewer-file-tree-utils"
 
 const ROUTE = "diff"
 const MIN_SPLIT_WIDTH = 100
+const FILE_TREE_WIDTH = 32
+const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
 type DiffMode = "git" | "last-turn"
 type DiffViewerFocus = "patches" | "files"
 
@@ -100,6 +105,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
   const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
   const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
+  const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
+  const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
   const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
   const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
   const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
@@ -109,6 +116,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   const singlePatchShortcut = useCommandShortcut("diff.single_patch")
   const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
   const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
+  const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
   let scroll: ScrollBoxRenderable | undefined
   const patchNodeByFileIndex = new Map<number, BoxRenderable>()
   const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
@@ -118,6 +126,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
     setHighlightedFileNode(undefined)
     setLastHighlightedFileNode(undefined)
     setActivePatchFileIndex(undefined)
+    setSelectedFileIndex(undefined)
+    setReviewedFileNames(new Set<string>())
   })
 
   const ensureHighlightedFileNode = () => {
@@ -144,11 +154,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
     setActivePatchFileIndex(undefined)
   }
 
-  const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
+  const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
     if (!scroll) return
-    scroll.scrollBy(patchNode.y - scroll.viewport.y)
+    const offset = fileIndex === 0 ? 0 : 1
+    scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
     requestAnimationFrame(() => {
-      if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
+      if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
     })
   }
 
@@ -168,8 +179,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   const scrollToFileIndex = (fileIndex: number | undefined) => {
     if (fileIndex === undefined) return
     setActivePatchFileIndex(fileIndex)
+    setSelectedFileIndex(fileIndex)
     const patchNode = patchNodeByFileIndex.get(fileIndex)
-    if (patchNode) scrollPatchNodeToTop(patchNode)
+    if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
   }
 
   const jumpToFileIndex = (fileIndex: number | undefined) => {
@@ -227,9 +239,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
     patchNodeByFileIndex.set(fileIndex, element)
     if (pendingPatchScrollFileIndex() !== fileIndex) return
     requestAnimationFrame(() => {
-      scrollPatchNodeToTop(element)
+      scrollPatchNodeToTop(element, fileIndex)
       requestAnimationFrame(() => {
-        scrollPatchNodeToTop(element)
+        scrollPatchNodeToTop(element, fileIndex)
         setPendingPatchScrollFileIndex(undefined)
       })
     })
@@ -244,6 +256,21 @@ function DiffViewer(props: { api: TuiPluginApi }) {
     setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
   }
 
+  const toggleSelectedFileReviewed = () => {
+    const fileIndex =
+      focus() === "files"
+        ? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
+        : (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
+    const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
+    if (!file) return
+    setReviewedFileNames((reviewed) => {
+      const next = new Set(reviewed)
+      if (next.has(file)) next.delete(file)
+      else next.add(file)
+      return next
+    })
+  }
+
   const commands = [
     {
       name: "diff.close",
@@ -326,6 +353,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
       category: "VCS",
       run: focusRunner({
         files() {
+          const highlighted = highlightedFileNode()
+          if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
+            setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
+            return
+          }
           setExpandedFileNodes((expanded) =>
             setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
           )
@@ -339,6 +371,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
       category: "VCS",
       run: focusRunner({
         files() {
+          const highlighted = highlightedFileNode()
+          const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
+          if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
+            setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
+            return
+          }
           setExpandedFileNodes((expanded) =>
             setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
           )
@@ -362,6 +400,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
         jumpRelativePatchFile(-1)
       },
     },
+    {
+      name: "diff.mark_reviewed",
+      title: "Toggle selected diff file reviewed",
+      category: "VCS",
+      run() {
+        toggleSelectedFileReviewed()
+      },
+    },
     {
       name: "diff.switch_focus",
       title: "Switch diff viewer focus",
@@ -460,6 +506,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
       { 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" },
+      { key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
       ...props.api.tuiConfig.keybinds.gather(
         "diff",
         commands.map((command) => command.name),
@@ -468,186 +515,193 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   }))
 
   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>
+    <box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
+      <PanelGroup axis="y" width="100%" height="100%">
+        <Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
+          <text fg={theme().text}>Diff </text>
           <text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
+          <box flexGrow={1} />
+          <text fg={theme().textMuted}>
+            {files().length} {files().length === 1 ? "file" : "files"}
+          </text>
+        </Panel>
+
+        <box flexGrow={1} minHeight={0}>
+          <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}>
+              <PanelGroup axis="x">
+                <Show when={showFileTree()}>
+                  <DiffViewerFileTree
+                    files={files()}
+                    loading={diff.loading}
+                    error={diff.error}
+                    theme={theme()}
+                    focused={focus() === "files"}
+                    width={FILE_TREE_WIDTH}
+                    highlightedNode={highlightedFileNode()}
+                    selectedFileIndex={selectedFileIndex()}
+                    reviewedFileNames={reviewedFileNames()}
+                    expandedNodes={expandedFileNodes()}
+                  />
+                </Show>
+
+                <Panel flexGrow={1} minHeight={0} border="none">
+                  <Separator axis="x" start="edge-out" />
+                  <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, index) => {
+                            const reviewed = () => reviewedFileNames().has(entry.file.file)
+                            return (
+                              <box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
+                                {index() !== 0 ? <Separator axis="x" start="edge" /> : null}
+                                <box
+                                  flexDirection="row"
+                                  gap={1}
+                                  flexShrink={0}
+                                  paddingLeft={2}
+                                  paddingRight={1}
+                                  border={["left"]}
+                                  borderColor={theme().border}
+                                >
+                                  <text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
+                                  <box flexGrow={1} />
+                                  <text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
+                                    +{entry.file.additions}
+                                  </text>
+                                  <text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
+                                    -{entry.file.deletions}
+                                  </text>
+                                </box>
+                                <Separator axis="x" start="edge" />
+                                <Show
+                                  when={entry.file.patch}
+                                  fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
+                                >
+                                  {(patch) => (
+                                    <box border={["left"]} borderColor={theme().border}>
+                                      <diff
+                                        diff={patch()}
+                                        view={view()}
+                                        filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
+                                        syntaxStyle={themeState.syntax()}
+                                        showLineNumbers={true}
+                                        width="100%"
+                                        wrapMode="char"
+                                        fg={reviewed() ? theme().textMuted : theme().text}
+                                        addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
+                                        removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
+                                        addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
+                                        removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
+                                        lineNumberFg={theme().diffLineNumber}
+                                        addedLineNumberBg={
+                                          reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
+                                        }
+                                        removedLineNumberBg={
+                                          reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
+                                        }
+                                      />
+                                    </box>
+                                  )}
+                                </Show>
+                              </box>
+                            )
+                          }}
+                        </For>
+                      </scrollbox>
+                    </Match>
+                  </Switch>
+                  <Separator axis="x" start="edge-in" />
+                </Panel>
+              </PanelGroup>
+            </Match>
+          </Switch>
         </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>
+
+        <Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
+          <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>
+          <Show when={markReviewedShortcut()}>
+            {(shortcut) => (
+              <text fg={theme().text}>
+                {shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
+              </text>
+            )}
+          </Show>
+        </Panel>
+      </PanelGroup>
     </box>
   )
 }

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

@@ -4,7 +4,9 @@ import {
   buildFileTree,
   flattenFileTree,
   moveFileTreeSelection,
+  moveFileTreeSelectionToFirstChild,
   moveFileTreeSelectionToFile,
+  moveFileTreeSelectionToParent,
   setFileTreeDirectoryExpanded,
   toggleFileTreeDirectory,
 } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
@@ -177,6 +179,47 @@ describe("diff viewer file tree utilities", () => {
     expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
   })
 
+  test("moves directory selection to first visible child", () => {
+    const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
+    const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
+    const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
+    const tui = rows.find((row) => row.name === "tui.ts")!
+
+    expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
+    expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
+    expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
+  })
+
+  test("moves collapsed chain selection to first visible child", () => {
+    const rows = flattenFileTree(
+      buildFileTree([
+        { file: "packages/opencode/src/cli/app.ts" },
+        { file: "packages/opencode/src/server/server.ts" },
+      ]),
+    )
+    const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
+    const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
+
+    expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
+  })
+
+  test("moves file and collapsed directory selection to visible parent", () => {
+    const rows = flattenFileTree(
+      buildFileTree([
+        { file: "packages/opencode/src/cli/app.ts" },
+        { file: "packages/opencode/src/server/server.ts" },
+      ]),
+    )
+    const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
+    const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
+    const app = rows.find((row) => row.name === "app.ts")!
+
+    expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
+    expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
+    expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
+    expect(moveFileTreeSelectionToParent(rows, undefined)).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" }]),

+ 59 - 38
packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"
 import { RGBA } from "@opentui/core"
 import { testRender } from "@opentui/solid"
 import type { JSX } from "solid-js"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
+import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
+import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
 import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
 import {
   allExpandedFileTreeDirectories,
@@ -14,6 +18,7 @@ const theme = {
   backgroundPanel: RGBA.fromHex("#111111"),
   backgroundElement: RGBA.fromHex("#333333"),
   primary: RGBA.fromHex("#00ffff"),
+  secondary: RGBA.fromHex("#0088ff"),
   selectedListItemText: RGBA.fromHex("#ffffff"),
   text: RGBA.fromHex("#ffffff"),
   textMuted: RGBA.fromHex("#888888"),
@@ -23,44 +28,50 @@ const theme = {
 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}
-        />
+      () => withTheme(
+        () => (
+          <DiffViewerFileTree
+            width={32}
+            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()
+      await renderOnceSettled(app)
       const lines = visibleLines(app.captureCharFrame())
 
-      expect(lines).toEqual(["▾ a", "    alpha.ts", "    zeta.ts", "▾ b", "    alpha.ts", "    file.ts", "  z-file.ts"])
+      expect(lines).toEqual([
+        "▾ a",
+        "│  ├─ alpha.ts               ?",
+        "│  └─ zeta.ts                ?",
+        "├─ ▾ b",
+        "│  ├─ alpha.ts               ?",
+        "│  └─ 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 loading = await renderFrame(() => <DiffViewerFileTree width={32} 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} />
+      <DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
     ))
+    const empty = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />)
 
     expect(loading).not.toContain("Loading diff...")
     expect(loading).not.toContain("No files")
@@ -75,18 +86,11 @@ describe("DiffViewerFileTree", () => {
 
     const focused = visibleLines(
       await renderFrame(() => (
-        <DiffViewerFileTree
-          files={files}
-          loading={false}
-          error={undefined}
-          theme={theme}
-          focused
-          highlightedNode={src.id}
-        />
+        <DiffViewerFileTree width={32} 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} />),
+      await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />),
     )
 
     expect(focused).toContain("▾ src/config")
@@ -105,16 +109,17 @@ describe("DiffViewerFileTree", () => {
     expect(
       visibleLines(
         await renderFrame(() => (
-          <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
+          <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
         )),
       ),
-    ).toEqual(["▸ src/config", "  README.md"])
+    ).toEqual(["▸ src/config"])
 
     expect(
       visibleLines(
         await renderFrame(() => (
           <DiffViewerFileTree
             files={files}
+            width={32}
             loading={false}
             error={undefined}
             theme={theme}
@@ -122,25 +127,41 @@ describe("DiffViewerFileTree", () => {
           />
         )),
       ),
-    ).toEqual(["▾ src/config", "    tui.ts", "  README.md"])
+    ).toEqual(["▾ src/config", "│  └─ tui.ts                 ?"])
   })
 })
 
 async function renderFrame(component: () => JSX.Element) {
-  const app = await testRender(component, { width: 40, height: 10 })
+  const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
   try {
-    await app.renderOnce()
+    await renderOnceSettled(app)
     return app.captureCharFrame()
   } finally {
     app.renderer.destroy()
   }
 }
 
+async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
+  await app.renderOnce()
+  await new Promise((resolve) => setTimeout(resolve, 25))
+  await app.renderOnce()
+}
+
+function withTheme(component: () => JSX.Element) {
+  return (
+    <TuiConfigProvider config={createTuiResolvedConfig()}>
+      <KVProvider>
+        <ThemeProvider mode="dark">{component()}</ThemeProvider>
+      </KVProvider>
+    </TuiConfigProvider>
+  )
+}
+
 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))
+    .filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
 }