fix(tui): update tool spacing before layout
This commit is contained in:
@@ -30,6 +30,7 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||||
|
import { setPreLayoutSiblingMargin } from "../../util/layout"
|
||||||
|
|
||||||
const id = "internal:session-v2-debug"
|
const id = "internal:session-v2-debug"
|
||||||
const route = "session.v2.messages"
|
const route = "session.v2.messages"
|
||||||
@@ -357,7 +358,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
return (
|
return (
|
||||||
<Show when={props.part.text.trim()}>
|
<Show when={props.part.text.trim()}>
|
||||||
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
|
<box paddingLeft={3} marginTop={1} flexShrink={0} id={`text-${props.part.id}`}>
|
||||||
<code
|
<code
|
||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
@@ -569,7 +570,6 @@ function InlineTool(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [margin, setMargin] = createSignal(0)
|
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const [showError, setShowError] = createSignal(false)
|
const [showError, setShowError] = createSignal(false)
|
||||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
const error = createMemo(() => (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))
|
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
marginTop={margin()}
|
|
||||||
paddingLeft={3}
|
paddingLeft={3}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
@@ -605,16 +604,8 @@ function InlineTool(props: {
|
|||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
setShowError((prev) => !prev)
|
setShowError((prev) => !prev)
|
||||||
}}
|
}}
|
||||||
renderBefore={function () {
|
ref={(el: BoxRenderable) => {
|
||||||
const el = this as BoxRenderable
|
setPreLayoutSiblingMargin(el, (previous) => (previous?.id.startsWith("text-") ? 1 : 0))
|
||||||
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)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import { QuestionPrompt } from "./question"
|
|||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
import * as Model from "../../util/model"
|
import * as Model from "../../util/model"
|
||||||
import { formatTranscript } from "../../util/transcript"
|
import { formatTranscript } from "../../util/transcript"
|
||||||
|
import { setPreLayoutSiblingMargin } from "../../util/layout"
|
||||||
import { UI } from "@/cli/ui.ts"
|
import { UI } from "@/cli/ui.ts"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||||
@@ -168,6 +169,7 @@ const context = createContext<{
|
|||||||
showTimestamps: () => boolean
|
showTimestamps: () => boolean
|
||||||
showDetails: () => boolean
|
showDetails: () => boolean
|
||||||
showGenericToolOutput: () => boolean
|
showGenericToolOutput: () => boolean
|
||||||
|
userMessageIDs: () => ReadonlySet<string>
|
||||||
diffWrapMode: () => "word" | "none"
|
diffWrapMode: () => "word" | "none"
|
||||||
providers: () => ReadonlyMap<string, Provider>
|
providers: () => ReadonlyMap<string, Provider>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
@@ -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(() => {
|
const permissions = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return []
|
||||||
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||||
@@ -1160,6 +1170,7 @@ export function Session() {
|
|||||||
showTimestamps,
|
showTimestamps,
|
||||||
showDetails,
|
showDetails,
|
||||||
showGenericToolOutput,
|
showGenericToolOutput,
|
||||||
|
userMessageIDs,
|
||||||
diffWrapMode,
|
diffWrapMode,
|
||||||
providers,
|
providers,
|
||||||
sync,
|
sync,
|
||||||
@@ -1895,9 +1906,7 @@ function InlineTool(props: {
|
|||||||
pending={props.pending}
|
pending={props.pending}
|
||||||
spinner={props.spinner}
|
spinner={props.spinner}
|
||||||
subagent={props.subagent}
|
subagent={props.subagent}
|
||||||
separateAfter={(id) =>
|
separateAfter={(id) => id !== undefined && ctx.userMessageIDs().has(id)}
|
||||||
sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false
|
|
||||||
}
|
|
||||||
onMouseOver={() => clickable() && setHover(true)}
|
onMouseOver={() => clickable() && setHover(true)}
|
||||||
onMouseOut={() => setHover(false)}
|
onMouseOut={() => setHover(false)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
@@ -1934,35 +1943,24 @@ export function InlineToolRow(props: {
|
|||||||
onMouseOut?: () => void
|
onMouseOut?: () => void
|
||||||
onMouseUp?: () => void
|
onMouseUp?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [margin, setMargin] = createSignal(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
id={props.id}
|
id={props.id}
|
||||||
marginTop={margin()}
|
|
||||||
paddingLeft={3}
|
paddingLeft={3}
|
||||||
onMouseOver={props.onMouseOver}
|
onMouseOver={props.onMouseOver}
|
||||||
onMouseOut={props.onMouseOut}
|
onMouseOut={props.onMouseOut}
|
||||||
onMouseUp={props.onMouseUp}
|
onMouseUp={props.onMouseUp}
|
||||||
renderBefore={function () {
|
ref={(el: BoxRenderable) => {
|
||||||
const el = this as BoxRenderable
|
setPreLayoutSiblingMargin(el, (previous) => {
|
||||||
const parent = el.parent
|
const previousInline = previous?.id.startsWith("tool-inline-") ?? false
|
||||||
if (!parent) {
|
const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false
|
||||||
return
|
return previous?.id.startsWith("text-") ||
|
||||||
}
|
|
||||||
const children = parent.getChildren()
|
|
||||||
const index = children.indexOf(el)
|
|
||||||
const previous = children[index - 1]
|
|
||||||
const previousInline = previous?.id.startsWith("tool-inline-") ?? false
|
|
||||||
const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false
|
|
||||||
setMargin(
|
|
||||||
previous?.id.startsWith("text-") ||
|
|
||||||
previous?.id.startsWith("tool-block-") ||
|
previous?.id.startsWith("tool-block-") ||
|
||||||
(previousInline && previousSubagent !== Boolean(props.subagent)) ||
|
(previousInline && previousSubagent !== Boolean(props.subagent)) ||
|
||||||
props.separateAfter?.(previous?.id)
|
props.separateAfter?.(previous?.id)
|
||||||
? 1
|
? 1
|
||||||
: 0,
|
: 0
|
||||||
)
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { BaseRenderable, BoxRenderable } from "@opentui/core"
|
||||||
|
|
||||||
|
const previousByParent = new WeakMap<
|
||||||
|
BaseRenderable,
|
||||||
|
{ frameID: number; previous: WeakMap<BaseRenderable, BaseRenderable | undefined> }
|
||||||
|
>()
|
||||||
|
|
||||||
|
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<BaseRenderable, BaseRenderable | undefined>()
|
||||||
|
parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1]))
|
||||||
|
previousByParent.set(parent, { frameID, previous })
|
||||||
|
return previous
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
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 { testRender, type JSX } from "@opentui/solid"
|
||||||
import {
|
import {
|
||||||
formatCompletedSubagentDetail,
|
formatCompletedSubagentDetail,
|
||||||
@@ -127,11 +128,30 @@ function LoadedReadBeforeSubagentFixture() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
|
||||||
|
return (
|
||||||
|
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
|
||||||
|
<box height={1}>
|
||||||
|
<text>First row</text>
|
||||||
|
</box>
|
||||||
|
<box height={1}>
|
||||||
|
<text>Second row</text>
|
||||||
|
</box>
|
||||||
|
<Show when={props.separated}>
|
||||||
|
<box id="text-before-tool">
|
||||||
|
<text>Assistant text</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<InlineToolRow icon="→" complete={true} pending="">
|
||||||
|
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||||
|
</InlineToolRow>
|
||||||
|
</scrollbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
|
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
|
||||||
testSetup = await testRender(component, options)
|
testSetup = await testRender(component, options)
|
||||||
await testSetup.renderOnce()
|
await testSetup.renderOnce()
|
||||||
await Bun.sleep(25)
|
|
||||||
await testSetup.renderOnce()
|
|
||||||
|
|
||||||
return testSetup
|
return testSetup
|
||||||
.captureCharFrame()
|
.captureCharFrame()
|
||||||
@@ -183,4 +203,30 @@ describe("TUI inline tool wrapping", () => {
|
|||||||
test("separates a subagent group after an expanded read", async () => {
|
test("separates a subagent group after an expanded read", async () => {
|
||||||
expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { width: 72, height: 8 })).toMatchSnapshot()
|
expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { 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(
|
||||||
|
() => <StickyScrollFixture separated={separated()} scroll={(value) => (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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user