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
88 changes: 85 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const context = createContext<{
showTimestamps: () => boolean
usernameVisible: () => boolean
showDetails: () => boolean
dynamicDetails: () => boolean
userMessageMarkdown: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
Expand Down Expand Up @@ -124,6 +125,7 @@ export function Session() {
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [dynamicDetails, setDynamicDetails] = createSignal(kv.get("dynamic_details", false))
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
Expand Down Expand Up @@ -557,6 +559,17 @@ export function Session() {
dialog.clear()
},
},
{
title: dynamicDetails() ? "Disable dynamic details" : "Enable dynamic details",
value: "session.toggle.dynamic_details",
category: "Session",
onSelect: (dialog) => {
const newValue = !dynamicDetails()
setDynamicDetails(newValue)
kv.set("dynamic_details", newValue)
dialog.clear()
},
},
{
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
Expand Down Expand Up @@ -995,6 +1008,7 @@ export function Session() {
showTimestamps,
usernameVisible,
showDetails,
dynamicDetails,
userMessageMarkdown,
diffWrapMode,
sync,
Expand Down Expand Up @@ -1398,9 +1412,50 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess

function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
const { theme } = useTheme()
const { showDetails } = use()
const { showDetails, dynamicDetails } = use()
const sync = useSync()
const [margin, setMargin] = createSignal(0)
const [collapsed, setCollapsed] = createSignal(true)

// Config values - memoized at component level
const maxLines = createMemo(() => sync.data.config.tui?.dynamic_details_max_lines ?? 15)
const showArrows = createMemo(() => sync.data.config.tui?.dynamic_details_show_arrows ?? true)

// Collapse logic - memoized at component level
const container = ToolRegistry.container(props.part.tool)
const shouldCollapse = createMemo(() => {
if (container !== "block" || !dynamicDetails()) return false

const threshold = maxLines()
const status = props.part.state.status

// For tools with output (bash, patch), check output length
if (status === "completed" && props.part.state.output) {
const lines = props.part.state.output.split("\n")
return lines.length > threshold
}

// For write tool, check content length
if (props.part.tool === "write") {
const content = (props.part.state.input as any)?.content
if (typeof content === "string") {
const lines = content.split("\n")
return lines.length > threshold
}
}

// For edit tool, check diff length
if (props.part.tool === "edit" && status !== "pending") {
const diff = (props.part.state.metadata as any)?.diff
if (typeof diff === "string") {
const lines = diff.split("\n")
return lines.length > threshold
}
}

return false
})

const component = createMemo(() => {
// Hide tool if showDetails is false and tool completed successfully
// But always show if there's an error or permission is required
Expand All @@ -1417,7 +1472,10 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess

const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input ?? {}
const container = ToolRegistry.container(props.part.tool)

// Dynamic details collapsing
const rawOutput = props.part.state.status === "completed" ? props.part.state.output : undefined

const permissions = sync.data.permission[props.message.sessionID] ?? []
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
const permission = permissions[permissionIndex]
Expand All @@ -1439,10 +1497,19 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
paddingLeft: 3,
}

const handleToggle = () => {
if (!shouldCollapse()) return
setCollapsed(!collapsed())
}

return (
<box
marginTop={margin()}
{...style}
maxHeight={shouldCollapse() && collapsed() ? maxLines() + 2 : undefined}
overflow={shouldCollapse() && collapsed() ? "hidden" : undefined}
justifyContent={shouldCollapse() && collapsed() ? "flex-start" : undefined}
onMouseUp={handleToggle}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
Expand Down Expand Up @@ -1472,8 +1539,23 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
tool={props.part.tool}
metadata={metadata}
permission={permission?.metadata ?? {}}
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
output={rawOutput}
/>
{shouldCollapse() && (
<box flexDirection="row">
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted}>
{collapsed()
? showArrows()
? "▶ Click to expand"
: "Click to expand"
: showArrows()
? "▼ Click to collapse"
: "Click to collapse"}
</text>
</box>
</box>
)}
{props.part.state.status === "error" && (
<box paddingLeft={2}>
<text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,18 @@ export namespace Config {
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
dynamic_details_max_lines: z
.number()
.int()
.min(1)
.optional()
.default(15)
.describe("Max lines before tool output becomes collapsible (default: 15)"),
dynamic_details_show_arrows: z
.boolean()
.optional()
.default(true)
.describe("Show arrow indicators on collapsible tool outputs (default: true)"),
})

export const Server = z
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,14 @@ export type Config = {
* Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column
*/
diff_style?: "auto" | "stacked"
/**
* Max lines before tool output becomes collapsible (default: 15)
*/
dynamic_details_max_lines?: number
/**
* Show arrow indicators on collapsible tool outputs (default: true)
*/
dynamic_details_show_arrows?: boolean
}
server?: ServerConfig
/**
Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8267,6 +8267,18 @@
"description": "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
"type": "string",
"enum": ["auto", "stacked"]
},
"dynamic_details_max_lines": {
"description": "Max lines before tool output becomes collapsible (default: 15)",
"default": 15,
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"dynamic_details_show_arrows": {
"description": "Show arrow indicators on collapsible tool outputs (default: true)",
"default": true,
"type": "boolean"
}
}
},
Expand Down