feat(tui): streamline diff review workflow (#45817)

This commit is contained in:
Kit Langton 2026-08-28 00:11:29 -04:00 committed by GitHub
parent 074413a96d
commit 92b9eebab2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2454 additions and 1067 deletions

View file

@ -1,4 +1,4 @@
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import {
For,
Index,
@ -398,7 +398,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
}
return (
<Portal>
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper otherwise follows the full-height app in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2500
}}
>
<box
position="absolute"
left={0}

View file

@ -66,21 +66,26 @@ export const Definitions = {
"diff.up": keybind("k,up", "Move diff viewer up"),
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
"diff.expand": keybind("right", "Expand diff viewer item"),
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
"diff.collapse": keybind("left", "Collapse diff viewer item"),
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
"diff.half_page.down": keybind("ctrl+d", "Scroll diff viewer down half a page"),
"diff.half_page.up": keybind("ctrl+u", "Scroll diff viewer up half a page"),
"diff.first": keybind("gg,home", "Go to the start of the diff"),
"diff.last": keybind("shift+g,end", "Go to the end of the diff"),
// Retain shipped configuration names without registering the removed tree navigation commands.
"diff.toggle": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.expand": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.expand_all": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.collapse": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.switch_focus": keybind("none", "Deprecated: keyboard navigation always controls the diff"),
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
"diff.next_file": keybind("n", "Jump to next diff file"),
"diff.previous_file": keybind("p", "Jump to previous diff file"),
"diff.next_file": keybind("n,alt+down", "Jump to next diff file"),
"diff.previous_file": keybind("p,alt+up", "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_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
"prompt.editor": keybind("<leader>e", "Open external editor"),
"theme.switch": keybind("<leader>t", "List available themes"),

View file

@ -1,8 +1,3 @@
// 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
readonly status?: "added" | "deleted" | "modified"
@ -119,44 +114,20 @@ export function compareFileTreeNodes(tree: FileTree, left: number, right: number
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 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 fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
return {
highlightedNode: node.id,
expandedNodes: fileTreeParentDirectories(tree, node.id),
}
}
export function singlePatchFileIndex(
selected: number | undefined,
active: number | undefined,
current: number | undefined,
first: number | undefined,
) {
return selected ?? active ?? current ?? first
return selected ?? current ?? first
}
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
@ -186,19 +157,6 @@ export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<nu
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: [] })

View file

@ -1,156 +1,259 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { Locale } from "../../util/locale"
import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { truncateFilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
import { useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
const FILE_TREE_STATUS_WIDTH = 2
const FILE_TREE_STATUS_WIDTH = 1
export type DiffViewerFileTreeProps = {
readonly context: Plugin.Context
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
readonly error: unknown
readonly focused?: boolean
readonly highlightedNode?: number
readonly layout?: "tree" | "list"
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number>
readonly onRowClick?: (row: FileTreeRow) => void
readonly onFileContextMenu?: (fileIndex: number, event: MouseEvent) => void
readonly source?: string
readonly onSwitchSource?: () => void
readonly footer?: JSX.Element
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const theme = props.context.theme
const theme = useTheme("elevated")
const [sourceHovered, setSourceHovered] = createSignal(false)
const list = () => props.layout === "list"
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
const rows = createMemo(() =>
list()
? flattenFileTree(tree()).filter((row) => row.fileIndex !== undefined)
: flattenFileTree(tree(), props.expandedNodes),
)
// Quieter than subdued text: markers are affordances, not content.
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
// Rails are pure texture; keep them barely above the surface.
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7))
const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length)
const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1)
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)
const index = rows().findIndex((row) => row.fileIndex !== undefined && row.fileIndex === props.selectedFileIndex)
if (index === -1) return
const top = index * (list() ? 3 : 1)
const height = list() ? 2 : 1
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, top, height)
scrollSelectedIntoView()
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
return (
<Panel border="both" width={props.width} context={props.context}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.background.default}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={theme.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row, index) => {
const highlighted = () => props.focused && props.highlightedNode === 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) ?? false)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const name = () =>
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
return (
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
<text
fg={
highlighted()
? theme.text.action.primary.focused
: selected()
? theme.text.formfield.selected
: reviewed() || row.kind === "directory"
? theme.text.subdued
: theme.text.default
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<text
id="diff-source-switch"
fg={
props.onSwitchSource
? sourceHovered()
? theme.text.action.secondary.hovered
: theme.text.action.secondary.default
: theme.text.default
}
attributes={TextAttributes.BOLD}
flexGrow={1}
wrapMode="none"
truncate
selectable={false}
onMouseOver={() => setSourceHovered(true)}
onMouseOut={() => setSourceHovered(false)}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
props.onSwitchSource?.()
}}
>
{props.source ?? "Files"}
</text>
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
{reviewedCount()}/{props.files.length} reviewed
</text>
</box>
<scrollbox
id="diff-files"
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={theme.text.subdued}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<box flexShrink={0} gap={list() ? 1 : 0}>
<For each={rows()}>
{(row) => {
const [hovered, setHovered] = createSignal(false)
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) ?? false)
}
const foreground = () => {
if (row.kind === "directory") return theme.text.subdued
return reviewed() ? theme.text.subdued : theme.text.default
}
const background = () => {
// Elevated context maps this to a quiet neutral surface step, not the loud accent.
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
const marker = () => {
if (row.kind !== "directory") return "≡ "
return props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ "
}
// Rails run straight down from each ancestor folder; no end hooks.
const indent = createMemo(() => {
if (list()) return ""
return "│ ".repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2))))
})
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const statusColor = () => {
if (reviewed()) return theme.text.subdued
const status = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.status
if (status === "added") return theme.diff.text.added
if (status === "deleted") return theme.diff.text.removed
return theme.text.subdued
}
const name = () => {
const width = contentWidth() - stringWidth(indent()) - stringWidth(marker())
if (row.kind === "directory") return truncateDirectoryChain(row.name, width)
return truncateFilePath(row.name, width)
}
const parent = () => {
const file = row.fileIndex === undefined ? "" : (props.files[row.fileIndex]?.file ?? "")
const directory = file.slice(0, Math.max(0, file.lastIndexOf("/")))
return directory ? truncateDirectoryChain(directory, contentWidth() - stringWidth(marker())) : ""
}
return (
<box
id={
row.fileIndex === undefined ? `diff-folder-row-${row.id}` : `diff-file-row-${row.fileIndex}`
}
wrapMode="none"
flexDirection="column"
width="100%"
height={list() ? 2 : 1}
flexShrink={0}
backgroundColor={background()}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (row.fileIndex !== undefined) props.onFileContextMenu?.(row.fileIndex, event)
}}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
props.onRowClick?.(row)
}}
>
{name()}
</text>
</box>
<text
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
flexShrink={0}
>
{status()}
</text>
</box>
)
}}
</For>
</Match>
</Switch>
</scrollbox>
</Panel>
<box flexDirection="row" height={1}>
<text wrapMode="none" flexShrink={0}>
<span style={{ fg: rail() }}>{indent()}</span>
<span style={{ fg: faint() }}>{marker()}</span>
</text>
<box flexGrow={1} minWidth={0} marginRight={1}>
<text
fg={foreground()}
attributes={selected() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{name()}
</text>
</box>
<text fg={statusColor()} wrapMode="none" width={FILE_TREE_STATUS_WIDTH} flexShrink={0}>
{status()}
</text>
</box>
<Show when={list()}>
<text
fg={foreground()}
attributes={selected() || hovered() ? TextAttributes.DIM : undefined}
marginLeft={stringWidth(marker())}
wrapMode="none"
truncate
>
{parent()}
</text>
</Show>
</box>
)
}}
</For>
</box>
</Match>
</Switch>
</scrollbox>
<Show when={props.footer}>
<box flexShrink={0} paddingTop={1} paddingBottom={1}>
{props.footer}
</box>
</Show>
</box>
</box>
)
}
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
if (!scroll) return
if (index < scroll.scrollTop) {
scroll.scrollTo(index)
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, top: number, height: number) {
if (!scroll || scroll.isDestroyed) return
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (index >= scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(index - scroll.viewport.height + 1)
if (top + height > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + height - scroll.viewport.height)
}
}
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[], reviewed: boolean) {
if (row.fileIndex === undefined) return ""
if (reviewed) return "✓"
const status = files[row.fileIndex]?.status
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
return status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
}
// Collapsed chains drop whole leading segments instead of squeezing
// mid-segment, so "a/b/c/d" narrows to "…/c/d" rather than "…/b…/c/d".
function truncateDirectoryChain(name: string, maxWidth: number) {
if (stringWidth(name) <= maxWidth) return name
const kept: string[] = []
let width = stringWidth("…/")
for (const segment of name.split("/").toReversed()) {
const next = stringWidth(segment) + (kept.length ? 1 : 0)
if (width + next > maxWidth) break
kept.unshift(segment)
width += next
}
if (kept.length === 0) return truncateFilePath(name, maxWidth)
return `…/${kept.join("/")}`
}

View file

@ -0,0 +1,96 @@
/** @jsxImportSource @opentui/solid */
import { useTerminalDimensions } from "@opentui/solid"
import type { MouseEvent } from "@opentui/core"
import { createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { useTheme } from "../../context/theme"
import { useDialog } from "../../ui/dialog"
export function isDiffImageFile(file: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(file)
}
export function DiffViewerImage(props: {
file: string
load: (file: string, signal: AbortSignal) => Promise<Uint8Array>
label?: string
}) {
const theme = useTheme()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const height = () => Math.max(3, Math.min(8, Math.floor(dimensions().height / 4)))
const [image] = createResource(
() => {
const controller = new AbortController()
onCleanup(() => controller.abort())
return { file: props.file, signal: controller.signal }
},
(input) => props.load(input.file, input.signal),
)
return (
<box width="100%" flexShrink={0} gap={1} paddingLeft={1} paddingRight={1} paddingBottom={1}>
<text fg={theme.text.subdued}>{props.label ?? "Working tree preview"}</text>
<box height={height() + 2} flexShrink={0} gap={1}>
<Switch>
<Match when={image.error}>
<text fg={theme.text.feedback.error.default}>Could not load image</text>
</Match>
<Match when={image.loading}>
<text fg={theme.text.subdued}>Loading image...</text>
</Match>
<Match when={!image.error && image()} keyed>
{(bytes) => {
const [failed, setFailed] = createSignal(false)
const [size, setSize] = createSignal<string>()
const open = (event: MouseEvent) => {
if (event.button !== 0 || !size() || failed()) return
event.stopPropagation()
dialog.replace(() => (
<DialogImagePreview
images={[
{
uri: `data:application/octet-stream;base64,${Buffer.from(bytes).toString("base64")}`,
mention: { text: props.file },
},
]}
initial={0}
/>
))
}
return (
<Show
when={!failed()}
fallback={<text fg={theme.text.feedback.error.default}>Could not decode image</text>}
>
<box width="100%" height={height()} onMouseUp={open}>
<image
id={`diff-image-${props.file}`}
source={bytes}
fit="fit"
protocol="auto"
width="100%"
height="100%"
onLoad={(loaded) => setSize(`${loaded.width} x ${loaded.height}`)}
onError={() => setFailed(true)}
/>
</box>
<Show when={size()}>
{(value) => (
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}>{value()}</text>
<text fg={theme.text.action.secondary.default} onMouseUp={open}>
Click to enlarge
</text>
</box>
)}
</Show>
</Show>
)
}}
</Match>
</Switch>
</box>
</box>
)
}

View file

@ -1,108 +0,0 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { JSX } from "@opentui/solid"
import { createContext, Show, 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; context: Plugin.Context }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
}
function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
<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; context?: Plugin.Context },
) {
const group = usePanelGroup()
const [local, boxProps] = splitProps(props, ["border", "context"])
const context = local.context ?? group?.context
if (!context) throw new Error("Panel context is missing")
const theme = context.theme
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: theme.border.default,
}
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()
if (!group) throw new Error("PanelGroup is missing")
const theme = group.context.theme
const color = () => props.color ?? theme.border.default
const axis = () => props.axis ?? crossAxis(group.axis)
if (axis() === "y") {
return (
<Show
when={props.start || props.end}
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
>
<box width={1} flexShrink={0} flexDirection="column">
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["left"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
return (
<Show
when={props.start || props.end}
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
>
<box height={1} flexShrink={0} flexDirection="row">
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["top"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
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 "├"
}

File diff suppressed because it is too large Load diff

View file

@ -236,7 +236,10 @@ function settle<T>(resolve: (value: T) => void) {
}
}
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
export function createDialogApi(
dialog: ReturnType<typeof useDialog>,
provide: (render: () => JSX.Element) => JSX.Element,
) {
const api: Dialog = {
show(render, onClose) {
dialog.replace(() => provide(render), onClose)

View file

@ -2,16 +2,11 @@
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { emptyThemeSource } from "../../fixture/fixture"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import {
DiffViewerFileTree,
type DiffViewerFileTreeProps,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiContexts } from "../../fixture/tui-environment"
import {
allExpandedFileTreeDirectories,
@ -19,44 +14,44 @@ import {
} from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
describe("DiffViewerFileTree", () => {
test.skip("renders sorted hierarchical file rows", async () => {
const lines = visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
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}
focused={true}
/>
)),
)
test("defaults to text-line file icons and triangle folders with straight rails", async () => {
const frame = await renderFrame(() => (
<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}
/>
))
expect(lines).toEqual([
expect(visibleLines(frame)).toEqual([
"Files 0/5 reviewed",
"▾ a",
"│ ├─ alpha.ts ?",
"│ └─ zeta.ts ?",
"├─ ▾ b",
"│ ├─ alpha.ts ?",
"│ └─ file.ts ?",
"│ ≡ alpha.ts ?",
"│ ≡ zeta.ts ?",
"▾ b",
"│ ≡ alpha.ts ?",
"│ ≡ file.ts ?",
"≡ z-file.ts ?",
])
expect(frame).not.toMatch(/[├└─]/)
})
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<ThemedDiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
))
const failed = await renderFrame(() => (
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
))
const empty = await renderFrame(() => (
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff…")
@ -64,32 +59,64 @@ describe("DiffViewerFileTree", () => {
expect(failed).not.toContain("Failed to load diff")
expect(failed).not.toContain("No files")
expect(empty).toContain("No files")
expect(
empty
.split("\n")
.find((line) => line.includes("No files"))
?.indexOf("No files"),
).toBe(2)
})
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")!
test.each(["tree", "list"] as const)("%s layout uses two-cell horizontal sidebar padding", async (layout) => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
layout={layout}
files={[
{ file: "src/a.ts", status: "added" },
{ file: "README.md", status: "modified" },
]}
loading={false}
error={undefined}
/>
))
const lines = frame
.split("\n")
.slice(1)
.filter((line) => line.trim())
expect(lines.find((line) => line.includes("Files"))?.indexOf("Files")).toBe(2)
const file = lines.find((line) => line.includes("README.md"))!
expect(file.indexOf("≡")).toBe(2)
expect(file.slice(29, 32)).toBe("M ")
expect(lines.every((line) => line.startsWith(" ") && line.slice(30, 32) === " ")).toBe(true)
})
const focused = visibleLines(
test.each(["dark", "light"] as const)("full top padding keeps the heading one row down in %s mode", async (mode) => {
const frame = await renderFrame(
() => <DiffViewerFileTree width={32} files={[{ file: "README.md" }]} loading={false} error={undefined} />,
mode,
)
const lines = frame.split("\n")
expect(lines[0].trim()).toBe("")
expect(lines[1].indexOf("Files")).toBe(2)
expect(lines.find((line) => line.includes("README.md"))?.indexOf("≡")).toBe(2)
})
test("does not render text markers for selected files", async () => {
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
const selected = visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
focused
highlightedNode={src.id}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} selectedFileIndex={0} />
)),
)
const unfocused = visibleLines(
await renderFrame(() => <ThemedDiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
const unselected = visibleLines(
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
)
expect(focused).toContain("▾ src/config")
expect(unfocused).toContain("▾ src/config")
expect(focused.some((line) => line.includes("*"))).toBe(false)
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
expect(selected).toContain("▾ src/config")
expect(unselected).toContain("▾ src/config")
expect(selected.some((line) => line.includes("*"))).toBe(false)
expect(unselected.some((line) => line.includes("*"))).toBe(false)
})
test("renders collapsed and expanded directory rows", async () => {
@ -102,21 +129,15 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
expandedNodes={collapsed}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
)),
),
).toEqual(["▸ src/config"])
).toEqual(["Files 0/2 reviewed", "▸ src/config", "≡ README.md ?"])
expect(
visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
<DiffViewerFileTree
files={files}
width={32}
loading={false}
@ -125,49 +146,111 @@ describe("DiffViewerFileTree", () => {
/>
)),
),
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
).toEqual(["Files 0/2 reviewed", "▾ src/config", "│ ≡ tui.ts ?", "≡ README.md ?"])
})
test.each(["dark", "light"] as const)(
"file tabs distinguish duplicate basenames and review state in %s",
async (mode) => {
const frame = await renderFrame(
() => (
<DiffViewerFileTree
width={32}
layout="list"
files={[
{ file: "src/sidebar.tsx", status: "added" },
{ file: "test/sidebar.tsx", status: "modified" },
]}
loading={false}
error={undefined}
selectedFileIndex={0}
reviewedFileNames={new Set(["src/sidebar.tsx"])}
/>
),
mode,
)
expect(visibleLines(frame)).toEqual(["Files 1/2 reviewed", "≡ sidebar.tsx ✓", "src", "≡ sidebar.tsx M", "test"])
expect(frame).not.toMatch(/[│├└─]/)
},
)
test("keeps rows quiet: straight rails, single status letters, no hooks or dots", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={[
{ file: "src/a.ts", status: "added" },
{ file: "src/b.ts", status: "modified" },
{ file: "test/a.ts", status: "deleted" },
]}
loading={false}
error={undefined}
/>
))
const lines = visibleLines(frame)
expect(lines).toEqual(["Files 0/3 reviewed", "▾ src", "│ ≡ a.ts A", "│ ≡ b.ts M", "▾ test", "│ ≡ a.ts D"])
expect(frame).not.toMatch(/[├└─·]/)
expect(frame).not.toMatch(/[\uE000-\uF8FF]/)
})
test("file tabs align parent paths beneath marked filenames", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
layout="list"
files={[{ file: "src/sidebar.tsx", status: "modified" }]}
loading={false}
error={undefined}
/>
))
expect(visibleLines(frame)).toEqual(["Files 0/1 reviewed", "≡ sidebar.tsx M", "src"])
const lines = frame.split("\n")
expect(lines.find((line) => line.includes("src"))?.indexOf("src")).toBe(
lines.find((line) => line.includes("sidebar.tsx"))?.indexOf("sidebar.tsx"),
)
})
test("narrow collapsed chains drop whole leading segments", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={26}
files={[
{ file: "packages/tui/src/feature-plugins/system/deeply/nested/selection.ts" },
{ file: "packages/tui/src/feature-plugins/system/other/index.ts" },
]}
loading={false}
error={undefined}
/>
))
const lines = visibleLines(frame)
expect(lines).toContain("▾ …/system")
expect(frame).not.toMatch(/\S+…\//)
})
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: useThemes().currentTokens() } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {
const mounted = Promise.withResolvers<void>()
const app = await testRender(() => withTheme(component, mounted.resolve), { width: 40, height: 10 })
async function renderFrame(component: () => JSX.Element, mode: "dark" | "light" = "dark") {
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode={mode} source={emptyThemeSource}>
{component()}
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 40, height: 20 },
)
try {
await mounted.promise
await app.renderOnce()
await app.renderOnce()
return app.captureCharFrame()
return await app.waitForFrame((frame) => frame.includes("Files"))
} finally {
app.renderer.destroy()
}
}
function withTheme(component: () => JSX.Element, onReady = () => {}) {
return (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Ready onReady={onReady}>{component()}</Ready>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
)
}
function Ready(props: ParentProps<{ onReady: () => void }>) {
onMount(props.onReady)
return props.children
}
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))
.map((line) => line.trim().replace(/\s+/g, " "))
.filter(Boolean)
}

View file

@ -0,0 +1,172 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { ImageRenderable, type Renderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal, Show, type JSX } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider } from "../../../src/context/theme"
import { Keymap } from "../../../src/context/keymap"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { DiffViewerImage, isDiffImageFile } from "../../../src/feature-plugins/system/diff-viewer-image"
import { diffImageFixture } from "../../fixture/diff-image"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("recognizes only supported image file extensions", () => {
for (const extension of ["png", "jpg", "jpeg", "webp", "gif", "PNG", "JPEG"]) {
expect(isDiffImageFile(`assets/preview.${extension}`)).toBe(true)
}
for (const file of ["assets/preview.svg", "preview.avif", "image.png.ts", "png", "image.png/file"]) {
expect(isDiffImageFile(file)).toBe(false)
}
})
test.each([
{ width: 40, height: 16, mode: "dark" as const },
{ width: 120, height: 40, mode: "light" as const },
])("renders a real image at $width columns in $mode mode", async (options) => {
const pending = Promise.withResolvers<Uint8Array>()
const requested: string[] = []
const app = await renderImage(
() => (
<DiffViewerImage
file="assets/landscape.png"
load={(file) => {
requested.push(file)
return pending.promise
}}
/>
),
options,
)
try {
await app.waitForFrame((frame) => frame.includes("Loading image..."))
pending.resolve(diffImageFixture)
await app.waitForFrame((frame) => frame.includes("96 x 48"))
expect(requested).toEqual(["assets/landscape.png"])
expect(app.captureCharFrame()).toContain("Working tree preview")
const image = findImage(app.renderer.root)!
expect(image.image?.width).toBe(96)
expect(image.image?.height).toBe(48)
expect(image.width).toBe(options.width - 2)
expect(image.height).toBe(Math.min(8, options.height / 4))
expect(image.fit).toBe("fit")
expect(image.protocol).toBe("auto")
} finally {
app.renderer.destroy()
}
})
test("clicking a thumbnail opens the shared image modal and escape returns to the preview", async () => {
const app = await renderImage(() => <DiffViewerImage file="landscape.png" load={async () => diffImageFixture} />)
try {
await app.waitForFrame((frame) => frame.includes("Click to enlarge"))
const thumbnail = findImage(app.renderer.root)!
await app.mockMouse.click(thumbnail.x + Math.floor(thumbnail.width / 2), thumbnail.y + 1)
await app.waitForFrame((frame) => frame.includes("Image 1 of 1"))
const modal = app.renderer.root.findDescendantById("prompt-image-viewer-image")
expect(modal).toBeInstanceOf(ImageRenderable)
if (!(modal instanceof ImageRenderable)) throw new Error("Missing image modal")
await app.waitFor(() => modal.image?.width === 96)
expect(modal.image?.height).toBe(48)
expect(modal.height).toBeGreaterThan(thumbnail.height)
expect(app.captureCharFrame()).toContain("landscape.png")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => !frame.includes("Image 1 of 1"))
expect(findImage(app.renderer.root)).toBe(thumbnail)
expect(app.captureCharFrame()).toContain("Click to enlarge")
} finally {
app.renderer.destroy()
}
})
test("pending image reads are aborted when the source changes or the preview closes", async () => {
const pending = Promise.withResolvers<Uint8Array>()
const [file, setFile] = createSignal("a.png")
const [visible, setVisible] = createSignal(true)
const signals: AbortSignal[] = []
const app = await renderImage(() => (
<Show when={visible()}>
<DiffViewerImage
file={file()}
load={(_, signal) => {
signals.push(signal)
return pending.promise
}}
/>
</Show>
))
try {
await app.waitForFrame((frame) => frame.includes("Loading image..."))
expect(signals[0].aborted).toBe(false)
setFile("b.png")
await app.flush()
expect(signals).toHaveLength(2)
expect(signals[0].aborted).toBe(true)
expect(signals[1].aborted).toBe(false)
setVisible(false)
await app.flush()
expect(signals[1].aborted).toBe(true)
} finally {
app.renderer.destroy()
pending.resolve(diffImageFixture)
}
})
test.each(["fetch", "decode"])("recovers from a %s error when the file changes", async (failure) => {
const [file, setFile] = createSignal("broken.png")
const app = await renderImage(() => (
<DiffViewerImage
file={file()}
label="Story fixture preview"
load={async (file) => {
if (file !== "broken.png") return diffImageFixture
if (failure === "fetch") throw new Error("Unavailable")
return new Uint8Array([1, 2, 3])
}}
/>
))
try {
await app.waitForFrame((frame) =>
frame.includes(failure === "fetch" ? "Could not load image" : "Could not decode image"),
)
expect(findImage(app.renderer.root)).toBeUndefined()
setFile("landscape.png")
await app.waitForFrame((frame) => frame.includes("96 x 48"))
expect(app.captureCharFrame()).toContain("Story fixture preview")
expect(app.captureCharFrame()).not.toContain("Could not")
expect(findImage(app.renderer.root)?.image?.width).toBe(96)
} finally {
app.renderer.destroy()
}
})
function renderImage(
component: () => JSX.Element,
options = { width: 80, height: 24, mode: "dark" as "dark" | "light" },
) {
return testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode={options.mode} source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>{component()}</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ ...options, kittyKeyboard: true },
)
}
function findImage(root: Renderable): ImageRenderable | undefined {
if (root instanceof ImageRenderable) return root
return root.getChildren().map(findImage).find(Boolean)
}

File diff suppressed because it is too large Load diff

View file

@ -65,7 +65,7 @@ test("releasing a transcript selection over tab controls does not activate them"
}
})
test("the tab context menu keeps preview tabs open without offering promotion for permanent tabs", async () => {
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const controller = {
@ -104,18 +104,31 @@ test("the tab context menu keeps preview tabs open without offering promotion fo
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.click(5, 0, MouseButton.RIGHT)
const first = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("First"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[first]!.indexOf("First"), first, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
await app.mockMouse.click(40, 0, MouseButton.RIGHT)
const second = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Second"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[second]!.indexOf("Second"), second, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
expect(app.captureCharFrame()).toContain("Rename")
expect(app.captureCharFrame()).toContain("Close")
expect(active()).toBe("first")
const frame = app.captureCharFrame().split("\n")
const row = frame.findIndex((line) => line.includes("Keep open"))
await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row)
await app.waitForFrame((frame) => !frame.includes("Rename"))
expect(promoted).toEqual(["second"])
expect(active()).toBe("first")

View file

@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { batch, createSignal } from "solid-js"
import { ConfigProvider, useConfig, type Info } from "../../src/config"
@ -13,23 +14,27 @@ import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { LocationProvider } from "../../src/context/location"
import { Keymap } from "../../src/context/keymap"
import { RouteProvider } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider } from "../../src/context/session-tabs"
import { StorageProvider } from "../../src/context/storage"
import { ThemeProvider, useTheme } from "../../src/context/theme"
import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
for (const orientation of ["horizontal", "vertical"] as const) {
test(`${orientation} tabs replace ordinals with status without moving titles`, async () => {
test(`${orientation} tabs replace ordinals with status without moving titles and keep context menu actions`, async () => {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const [preview, setPreview] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
let config!: ReturnType<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
@ -53,6 +58,10 @@ for (const orientation of ["horizontal", "vertical"] as const) {
},
close() {},
move() {},
isPreview: (sessionID: string) => sessionID === "first" && preview(),
promote(sessionID: string) {
if (sessionID === "first") setPreview(false)
},
detail: () => "project",
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
} satisfies SessionTabsController
@ -78,7 +87,19 @@ for (const orientation of ["horizontal", "vertical"] as const) {
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Colors />
<SessionTabs controller={controller} orientation={orientation} animations={animations()} />
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<box width="100%" height="100%">
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
</box>
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
@ -227,6 +248,46 @@ for (const orientation of ["horizontal", "vertical"] as const) {
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
setAnimations(false)
setStatus(EMPTY_SESSION_TAB_STATUS)
setActive("second")
await app.renderOnce()
const rows = app.captureCharFrame().split("\n")
const row = rows.findIndex((line) => line.includes("First"))
const column = rows[row]!.indexOf("First")
await app.mockMouse.click(column, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame().split("\n")[row + 1]!.indexOf("Rename")).toBe(column + 1)
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
expect(active()).toBe("second")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
setPreview(true)
await app.mockMouse.click(column, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
expect(app.captureCharFrame()).toContain("Rename")
expect(app.captureCharFrame()).toContain("Close")
expect(active()).toBe("second")
for (const size of [
{ width: 18, height: 4, row: 1, column: 3 },
{ width: 60, height: 10, row: row + 1, column: column + 1 },
]) {
app.resize(size.width, size.height)
await app.waitForFrame((frame) => frame.split("\n")[0]?.length === size.width && frame.includes("Keep open"))
const rows = app.captureCharFrame().split("\n")
expect(rows[size.row]?.indexOf("Keep open")).toBe(size.column)
expect(rows[size.row + 1]?.indexOf("Rename")).toBe(size.column)
expect(rows[size.row + 2]?.indexOf("Close")).toBe(size.column)
}
const menu = app.captureCharFrame().split("\n")
const keepOpen = menu.findIndex((line) => line.includes("Keep open"))
await app.mockMouse.click(menu[keepOpen]!.indexOf("Keep open"), keepOpen)
await app.waitForFrame((frame) => !frame.includes("Rename"))
expect(preview()).toBe(false)
expect(active()).toBe("second")
setNewTab(true)
await app.waitForFrame((frame) => frame.includes("+ New session"))
} finally {

View file

@ -179,6 +179,10 @@ test("accepts every v2-only named command ID", () => {
"diff.up",
"diff.page.down",
"diff.page.up",
"diff.half_page.down",
"diff.half_page.up",
"diff.first",
"diff.last",
"diff.mark_reviewed",
"opencode.settings",
"service.restart",
@ -207,7 +211,16 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.up": "k,up",
"diff.page.down": "pagedown,ctrl+f",
"diff.page.up": "pageup,ctrl+b",
"diff.half_page.down": "ctrl+d",
"diff.half_page.up": "ctrl+u",
"diff.first": "gg,home",
"diff.last": "shift+g,end",
"diff.next_file": "n,alt+down",
"diff.previous_file": "p,alt+up",
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })
Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
@ -219,6 +232,14 @@ test("centralizes named command defaults and resolves explicit none", () => {
Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
})
test("retired diff tree keybinds remain accepted but have no default bindings", () => {
const ids = ["diff.toggle", "diff.expand", "diff.expand_all", "diff.collapse", "diff.switch_focus"]
const defaults = resolve({}, { terminalSuspend: true })
const overrides = Object.fromEntries(ids.map((id) => [id, "ctrl+alt+x"]))
expect(decodeInfo({ keybinds: overrides }).keybinds).toEqual(overrides)
ids.forEach((id) => expect(defaults.keybinds.get(id)).toEqual([]))
})
test("rejects orphaned keybind definitions", () => {
expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
})

View file

@ -4,12 +4,8 @@ import {
buildFileTree,
fileTreeFileSelection,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
setFileTreeDirectoryExpanded,
showDiffViewerFileTree,
singlePatchFileIndex,
toggleFileTreeDirectory,
@ -173,67 +169,18 @@ describe("diff viewer file tree utilities", () => {
])
})
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 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("selects a file tree node and expands its parents for a patch file", () => {
test("finds the parent directories to expand for a patch file", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
const selection = fileTreeFileSelection(tree, 1)
expect(selection?.highlightedNode).toBe(
tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
)
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
})
test("prefers the selected file when choosing the single patch file", () => {
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
expect(singlePatchFileIndex(2, 0, 3)).toBe(2)
expect(singlePatchFileIndex(undefined, 0, 3)).toBe(0)
expect(singlePatchFileIndex(undefined, undefined, 3)).toBe(3)
})
test("orders patches by the flattened file tree order", () => {
@ -284,20 +231,4 @@ describe("diff viewer file tree utilities", () => {
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)
})
})

View file

@ -0,0 +1,7 @@
// A 96 x 48 PNG fixture: a golden sun over blue mountains and a lake.
export const diffImageFixture = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAIAAABhdOiYAAABX0lEQVR42u3XwQkCMRCF4eljwVoET3r1YAGCbCcebMMCrMEW7MWrLiysi+4ms5PJJDEP/lsu4WMGEtpf78gRgQBAAAIQgCSttse5o9ej/a0uoE6nj6kjNqKidX6NHDoyIypdZ2zk1REY6QPtDhdjncGoAKBOp89Ypy9roIFmnKUO3ygB0KSOohFTh2lkCuSg0WJapMMxsgNi6oQYCXS8RkZAi3RkRmIdh5HFO0hAI2AK1Jk0snhJB+owjVR0voyi/8VUaLxMijRz35EoQOo6k0aRdEKMKKHOl1FUHbERJaQZZ6AjM6KqdARGlJbGXmepEVWos8iI6tThG1ESmhx0mEZUsw7HiCrX8RoRdPrmLkxNe0aOAAQgAE32vJ3GAQgTBKBIyxW4aNSsN58wMh4gSHGBipVy7Jdsy3xA1Y8VgBSB8hb0Lpds0VSB/nESUwAVhZgrUDaOhQPFR6wAKMzxDTghptXEGbWAAAAAAElFTkSuQmCC",
),
(character) => character.charCodeAt(0),
)

View file

@ -101,29 +101,40 @@ Unknown command IDs are rejected.
## Diff Viewer
| ID | Default | Description |
| ----------------------- | ----------------- | ---------------------------------------- |
| `diff.open` | `none` | Open diff viewer |
| `diff.close` | `escape,q` | Close diff viewer |
| `diff.down` | `j,down` | Move diff viewer down |
| `diff.up` | `k,up` | Move diff viewer up |
| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down |
| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up |
| `diff.toggle` | `enter,space` | Toggle diff viewer item |
| `diff.expand` | `right` | Expand diff viewer item |
| `diff.expand_all` | `E` | Expand all diff viewer folders |
| `diff.collapse` | `left` | Collapse diff viewer item |
| `diff.switch_focus` | `tab` | Switch diff viewer focus |
| `diff.next_hunk` | `]` | Jump to next diff hunk |
| `diff.previous_hunk` | `[` | Jump to previous diff hunk |
| `diff.next_file` | `n` | Jump to next diff file |
| `diff.previous_file` | `p` | Jump to previous diff file |
| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree |
| `diff.single_patch` | `s` | Toggle single patch view |
| `diff.switch_source` | `d` | Switch diff viewer source |
| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view |
| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed |
| `diff.help` | `?` | Show more diff viewer shortcuts |
Scrolling, paging, and start/end shortcuts always control the diff. There is no keyboard focus switch: click files to open them, click folders to expand or collapse them, and use the mouse wheel to scroll the file tree.
Use `n` / `p` or `alt+down` / `alt+up` (Option on macOS) to move between files. Inside the diff viewer, the Alt shortcuts navigate files rather than session tabs.
Press `m` to mark a file reviewed and collapse its patch body. In all-files view, press `m` again to unmark it and reopen the patch. Single-file view automatically advances to the next file when marking reviewed, but stays on the last file rather than wrapping. Unmarking a file never advances.
Right-click a file in the sidebar or its diff heading for **Mark complete** (or **Mark incomplete**). This changes the same reviewed state as `m`. Completing a different file from its menu does not select it or advance the file currently being read.
The shifted `diff.help` alternatives support terminals that report the Shift modifier when you press `?`.
| ID | Default | Description |
| ----------------------- | ------------------- | ---------------------------------------- |
| `diff.open` | `none` | Open diff viewer |
| `diff.close` | `escape,q` | Close diff viewer |
| `diff.down` | `j,down` | Move diff viewer down |
| `diff.up` | `k,up` | Move diff viewer up |
| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down |
| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up |
| `diff.half_page.down` | `ctrl+d` | Scroll diff viewer down half a page |
| `diff.half_page.up` | `ctrl+u` | Scroll diff viewer up half a page |
| `diff.first` | `gg,home` | Go to the start of the diff |
| `diff.last` | `shift+g,end` | Go to the end of the diff |
| `diff.next_hunk` | `]` | Jump to next diff hunk |
| `diff.previous_hunk` | `[` | Jump to previous diff hunk |
| `diff.next_file` | `n,alt+down` | Jump to next diff file |
| `diff.previous_file` | `p,alt+up` | Jump to previous diff file |
| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree |
| `diff.single_patch` | `s` | Toggle single patch view |
| `diff.switch_source` | `d` | Switch diff viewer source |
| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view |
| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed |
| `diff.help` | `?,shift+?,shift+/` | Show more diff viewer shortcuts |
The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, and `diff.switch_focus` configuration entries are still accepted, but no longer register shortcuts.
## Appearance And Navigation