Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 148 additions & 50 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import { parseDiffStats, formatDiffStats } from "@/cli/cmd/tui/util/diff"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
Expand Down Expand Up @@ -1600,12 +1601,27 @@ function Bash(props: ToolProps<typeof BashTool>) {
}

function Write(props: ToolProps<typeof WriteTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const sync = useSync()

const code = createMemo(() => {
if (!props.input.content) return ""
return props.input.content
})

// Check if minimal mode is enabled
const diffDisplay = createMemo(() => sync.data.config.tui?.diff_display ?? "full")

// Calculate line count
const lineCount = createMemo(() => code().split("\n").length)

// Expanded/collapsed state
const [expanded, setExpanded] = createSignal(false)

// Should show collapsed view
const showCollapsed = createMemo(() => diffDisplay() === "minimal" && !expanded())

const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
return props.metadata.diagnostics?.[filePath] ?? []
Expand All @@ -1614,24 +1630,56 @@ function Write(props: ToolProps<typeof WriteTool>) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
</text>
)}
</For>
<BlockTool
title={"# Wrote " + normalizePath(props.input.filePath!)}
part={props.part}
onClick={() => {
if (diffDisplay() === "minimal") {
setExpanded(!expanded())
}
}}
>
<Show
when={showCollapsed()}
fallback={
// Full content display (when expanded or full mode)
<>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
</text>
)}
</For>
</Show>
</>
}
>
{/* Collapsed view */}
<box paddingLeft={3} paddingTop={1} paddingBottom={1}>
<text fg={theme.textMuted}>
{lineCount()} lines [press Enter to expand]
</text>
</box>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error} paddingLeft={1}>
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
</text>
)}
</For>
</Show>
</Show>
</BlockTool>
</Match>
Expand Down Expand Up @@ -1781,6 +1829,21 @@ function Edit(props: ToolProps<typeof EditTool>) {

const diffContent = createMemo(() => props.metadata.diff)

// Check if minimal mode is enabled
const diffDisplay = createMemo(() => ctx.sync.data.config.tui?.diff_display ?? "full")

// Parse diff statistics
const diffStats = createMemo(() => {
if (!props.metadata.diff) return null
return parseDiffStats(props.metadata.diff)
})

// Expanded/collapsed state
const [expanded, setExpanded] = createSignal(false)

// Should show collapsed view
const showCollapsed = createMemo(() => diffDisplay() === "minimal" && !expanded())

const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
const arr = props.metadata.diagnostics?.[filePath] ?? []
Expand All @@ -1790,39 +1853,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
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}
/>
</box>
<Show when={diagnostics().length}>
<box>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
{diagnostic.message}
</text>
)}
</For>
<BlockTool
title={"← Edit " + normalizePath(props.input.filePath!)}
part={props.part}
onClick={() => {
if (diffDisplay() === "minimal") {
setExpanded(!expanded())
}
}}
>
<Show
when={showCollapsed()}
fallback={
// Full diff display (when expanded or full mode)
<>
<box paddingLeft={1}>
<diff
diff={diffContent()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
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}
/>
</box>
<Show when={diagnostics().length}>
<box>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
{diagnostic.message}
</text>
)}
</For>
</box>
</Show>
</>
}
>
{/* Collapsed view */}
<box paddingLeft={3} paddingTop={1} paddingBottom={1}>
<text fg={theme.textMuted}>
{formatDiffStats(diffStats()!)} [press Enter to expand]
</text>
</box>
<Show when={diagnostics().length}>
<box paddingLeft={1}>
<For each={diagnostics()}>
{(diagnostic) => (
<text fg={theme.error}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
{diagnostic.message}
</text>
)}
</For>
</box>
</Show>
</Show>
</BlockTool>
</Match>
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Diff utility functions for TUI display
*/

/**
* Parse unified diff format to extract line statistics
* @param diff - Unified diff string
* @returns Object with added and removed line counts
* @example
* parseDiffStats(`
* --- a/file.ts
* +++ b/file.ts
* @@ -1,3 +1,5 @@
* const x = 1
* +const y = 2
* +const z = 3
* `)
* // Returns: { added: 2, removed: 0 }
*/
export function parseDiffStats(diff: string): { added: number; removed: number } {
const lines = diff.split("\n")
let added = 0
let removed = 0

for (const line of lines) {
if (line.startsWith("+") && !line.startsWith("+++")) added++
else if (line.startsWith("-") && !line.startsWith("---")) removed++
}

return { added, removed }
}

/**
* Format diff statistics for display
* @param stats - Object with added and removed line counts
* @returns Formatted string like "+5 lines" or "+5, -3 lines"
* @example
* formatDiffStats({ added: 5, removed: 3 }) // "+5, -3 lines"
* formatDiffStats({ added: 0, removed: 3 }) // "-3 lines"
*/
export function formatDiffStats(stats: { added: number; removed: number }): string {
const parts: string[] = []
if (stats.added > 0) parts.push(`+${stats.added}`)
if (stats.removed > 0) parts.push(`-${stats.removed}`)

if (parts.length === 0) return "0 lines"
return parts.join(", ") + " lines"
}
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,10 @@ export namespace Config {
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
diff_display: z
.enum(["full", "minimal"])
.optional()
.describe("Control diff display mode: 'full' shows complete diff, 'minimal' shows collapsed by default with line statistics"),
})

export const Server = z
Expand Down
98 changes: 98 additions & 0 deletions packages/opencode/test/cli/cmd/tui/util/diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, test, expect } from "bun:test"
import { parseDiffStats, formatDiffStats } from "@/cli/cmd/tui/util/diff"

describe("diff utility functions", () => {
describe("parseDiffStats", () => {
test("parses added lines", () => {
const diff = `
--- a/test.ts
+++ b/test.ts
@@ -1,3 +1,5 @@
const x = 1
+const y = 2
+const z = 3
`
expect(parseDiffStats(diff)).toEqual({ added: 2, removed: 0 })
})

test("parses removed lines", () => {
const diff = `
--- a/test.ts
+++ b/test.ts
@@ -1,3 +1,2 @@
const x = 1
-const y = 2
`
expect(parseDiffStats(diff)).toEqual({ added: 0, removed: 1 })
})

test("parses mixed changes", () => {
const diff = `
--- a/test.ts
+++ b/test.ts
@@ -1,5 +1,6 @@
const x = 1
-const old = 2
+const new = 2
+const extra = 3
`
expect(parseDiffStats(diff)).toEqual({ added: 2, removed: 1 })
})

test("handles empty diff", () => {
expect(parseDiffStats("")).toEqual({ added: 0, removed: 0 })
})

test("ignores diff headers", () => {
const diff = `
--- a/test.ts
+++ b/test.ts
@@ -1,3 +1,5 @@
-old line
+new line
`
expect(parseDiffStats(diff)).toEqual({ added: 1, removed: 1 })
})

test("handles multi-hunk diff", () => {
const diff = `
--- a/test.ts
+++ b/test.ts
@@ -1,3 +1,4 @@
const x = 1
+const y = 2
@@ -5,3 +6,5 @@
const a = 1
+const b = 2
-const c = 3
`
expect(parseDiffStats(diff)).toEqual({ added: 2, removed: 1 })
})
})

describe("formatDiffStats", () => {
test("formats added lines", () => {
expect(formatDiffStats({ added: 5, removed: 0 })).toBe("+5 lines")
})

test("formats removed lines", () => {
expect(formatDiffStats({ added: 0, removed: 3 })).toBe("-3 lines")
})

test("formats mixed changes", () => {
expect(formatDiffStats({ added: 5, removed: 3 })).toBe("+5, -3 lines")
})

test("formats no changes", () => {
expect(formatDiffStats({ added: 0, removed: 0 })).toBe("0 lines")
})

test("formats single added line", () => {
expect(formatDiffStats({ added: 1, removed: 0 })).toBe("+1 lines")
})

test("formats single removed line", () => {
expect(formatDiffStats({ added: 0, removed: 1 })).toBe("-1 lines")
})
})
})