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
22 changes: 21 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
Expand Down Expand Up @@ -115,6 +116,25 @@ export function tui(input: {
resolve()
}

// Fetch config to determine kitty keyboard mode
let useKittyKeyboard: boolean | Record<string, boolean> = {}
try {
const sdk = createOpencodeClient({
baseUrl: input.url,
directory: input.directory,
fetch: input.fetch,
})
const config = await sdk.config.get()
const kittyMode = config.data?.tui?.kitty_keyboard ?? "auto"
console.log("[Kitty Keyboard Config] Raw mode from server:", kittyMode)
useKittyKeyboard =
kittyMode === "disabled" ? false : kittyMode === "enabled" ? true : {}
console.log("[Kitty Keyboard Config] Mapped to useKittyKeyboard:", useKittyKeyboard)
} catch (error) {
// If config fetch fails, use default (auto)
console.warn("[Kitty Keyboard Config] Failed to fetch config, using default 'auto'", error)
}

render(
() => {
return (
Expand Down Expand Up @@ -166,7 +186,7 @@ export function tui(input: {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
useKittyKeyboard: useKittyKeyboard as any,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Expand Down
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"
}
9 changes: 9 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,15 @@ 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"),
kitty_keyboard: z
.enum(["auto", "enabled", "disabled"])
.optional()
.default("auto")
.describe("Kitty keyboard protocol mode: 'auto' for best effort, 'enabled' for modern terminals (Kitty, iTerm2, WezTerm), 'disabled' for older terminals"),
})

export const Server = z
Expand Down
Loading