From e38bd06c9571f3aebba3edd8cd3018069cf85a8d Mon Sep 17 00:00:00 2001 From: Brent Lopez Date: Sat, 3 Jan 2026 18:17:37 -0800 Subject: [PATCH] feat: add collapsible tool output blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add collapsed state management to session context - BlockTool now supports collapse/expand with clickable arrow (▶/▼) - Add tui.block.toggle command to event system - Add tui.block.collapsed hook for plugin integration - All BlockTool usages updated with blockID for state tracking Enables Warp-like block UX where users can collapse verbose tool outputs. Plugins can now control block collapse behavior via the new hook. --- packages/opencode/src/cli/cmd/tui/event.ts | 7 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 66 +++++++++++++++---- packages/plugin/src/index.ts | 8 +++ 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 7c75523c136..a1cb2656287 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -43,4 +43,11 @@ export const TuiEvent = { sessionID: z.string().regex(/^ses/).describe("Session ID to navigate to"), }), ), + BlockToggle: BusEvent.define( + "tui.block.toggle", + z.object({ + blockID: z.string().describe("Block ID to toggle collapse state"), + collapsed: z.boolean().optional().describe("Force collapse state, or toggle if omitted"), + }), + ), } 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 d049ec4373c..fd6c41a3b2f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -92,6 +92,8 @@ const context = createContext<{ showDetails: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType + collapsedBlocks: () => Set + toggleBlockCollapse: (blockID: string, collapsed?: boolean) => void }>() function use() { @@ -881,6 +883,20 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() + const [collapsedBlocks, setCollapsedBlocks] = createSignal>(new Set()) + const toggleBlockCollapse = (blockID: string, collapsed?: boolean) => { + setCollapsedBlocks((prev) => { + const next = new Set(prev) + const shouldCollapse = collapsed ?? !next.has(blockID) + if (shouldCollapse) { + next.add(blockID) + } else { + next.delete(blockID) + } + return next + }) + } + // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) @@ -898,6 +914,8 @@ export function Session() { showDetails, diffWrapMode, sync, + collapsedBlocks, + toggleBlockCollapse, }} > @@ -1447,34 +1465,53 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child ) } -function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) { +function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart; blockID?: string }) { const { theme } = useTheme() const renderer = useRenderer() + const ctx = useContext(context) const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + const collapsed = createMemo(() => props.blockID ? ctx?.collapsedBlocks().has(props.blockID) : false) + const toggleCollapse = () => { + if (props.blockID && ctx?.toggleBlockCollapse) { + ctx.toggleBlockCollapse(props.blockID) + } + } return ( props.onClick && setHover(true)} + onMouseOver={() => (props.onClick || props.blockID) && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return props.onClick?.() }} > - - {props.title} - - {props.children} - + + + {props.title} + + + + {collapsed() ? "▶" : "▼"} + + + + + {props.children} + + + (collapsed) + + {error()} @@ -1487,7 +1524,7 @@ function Bash(props: ToolProps) { return ( - + $ {props.input.command} {output()} @@ -1518,7 +1555,7 @@ function Write(props: ToolProps) { return ( - + ) { : undefined } part={props.part} + blockID={`task-${props.part.id}`} > @@ -1690,7 +1728,7 @@ function Edit(props: ToolProps) { return ( - + ) { return ( - + {props.output?.trim()} @@ -1759,7 +1797,7 @@ function TodoWrite(props: ToolProps) { return ( - + {(todo) => } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 5653f19d912..5225f2eee78 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -207,4 +207,12 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Called when a block's collapse state changes. Allows plugins to control + * block collapse/expand behavior. + */ + "tui.block.collapsed"?: ( + input: { sessionID: string; blockID: string }, + output: { collapsed: boolean }, + ) => Promise }