Improve OpenTUI session rendering and tool display (has typecheck errors)

This commit is contained in:
Dax Raad 2025-09-20 18:27:59 -04:00
commit 0cc3d9229e

View file

@ -5,7 +5,7 @@ import { useRouteData } from "./context/route"
import { useSync } from "./context/sync" import { useSync } from "./context/sync"
import { SplitBorder } from "./component/border" import { SplitBorder } from "./component/border"
import { Theme } from "./context/theme" import { Theme } from "./context/theme"
import { hastToStyledText, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core" import { BoxRenderable, hastToStyledText, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core"
import { Prompt } from "./component/prompt" import { Prompt } from "./component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage } from "@opencode-ai/sdk" import type { AssistantMessage, Part, ToolPart, UserMessage } from "@opencode-ai/sdk"
import type { TextPart } from "ai" import type { TextPart } from "ai"
@ -25,7 +25,7 @@ import type { EditTool } from "../../../tool/edit"
import type { PatchTool } from "../../../tool/patch" import type { PatchTool } from "../../../tool/patch"
import type { WebFetchTool } from "../../../tool/webfetch" import type { WebFetchTool } from "../../../tool/webfetch"
import type { TaskTool } from "../../../tool/task" import type { TaskTool } from "../../../tool/task"
import { useKeyboard, type JSX } from "@opentui/solid" import { useKeyboard, type BoxProps, type JSX } from "@opentui/solid"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
export function Session() { export function Session() {
@ -39,8 +39,8 @@ export function Session() {
createEffect(() => sync.session.sync(route.sessionID)) createEffect(() => sync.session.sync(route.sessionID))
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "pageup") scroll.scrollBy(-scroll.height) if (evt.name === "pageup") scroll.scrollBy(-scroll.height / 2)
if (evt.name === "pagedown") scroll.scrollBy(scroll.height) if (evt.name === "pagedown") scroll.scrollBy(scroll.height / 2)
}) })
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@ -136,23 +136,25 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
<For each={props.parts}> <For each={props.parts}>
{(part) => { {(part) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
const [margin, setMargin] = createSignal(0) function resize(el: BoxRenderable) {
const parent = el.parent
if (!parent) return
const children = parent.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
if (!previous) return
console.log(previous.height, el.height)
if (el.height > 1 || previous.height > 1) {
el.marginTop = 1
}
}
return ( return (
<Show when={component()}> <Show when={component()}>
<box <box
marginTop={margin()}
onSizeChange={function () { onSizeChange={function () {
const el = this resize(this)
if (!el.parent) return
const children = el.parent!.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
if (previous.height > 1 || el.height > 1) {
setMargin(1)
return
}
setMargin(0)
}} }}
ref={(el) => resize(el!)}
> >
<Dynamic component={component()} part={part as any} message={props.message} /> <Dynamic component={component()} part={part as any} message={props.message} />
</box> </box>
@ -194,8 +196,23 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input const input = props.part.state.input
const container: BoxProps =
ToolRegistry.container(props.part.tool) === "block"
? {
border: ["left"] as const,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
backgroundColor: Theme.backgroundPanel,
customBorderChars: SplitBorder.customBorderChars,
borderColor: Theme.background,
}
: {
paddingLeft: 3,
}
return ( return (
<box> <box {...container}>
<Dynamic <Dynamic
component={ready} component={ready}
input={input} input={input}
@ -203,7 +220,7 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
output={props.part.state.status === "completed" ? props.part.state.output : undefined} output={props.part.state.status === "completed" ? props.part.state.output : undefined}
/> />
{props.part.state.status === "error" && ( {props.part.state.status === "error" && (
<box paddingLeft={2}> <box paddingLeft={2} marginTop={1}>
<text fg={Theme.error}>{props.part.state.error.replace("Error: ", "")}</text> <text fg={Theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
</box> </box>
)} )}
@ -211,11 +228,7 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
) )
}) })
return ( return <Show when={component()}>{component()}</Show>
<Show when={component()}>
<box paddingLeft={3}>{component()}</box>
</Show>
)
} }
type ToolProps<T extends Tool.Info> = { type ToolProps<T extends Tool.Info> = {
@ -225,13 +238,20 @@ type ToolProps<T extends Tool.Info> = {
} }
const ToolRegistry = (() => { const ToolRegistry = (() => {
const state: Record<string, { name: string; ready?: Component<ToolProps<any>> }> = {} const state: Record<string, { name: string; container: "inline" | "block"; ready?: Component<ToolProps<any>> }> = {}
function register<T extends Tool.Info>(input: { name: string; ready?: Component<ToolProps<T>> }) { function register<T extends Tool.Info>(input: {
name: string
container: "inline" | "block"
ready?: Component<ToolProps<T>>
}) {
state[input.name] = input state[input.name] = input
return input return input
} }
return { return {
register, register,
container(name: string) {
return state[name]?.container
},
ready(name: string) { ready(name: string) {
return state[name]?.ready return state[name]?.ready
}, },
@ -240,7 +260,7 @@ const ToolRegistry = (() => {
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
return ( return (
<text fg={props.when ? Theme.textMuted : Theme.text}> <text paddingLeft={3} fg={props.when ? Theme.textMuted : Theme.text}>
<Show fallback={<>~ {props.fallback}</>} when={props.when}> <Show fallback={<>~ {props.fallback}</>} when={props.when}>
<span style={{ bold: true }}>{props.icon}</span> {props.children} <span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show> </Show>
@ -250,21 +270,22 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
ToolRegistry.register<typeof BashTool>({ ToolRegistry.register<typeof BashTool>({
name: "bash", name: "bash",
container: "block",
ready(props) { ready(props) {
return ( return (
<> <box>
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}> <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
{props.input.description} {props.input.description}
</ToolTitle> </ToolTitle>
<Show when={props.input.command}> <Show when={props.input.command}>
<box> <box paddingTop={1}>
<text fg={Theme.textMuted}>$ {props.input.command}</text> <text fg={Theme.text}>$ {props.input.command}</text>
<box> <box>
<text fg={Theme.textMuted}>{props.output?.trim()}</text> <text fg={Theme.text}>{props.output?.trim()}</text>
</box> </box>
</box> </box>
</Show> </Show>
</> </box>
) )
}, },
}) })
@ -285,6 +306,7 @@ const syntax = new SyntaxStyle({
ToolRegistry.register<typeof ReadTool>({ ToolRegistry.register<typeof ReadTool>({
name: "read", name: "read",
container: "inline",
ready(props) { ready(props) {
return ( return (
<> <>
@ -298,6 +320,7 @@ ToolRegistry.register<typeof ReadTool>({
ToolRegistry.register<typeof WriteTool>({ ToolRegistry.register<typeof WriteTool>({
name: "write", name: "write",
container: "block",
ready(props) { ready(props) {
const lines = createMemo(() => { const lines = createMemo(() => {
return props.input.content?.split("\n") ?? [] return props.input.content?.split("\n") ?? []
@ -337,6 +360,7 @@ ToolRegistry.register<typeof WriteTool>({
ToolRegistry.register<typeof GlobTool>({ ToolRegistry.register<typeof GlobTool>({
name: "glob", name: "glob",
container: "inline",
ready(props) { ready(props) {
return ( return (
<> <>
@ -350,9 +374,10 @@ ToolRegistry.register<typeof GlobTool>({
ToolRegistry.register<typeof GrepTool>({ ToolRegistry.register<typeof GrepTool>({
name: "grep", name: "grep",
container: "inline",
ready(props) { ready(props) {
return ( return (
<ToolTitle icon="%" fallback="Searching content..." when={props.input.pattern}> <ToolTitle icon="" fallback="Searching content..." when={props.input.pattern}>
Grep "{props.input.pattern}" <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show> Grep "{props.input.pattern}" <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
</ToolTitle> </ToolTitle>
) )
@ -361,6 +386,7 @@ ToolRegistry.register<typeof GrepTool>({
ToolRegistry.register<typeof ListTool>({ ToolRegistry.register<typeof ListTool>({
name: "list", name: "list",
container: "inline",
ready(props) { ready(props) {
const dir = createMemo(() => { const dir = createMemo(() => {
if (props.input.path) { if (props.input.path) {
@ -380,6 +406,7 @@ ToolRegistry.register<typeof ListTool>({
ToolRegistry.register<typeof TaskTool>({ ToolRegistry.register<typeof TaskTool>({
name: "task", name: "task",
container: "block",
ready(props) { ready(props) {
return ( return (
<> <>
@ -387,7 +414,7 @@ ToolRegistry.register<typeof TaskTool>({
Task {props.input.description} Task {props.input.description}
</ToolTitle> </ToolTitle>
<Show when={props.metadata.summary?.length}> <Show when={props.metadata.summary?.length}>
<box> <box marginTop={1}>
<For each={props.metadata.summary ?? []}> <For each={props.metadata.summary ?? []}>
{(task) => ( {(task) => (
<text style={{ fg: Theme.textMuted }}> <text style={{ fg: Theme.textMuted }}>
@ -404,6 +431,7 @@ ToolRegistry.register<typeof TaskTool>({
ToolRegistry.register<typeof WebFetchTool>({ ToolRegistry.register<typeof WebFetchTool>({
name: "webfetch", name: "webfetch",
container: "block",
ready(props) { ready(props) {
return ( return (
<> <>
@ -422,6 +450,7 @@ ToolRegistry.register<typeof WebFetchTool>({
ToolRegistry.register<typeof EditTool>({ ToolRegistry.register<typeof EditTool>({
name: "edit", name: "edit",
container: "block",
ready(props) { ready(props) {
const code = createMemo(() => { const code = createMemo(() => {
if (!props.metadata.diff) return "" if (!props.metadata.diff) return ""
@ -447,6 +476,7 @@ ToolRegistry.register<typeof EditTool>({
ToolRegistry.register<typeof PatchTool>({ ToolRegistry.register<typeof PatchTool>({
name: "patch", name: "patch",
container: "block",
ready(props) { ready(props) {
return ( return (
<> <>
@ -465,13 +495,16 @@ ToolRegistry.register<typeof PatchTool>({
ToolRegistry.register<typeof TodoWriteTool>({ ToolRegistry.register<typeof TodoWriteTool>({
name: "todowrite", name: "todowrite",
container: "block",
ready(props) { ready(props) {
return ( return (
<> <For each={props.input.todos ?? []}>
<ToolTitle icon="~" fallback="Planning..." when={true}> {(todo) => (
Updated todos <text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
</ToolTitle> [{todo.status === "completed" ? "✓" : " "}] {todo.content}
</> </text>
)}
</For>
) )
}, },
}) })