Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
This commit is contained in:
@@ -1,6 +1,107 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolRenderer, ToolScrollHelpers } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
import { tGlobal } from "../../../lib/i18n"
|
||||
import { createStableAnsiStreamUpdater } from "../ansi-render"
|
||||
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
|
||||
|
||||
function RunningBashOutput(props: {
|
||||
content: Accessor<string>
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}) {
|
||||
let preRef: HTMLPreElement | undefined
|
||||
const updater = createStableAnsiStreamUpdater()
|
||||
|
||||
createEffect(() => {
|
||||
const element = preRef
|
||||
if (!element) return
|
||||
updater.update(element, props.content())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
preRef = undefined
|
||||
updater.reset()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown"
|
||||
ref={props.scrollHelpers?.registerContainer}
|
||||
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||
>
|
||||
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
|
||||
{props.scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolBody(props: {
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}) {
|
||||
const state = createMemo(() => props.toolState())
|
||||
|
||||
const joinedContent = createMemo(() => {
|
||||
const current = state()
|
||||
if (!current || current.status === "pending") return ""
|
||||
|
||||
const { input, metadata } = readToolStatePayload(current)
|
||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||
const outputResult = formatUnknown(
|
||||
isToolStateCompleted(current)
|
||||
? current.output
|
||||
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
|
||||
? metadata.output
|
||||
: undefined,
|
||||
)
|
||||
return [command, outputResult?.text].filter(Boolean).join("\n")
|
||||
})
|
||||
|
||||
const finalMarkdown = createMemo(() => {
|
||||
const current = state()
|
||||
const content = joinedContent()
|
||||
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (hasAnsi(content)) {
|
||||
return null
|
||||
}
|
||||
return ensureMarkdownContent(content, "bash", true)
|
||||
})
|
||||
|
||||
const finalAnsiHtml = createMemo(() => {
|
||||
const current = state()
|
||||
const content = joinedContent()
|
||||
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (!hasAnsi(content)) {
|
||||
return null
|
||||
}
|
||||
return ansiToHtml(content)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={state() && joinedContent().length > 0}>
|
||||
<Show
|
||||
when={state()?.status === "running"}
|
||||
fallback={
|
||||
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
|
||||
{(html) => (
|
||||
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
|
||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const bashRenderer: ToolRenderer = {
|
||||
tools: ["bash"],
|
||||
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
|
||||
const timeoutLabel = `${timeout}ms`
|
||||
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { input, metadata } = readToolStatePayload(state)
|
||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||
const outputResult = formatUnknown(
|
||||
isToolStateCompleted(state)
|
||||
? state.output
|
||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
||||
? metadata.output
|
||||
: undefined,
|
||||
)
|
||||
const parts = [command, outputResult?.text].filter(Boolean)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const joined = parts.join("\n")
|
||||
if (state.status === "running") {
|
||||
return renderAnsi({ content: joined, variant: "running" })
|
||||
}
|
||||
|
||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
||||
if (ansiBody) {
|
||||
return ansiBody
|
||||
}
|
||||
|
||||
const content = ensureMarkdownContent(joined, "bash", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content })
|
||||
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
|
||||
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user