From c814f84c87e9acb647b4c88047a26f5f49217979 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 15:56:10 -0400 Subject: [PATCH] fix(tui): update tool spacing before layout --- .../tui/feature-plugins/system/session-v2.tsx | 17 ++---- .../src/cli/cmd/tui/routes/session/index.tsx | 40 +++++++------- .../opencode/src/cli/cmd/tui/util/layout.ts | 25 +++++++++ .../tui/inline-tool-wrap-snapshot.test.tsx | 52 +++++++++++++++++-- 4 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/layout.ts diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 368a829534..a169d0c760 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -30,6 +30,7 @@ import type { } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" import { collapseToolOutput } from "../../util/collapse-tool-output" +import { setPreLayoutSiblingMargin } from "../../util/layout" const id = "internal:session-v2-debug" const route = "session.v2.messages" @@ -357,7 +358,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta const { theme } = useTheme() return ( - + (props.part.state.status === "error" ? props.part.state.error.message : undefined)) @@ -592,7 +592,6 @@ function InlineTool(props: { const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) return ( !prev) }} - renderBefore={function () { - const el = this as BoxRenderable - const parent = el.parent - if (!parent) return - const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.id.startsWith("text")) setMargin(1) + ref={(el: BoxRenderable) => { + setPreLayoutSiblingMargin(el, (previous) => (previous?.id.startsWith("text-") ? 1 : 0)) }} > 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 131303269d..60f2e6658b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -80,6 +80,7 @@ import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" +import { setPreLayoutSiblingMargin } from "../../util/layout" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" @@ -168,6 +169,7 @@ const context = createContext<{ showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean + userMessageIDs: () => ReadonlySet diffWrapMode: () => "word" | "none" providers: () => ReadonlyMap sync: ReturnType @@ -209,6 +211,14 @@ export function Session() { ), ), ) + const userMessageIDs = createMemo( + () => + new Set( + messages() + .filter((message) => message.role === "user") + .map((message) => message.id), + ), + ) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -1160,6 +1170,7 @@ export function Session() { showTimestamps, showDetails, showGenericToolOutput, + userMessageIDs, diffWrapMode, providers, sync, @@ -1895,9 +1906,7 @@ function InlineTool(props: { pending={props.pending} spinner={props.spinner} subagent={props.subagent} - separateAfter={(id) => - sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false - } + separateAfter={(id) => id !== undefined && ctx.userMessageIDs().has(id)} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1934,35 +1943,24 @@ export function InlineToolRow(props: { onMouseOut?: () => void onMouseUp?: () => void }) { - const [margin, setMargin] = createSignal(0) - return ( { + setPreLayoutSiblingMargin(el, (previous) => { + const previousInline = previous?.id.startsWith("tool-inline-") ?? false + const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false + return previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") || (previousInline && previousSubagent !== Boolean(props.subagent)) || props.separateAfter?.(previous?.id) ? 1 - : 0, - ) + : 0 + }) }} > diff --git a/packages/opencode/src/cli/cmd/tui/util/layout.ts b/packages/opencode/src/cli/cmd/tui/util/layout.ts new file mode 100644 index 0000000000..e5e4410a8a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/layout.ts @@ -0,0 +1,25 @@ +import type { BaseRenderable, BoxRenderable } from "@opentui/core" + +const previousByParent = new WeakMap< + BaseRenderable, + { frameID: number; previous: WeakMap } +>() + +export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) { + // Run before Yoga layout so scroll geometry matches the rendered frame. + el.onLifecyclePass = () => { + const parent = el.parent + if (!parent) return + const cached = previousByParent.get(parent) + const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId) + const value = margin(previous.get(el)) + if (el.marginTop !== value) el.marginTop = value + } +} + +function previousSiblings(parent: BaseRenderable, frameID: number) { + const previous = new WeakMap() + parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1])) + previousByParent.set(parent, { frameID, previous }) + return previous +} diff --git a/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 0171e13e32..b018c419fb 100644 --- a/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" -import { For } from "solid-js" +import { createSignal, For, Show } from "solid-js" +import type { ScrollBoxRenderable } from "@opentui/core" import { testRender, type JSX } from "@opentui/solid" import { formatCompletedSubagentDetail, @@ -127,11 +128,30 @@ function LoadedReadBeforeSubagentFixture() { ) } +function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) { + return ( + + + First row + + + Second row + + + + Assistant text + + + + Read src/cli/cmd/tui/routes/session/index.tsx + + + ) +} + async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { testSetup = await testRender(component, options) await testSetup.renderOnce() - await Bun.sleep(25) - await testSetup.renderOnce() return testSetup .captureCharFrame() @@ -183,4 +203,30 @@ describe("TUI inline tool wrapping", () => { test("separates a subagent group after an expanded read", async () => { expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() }) + + test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => { + const [separated, setSeparated] = createSignal(false) + let scroll: ScrollBoxRenderable | undefined + testSetup = await testRender( + () => (scroll = value)} />, + { + width: 72, + height: 3, + }, + ) + + await testSetup.renderOnce() + expect(scroll?.scrollHeight).toBe(3) + expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) + + setSeparated(true) + await testSetup.renderOnce() + expect(scroll?.scrollHeight).toBe(5) + expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) + + setSeparated(false) + await testSetup.renderOnce() + expect(scroll?.scrollHeight).toBe(3) + expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) + }) })