diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1503e37d99e..9f8397f9e6e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -9,6 +9,7 @@ import { Show, Switch, useContext, + type Component, } from "solid-js" import { Dynamic } from "solid-js/web" import path from "path" @@ -22,7 +23,6 @@ import { addDefaultParsers, MacOSScrollAccel, type ScrollAcceleration, - TextAttributes, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" @@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "@tui/context/keybind" @@ -67,9 +67,7 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { PermissionPrompt } from "./permission" import { DialogExportOptions } from "../../ui/dialog-export-options" -import { formatTranscript } from "../../util/transcript" addDefaultParsers(parsers.parsers) @@ -85,12 +83,13 @@ class CustomSpeedScroll implements ScrollAcceleration { const context = createContext<{ width: number - sessionID: string conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean usernameVisible: () => boolean showDetails: () => boolean + dynamicDetails: () => boolean + userMessageMarkdown: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType }>() @@ -137,7 +136,9 @@ export function Session() { const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_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") const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) @@ -188,6 +189,28 @@ export function Session() { } }) + // Auto-navigate to whichever session currently needs permission input + createEffect(() => { + const currentSession = session() + if (!currentSession) return + const currentPermissions = permissions() + let targetID = currentPermissions.length > 0 ? currentSession.id : undefined + + if (!targetID) { + const child = sync.data.session.find( + (x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0, + ) + if (child) targetID = child.id + } + + if (targetID && targetID !== currentSession.id) { + navigate({ + type: "session", + sessionID: targetID, + }) + } + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -247,6 +270,29 @@ export function Session() { dialog.clear() } + useKeyboard((evt) => { + if (dialog.stack.length > 0) return + + const first = permissions()[0] + if (first) { + const response = iife(() => { + if (evt.ctrl || evt.meta) return + if (evt.name === "return") return "once" + if (evt.name === "a") return "always" + if (evt.name === "d") return "reject" + if (evt.name === "escape") return "reject" + return + }) + if (response) { + sdk.client.permission.respond({ + permissionID: first.id, + sessionID: route.sessionID, + response: response, + }) + } + } + }) + function toBottom() { setTimeout(() => { if (scroll) scroll.scrollTo(scroll.scrollHeight) @@ -256,14 +302,18 @@ export function Session() { const local = useLocal() function moveChild(direction: number) { - if (children().length === 1) return - let next = children().findIndex((x) => x.id === session()?.id) + direction - if (next >= children().length) next = 0 - if (next < 0) next = children().length - 1 - if (children()[next]) { + const parentID = session()?.parentID ?? session()?.id + let children = sync.data.session + .filter((x) => x.parentID === parentID || x.id === parentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + if (children.length === 1) return + let next = children.findIndex((x) => x.id === session()?.id) + direction + if (next >= children.length) next = 0 + if (next < 0) next = children.length - 1 + if (children[next]) { navigate({ type: "session", - sessionID: children()[next].id, + sessionID: children[next].id, }) } } @@ -529,6 +579,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", @@ -543,6 +604,19 @@ export function Session() { dialog.clear() }, }, + { + title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown", + value: "session.toggle.user_message_markdown", + category: "Session", + onSelect: (dialog) => { + setUserMessageMarkdown((prev) => { + const next = !prev + kv.set("user_message_markdown", next) + return next + }) + dialog.clear() + }, + }, { title: animationsEnabled() ? "Disable animations" : "Enable animations", value: "session.toggle.animations", @@ -724,18 +798,48 @@ export function Session() { category: "Session", onSelect: async (dialog) => { try { + // Format session transcript as markdown const sessionData = session() if (!sessionData) return const sessionMessages = messages() - const transcript = formatTranscript( - sessionData, - sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), - { - thinking: showThinking(), - toolDetails: showDetails(), - assistantMetadata: showAssistantMetadata(), - }, - ) + + let transcript = `# ${sessionData.title}\n\n` + transcript += `**Session ID:** ${sessionData.id}\n` + transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` + transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` + transcript += `---\n\n` + + for (const msg of sessionMessages) { + const parts = sync.data.part[msg.id] ?? [] + const role = msg.role === "user" ? "User" : "Assistant" + transcript += `## ${role}\n\n` + + for (const part of parts) { + if (part.type === "text" && !part.synthetic) { + transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (showThinking()) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } + } else if (part.type === "tool") { + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (showDetails() && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (showDetails() && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (showDetails() && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` + } + } + + transcript += `---\n\n` + } + + // Copy to clipboard await Clipboard.copy(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch (error) { @@ -745,12 +849,13 @@ export function Session() { }, }, { - title: "Export session transcript", + title: "Export session transcript to file", value: "session.export", keybind: "session_export", category: "Session", onSelect: async (dialog) => { try { + // Format session transcript as markdown const sessionData = session() if (!sessionData) return const sessionMessages = messages() @@ -768,34 +873,59 @@ export function Session() { if (options === null) return - const transcript = formatTranscript( - sessionData, - sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), - { - thinking: options.thinking, - toolDetails: options.toolDetails, - assistantMetadata: options.assistantMetadata, - }, - ) + const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options + + let transcript = `# ${sessionData.title}\n\n` + transcript += `**Session ID:** ${sessionData.id}\n` + transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` + transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` + transcript += `---\n\n` + + for (const msg of sessionMessages) { + const parts = sync.data.part[msg.id] ?? [] + const role = msg.role === "user" ? "User" : "Assistant" + transcript += `## ${role}\n\n` + + for (const part of parts) { + if (part.type === "text" && !part.synthetic) { + transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (includeThinking) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } + } else if (part.type === "tool") { + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (includeToolDetails && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` + } + } - if (options.openWithoutSaving) { - // Just open in editor without saving - await Editor.open({ value: transcript, renderer }) - } else { - const exportDir = process.cwd() - const filename = options.filename.trim() - const filepath = path.join(exportDir, filename) + transcript += `---\n\n` + } - await Bun.write(filepath, transcript) + // Save to file in current working directory + const exportDir = process.cwd() + const filename = customFilename.trim() + const filepath = path.join(exportDir, filename) - // Open with EDITOR if available - const result = await Editor.open({ value: transcript, renderer }) - if (result !== undefined) { - await Bun.write(filepath, result) - } + await Bun.write(filepath, transcript) - toast.show({ message: `Session exported to ${filename}`, variant: "success" }) + // Open with EDITOR if available + const result = await Editor.open({ value: transcript, renderer }) + if (result !== undefined) { + // User edited the file, save the changes + await Bun.write(filepath, result) } + + toast.show({ message: `Session exported to ${filename}`, variant: "success" }) } catch (error) { toast.show({ message: "Failed to export session", variant: "error" }) } @@ -902,12 +1032,13 @@ export function Session() { get width() { return contentWidth() }, - sessionID: route.sessionID, conceal, showThinking, showTimestamps, usernameVisible, showDetails, + dynamicDetails, + userMessageMarkdown, diffWrapMode, sync, }} @@ -1033,9 +1164,6 @@ export function Session() { - 0}> - - { @@ -1085,7 +1213,7 @@ function UserMessage(props: { const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const sync = useSync() - const { theme } = useTheme() + const { theme, syntax } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) @@ -1116,7 +1244,22 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()?.text} + + + + + + {text()?.text} + + @@ -1310,77 +1453,213 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess // Pending messages moved to individual tool pending functions function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { + const { theme } = useTheme() + const { showDetails, dynamicDetails } = use() const sync = useSync() + const [margin, setMargin] = createSignal(0) + const [collapsed, setCollapsed] = createSignal(true) - const toolprops = { - get metadata() { - return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) - }, - get input() { - return props.part.state.input ?? {} - }, - get output() { - return props.part.state.status === "completed" ? props.part.state.output : undefined - }, - get permission() { - const permissions = sync.data.permission[props.message.sessionID] ?? [] - const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID) - return permissions[permissionIndex] - }, - get tool() { - return props.part.tool - }, - get part() { - return props.part - }, - } + // 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) - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) + // Collapse logic - memoized at component level + const container = ToolRegistry.container(props.part.tool) + + // Calculate raw line count for initial collapse decision + const totalLines = createMemo(() => { + if (container !== "block" || !dynamicDetails()) return 0 + + const status = props.part.state.status + const mapping = toolmap.find((t) => t.name === props.part.tool) + + // Skip for tools that opt out of dynamic details + if (mapping?.dynamic === false) return 0 + + // Use toolmap for configured tools + if (mapping && status === "completed") { + let total = 0 + for (const field of mapping.fields) { + const value = getFieldValue(props.part.state as Record, field.path) + if (typeof value === "string") { + total += value.trimEnd().split("\n").length + } + } + return total + } + + // Default: check output for tools not in toolmap + if (status === "completed" && props.part.state.output) { + return props.part.state.output.split("\n").length + } + + return 0 + }) + + const shouldCollapse = createMemo(() => totalLines() > maxLines()) + const [visualLines, setVisualLines] = createSignal(0) + + 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 + const shouldHide = + !showDetails() && + props.part.state.status === "completed" && + !sync.data.permission[props.message.sessionID]?.some((x) => x.tool?.callID === props.part.callID) + + if (shouldHide) { + return undefined + } + + const render = ToolRegistry.render(props.part.tool) ?? GenericTool + + // Process tool fields (stripAnsi, trimEnd) based on toolmap config + const processedState = + props.part.state.status === "pending" + ? props.part.state + : processToolFields(props.part.tool, props.part.state as Record) + + const metadata = + props.part.state.status === "pending" ? {} : ((processedState as Record).metadata ?? {}) + const input = (processedState as Record).input ?? {} + const rawOutput = + props.part.state.status === "completed" + ? ((processedState as Record).output as string | undefined) + : undefined + + const permissions = sync.data.permission[props.message.sessionID] ?? [] + const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID) + const permission = permissions[permissionIndex] + + const style: BoxProps = + container === "block" || permission + ? { + border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const), + paddingTop: 1, + paddingBottom: 1, + paddingLeft: 2, + marginTop: 1, + gap: 1, + backgroundColor: theme.backgroundPanel, + customBorderChars: SplitBorder.customBorderChars, + borderColor: permissionIndex === 0 ? theme.warning : theme.background, + } + : { + paddingLeft: 3, + } + + const handleToggle = () => { + if (!shouldCollapse()) return + setCollapsed(!collapsed()) + } + + return ( + 1) { + setMargin(1) + } else { + const children = parent.getChildren() + const index = children.indexOf(el) + const previous = children[index - 1] + if (!previous) { + setMargin(0) + } else if (previous.height > 1 || previous.id.startsWith("text-")) { + setMargin(1) + } + } + // Calculate visual lines from text children + if (shouldCollapse()) { + const countVisualLines = (node: BoxRenderable): number => { + const children = node.getChildren() + // Check if this is a split diff view (has view="split" property) + const isSplitView = "view" in node && node.view === "split" + let maxChildLines = 0 + let sumChildLines = 0 + for (const child of children) { + let childLines = 0 + if ("virtualLineCount" in child && typeof child.virtualLineCount === "number") { + childLines = child.virtualLineCount + } + if ("getChildren" in child && typeof child.getChildren === "function") { + childLines = Math.max(childLines, countVisualLines(child as BoxRenderable)) + } + maxChildLines = Math.max(maxChildLines, childLines) + sumChildLines += childLines + } + // For split diff view, use max (left/right show same content) + // Otherwise sum all children + return isSplitView ? maxChildLines : sumChildLines + } + const total = countVisualLines(el) + if (total > 0) setVisualLines(total) + } + }} + > + + {shouldCollapse() && (visualLines() === 0 || visualLines() > maxLines()) && ( + + + + {collapsed() ? ( + <> + {showArrows() ? "▶ " : ""}Click to expand{" "} + (+{Math.max(1, visualLines() - maxLines())}) + + ) : ( + <>{showArrows() ? "▼ " : ""}Click to collapse + )} + + + + )} + {props.part.state.status === "error" && ( + + {props.part.state.error.replace("Error: ", "")} + + )} + {permission && ( + + Permission required to run this tool: + + + enter + accept + + + a + accept always + + + d + deny + + + + )} + + ) + }) + + return {component()} } type ToolProps = { @@ -1389,160 +1668,181 @@ type ToolProps = { permission: Record tool: string output?: string - part: ToolPart } function GenericTool(props: ToolProps) { return ( - + {props.tool} {input(props.input)} - + ) } -function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { - const { theme } = useTheme() - return ( - - ~ {props.fallback}} when={props.when}> - {props.icon} {props.children} - - - ) +type ToolRegistration = { + name: string + container: "inline" | "block" + render?: Component> +} +const ToolRegistry = (() => { + const state: Record = {} + function register(input: ToolRegistration) { + state[input.name] = input + return input + } + return { + register, + container(name: string) { + return state[name]?.container + }, + render(name: string) { + return state[name]?.render + }, + } +})() + +// Tool field mapping for dynamic details +// - path: dot-notation path to the field in part.state (e.g., "input.command", "output", "metadata.diff") +// - stripAnsi: whether to strip ANSI codes before processing (default false) +// - dynamic: whether to enable dynamic details for this tool (default true) +// Fields are used for both line counting (shouldCollapse) and display processing (trimEnd, stripAnsi) +const toolmap: Array<{ name: string; dynamic?: boolean; fields: Array<{ path: string; stripAnsi?: boolean }> }> = [ + { name: "bash", fields: [{ path: "input.command" }, { path: "metadata.output", stripAnsi: true }] }, + { name: "edit", fields: [{ path: "metadata.diff" }] }, + { name: "write", fields: [{ path: "input.content" }] }, + { name: "patch", fields: [{ path: "output" }] }, + { name: "todowrite", dynamic: false, fields: [] }, +] + +function getFieldValue(state: Record, path: string): string | undefined { + const parts = path.split(".") + let value: unknown = state + for (const part of parts) { + if (value && typeof value === "object" && part in value) { + value = (value as Record)[part] + } else { + return undefined + } + } + return typeof value === "string" ? value : undefined } -function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) { - const [margin, setMargin] = createSignal(0) - const { theme } = useTheme() - const ctx = use() - const sync = useSync() +function setFieldValue(state: Record, path: string, value: string): void { + const parts = path.split(".") + let current: Record = state + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + if (!(part in current) || typeof current[part] !== "object") { + current[part] = {} + } + current = current[part] as Record + } + current[parts[parts.length - 1]] = value +} - const permission = createMemo(() => { - const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID - if (!callID) return false - return callID === props.part.callID - }) +// Process tool state fields based on toolmap config (stripAnsi, trimEnd) +function processToolFields(tool: string, state: Record): Record { + const mapping = toolmap.find((t) => t.name === tool) - const fg = createMemo(() => { - if (permission()) return theme.warning - if (props.complete) return theme.textMuted - return theme.text - }) + // Skip processing for tools that opt out of dynamic details + if (mapping?.dynamic === false) return state - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) + const result = JSON.parse(JSON.stringify(state)) as Record - const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) + if (mapping) { + for (const field of mapping.fields) { + const value = getFieldValue(result, field.path) + if (typeof value === "string") { + let processed = value + if (field.stripAnsi) processed = stripAnsi(processed) + processed = processed.trimEnd() + setFieldValue(result, field.path, processed) + } + } + } else { + // Default: trimEnd output field + const output = getFieldValue(result, "output") + if (typeof output === "string") { + setFieldValue(result, "output", output.trimEnd()) + } + } - return ( - 1) { - setMargin(1) - return - } - const children = parent.getChildren() - const index = children.indexOf(el) - const previous = children[index - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.height > 1 || previous.id.startsWith("text-")) { - setMargin(1) - return - } - }} - > - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - - - {error()} - - - ) + return result } -function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) { +function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { const { theme } = useTheme() - const renderer = useRenderer() - const [hover, setHover] = createSignal(false) - const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( - props.onClick && setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - props.onClick?.() - }} - > - - {props.title} - - {props.children} - - {error()} + + ~ {props.fallback}} when={props.when}> + {props.icon} {props.children} - - ) -} - -function Bash(props: ToolProps) { - const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) - const { theme } = useTheme() - return ( - - - - - $ {props.input.command} - {output()} - - - - - - {props.input.command} - - - + ) } -function Write(props: ToolProps) { - const { theme, syntax } = useTheme() - const code = createMemo(() => { - if (!props.input.content) return "" - return props.input.content - }) - - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - return props.metadata.diagnostics?.[filePath] ?? [] - }) - - return ( - - - +// Bash tool uses a single for command + output combined. +// This is required for dynamic details collapsing to work properly - having +// separate elements causes yoga layout issues where children overlap instead +// of stacking when maxHeight/overflow:hidden is applied to the parent. +ToolRegistry.register({ + name: "bash", + container: "block", + render(props) { + const command = createMemo(() => props.input.command ?? "") + const output = createMemo(() => props.metadata.output ?? "") + const { theme } = useTheme() + return ( + <> + + {props.input.description || "Shell"} + + + + $ {command()} + {output() ? "\n" + output() : ""} + + + + ) + }, +}) + +ToolRegistry.register({ + name: "read", + container: "inline", + render(props) { + return ( + <> + + Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + + + ) + }, +}) + +ToolRegistry.register({ + name: "write", + container: "block", + render(props) { + const { theme, syntax } = useTheme() + const code = createMemo(() => { + if (!props.input.content) return "" + return props.input.content + }) + + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + return props.metadata.diagnostics?.[filePath] ?? [] + }) + + const done = !!props.input.filePath + + return ( + <> + + Wrote {props.input.filePath} + + ) { content={code()} /> - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} - - )} - - - - - - - Write {normalizePath(props.input.filePath!)} - - - - ) -} - -function Glob(props: ToolProps) { - return ( - - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.count} matches) - - ) -} - -function Read(props: ToolProps) { - return ( - - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} - - ) -} - -function Grep(props: ToolProps) { - return ( - - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.matches} matches) - - ) -} - -function List(props: ToolProps) { - const dir = createMemo(() => { - if (props.input.path) { - return normalizePath(props.input.path) - } - return "" - }) - return ( - - List {dir()} - - ) -} - -function WebFetch(props: ToolProps) { - return ( - - WebFetch {(props.input as any).url} - - ) -} - -function CodeSearch(props: ToolProps) { - const input = props.input as any - const metadata = props.metadata as any - return ( - - Exa Code Search "{input.query}" ({metadata.results} results) - - ) -} - -function WebSearch(props: ToolProps) { - const input = props.input as any - const metadata = props.metadata as any - return ( - - Exa Web Search "{input.query}" ({metadata.numResults} results) - - ) -} - -function Task(props: ToolProps) { - const { theme } = useTheme() - const keybind = useKeybind() - const { navigate } = useRoute() - - const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) - - return ( - - - navigate({ type: "session", sessionID: props.metadata.sessionId! }) - : undefined - } - part={props.part} - > - - - {props.input.description} ({props.metadata.summary?.length} toolcalls) - - - - └ {Locale.titlecase(current()!.tool)}{" "} - {current()!.state.status === "completed" ? current()!.state.title : ""} + + + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} - - - - {keybind.print("session_child_cycle")} - view subagents - - - - - + )} + + + + ) + }, +}) + +ToolRegistry.register({ + name: "glob", + container: "inline", + render(props) { + return ( + <> + + Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + ({props.metadata.count} matches) + + + ) + }, +}) + +ToolRegistry.register({ + name: "grep", + container: "inline", + render(props) { + return ( + + Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + ({props.metadata.matches} matches) + + ) + }, +}) + +ToolRegistry.register({ + name: "list", + container: "inline", + render(props) { + const dir = createMemo(() => { + if (props.input.path) { + return normalizePath(props.input.path) + } + return "" + }) + return ( + <> + + List {dir()} + + + ) + }, +}) + +ToolRegistry.register({ + name: "task", + container: "block", + render(props) { + const { theme } = useTheme() + const keybind = useKeybind() + const dialog = useDialog() + const renderer = useRenderer() + + return ( + <> + {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}" - - - - ) -} - -function Edit(props: ToolProps) { - const ctx = use() - const { theme, syntax } = useTheme() - - const view = createMemo(() => { - const diffStyle = ctx.sync.data.config.tui?.diff_style - if (diffStyle === "stacked") return "unified" - // Default to "auto" behavior - return ctx.width > 120 ? "split" : "unified" - }) - - const ft = createMemo(() => filetype(props.input.filePath)) - - const diffContent = createMemo(() => props.metadata.diff) - - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - const arr = props.metadata.diagnostics?.[filePath] ?? [] - return arr.filter((x) => x.severity === 1).slice(0, 3) - }) - - return ( - - - + + + + + {(task, index) => { + const summary = props.metadata.summary ?? [] + return ( + + {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "} + {task.state.status === "completed" ? task.state.title : ""} + + ) + }} + + + + + {keybind.print("session_child_cycle")} + view subagents + + + ) + }, +}) + +ToolRegistry.register({ + name: "webfetch", + container: "inline", + render(props) { + return ( + + WebFetch {(props.input as any).url} + + ) + }, +}) + +ToolRegistry.register({ + name: "codesearch", + container: "inline", + render(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Code Search "{input.query}" ({metadata.results} results) + + ) + }, +}) + +ToolRegistry.register({ + name: "websearch", + container: "inline", + render(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Exa Web Search "{input.query}" ({metadata.numResults} results) + + ) + }, +}) + +ToolRegistry.register({ + name: "edit", + container: "block", + render(props) { + const ctx = use() + const { theme, syntax } = useTheme() + + const view = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + // Default to "auto" behavior + return ctx.width > 120 ? "split" : "unified" + }) + + const ft = createMemo(() => filetype(props.input.filePath)) + + const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"]) + + const diagnostics = createMemo(() => { + const filePath = Filesystem.normalizePath(props.input.filePath ?? "") + const arr = props.metadata.diagnostics?.[filePath] ?? [] + return arr.filter((x) => x.severity === 1).slice(0, 3) + }) + + return ( + <> + + Edit {normalizePath(props.input.filePath!)}{" "} + {input({ + replaceAll: props.input.replaceAll, + })} + + ) { removedLineNumberBg={theme.diffRemovedLineNumberBg} /> - - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "} - {diagnostic.message} - - )} - - - - - - - - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} - - - - ) -} - -function Patch(props: ToolProps) { - const { theme } = useTheme() - return ( - - - + + - {props.output?.trim()} + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message} + + )} + - - - - + + + ) + }, +}) + +ToolRegistry.register({ + name: "patch", + container: "block", + render(props) { + const { theme } = useTheme() + return ( + <> + Patch - - - - ) -} - -function TodoWrite(props: ToolProps) { - return ( - - - + + + + {props.output} + + + + ) + }, +}) + +ToolRegistry.register({ + name: "todowrite", + container: "block", + render(props) { + const { theme } = useTheme() + return ( + <> + + + Updating todos... + + + {(todo) => } - - - - - Updating todos... - - - - ) -} + + + ) + }, +}) function normalizePath(input?: string) { if (!input) return "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8eafb92e815..4596b5aa00e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -665,6 +665,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 diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 431135db3d1..2a24b1ec7df 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1487,6 +1487,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 /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3e7bd5e08da..68365be5422 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8736,6 +8736,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" } } },