refactor(tui): extract shared session rendering primitives (#48393)

This commit is contained in:
James Long
2026-09-10 18:07:14 -04:00
committed by GitHub
Unverified
parent 8f4d706647
commit 0c1dfa9186
3 changed files with 306 additions and 280 deletions
+3 -280
View File
@@ -1,6 +1,5 @@
import {
batch,
createContext,
createEffect,
createMemo,
createSignal,
@@ -12,7 +11,6 @@ import {
onMount,
Show,
Switch,
useContext,
type Accessor,
} from "solid-js"
import path from "node:path"
@@ -29,7 +27,6 @@ import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@@ -111,6 +108,9 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
export { InlineToolRow } from "./message-parts"
addDefaultParsers(parsers.parsers)
@@ -121,34 +121,6 @@ const BACKGROUND_TOOL_HINT_DELAY = 3_000
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
}>()
function use() {
const ctx = useContext(context)
if (!ctx) throw new Error("useContext must be used within a Session component")
return ctx
}
export function Session(props: {
scrollRef?: (scroll: ScrollBoxRenderable | undefined) => void
@@ -2462,160 +2434,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
)
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed
const start = props.part.time?.created ?? props.message.time.created
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
</box>
</Show>
)
}
function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: theme.text.feedback.warning.default
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
function TextPart(props: { last: boolean; part: SessionMessageAssistantText; message: SessionMessageAssistant }) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
@@ -2918,101 +2736,6 @@ function InlineTool(props: {
)
}
export function InlineToolRow(props: {
icon: string
iconColor?: RGBA
color?: RGBA
errorColor?: RGBA
failed?: boolean
denied?: boolean
error?: string
errorExpanded?: boolean
complete: unknown
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
</Switch>
<Show when={props.failed && props.errorExpanded}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
</box>
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
function StatusBadge(props: { children: string }) {
const theme = useTheme()
return (
@@ -0,0 +1,269 @@
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { RGBA, TextAttributes } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type {
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
} from "@opencode/client"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { reasoningSummary } from "../../context/thinking"
import { usePlugin } from "../../plugin/context"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { use } from "./render-context"
import { generateThinkingSyntax } from "./thinking-syntax"
export const INLINE_TOOL_ICON_WIDTH = 2
export function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed
const start = props.part.time?.created ?? props.message.time.created
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
</box>
</Show>
)
}
export function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: theme.text.feedback.warning.default
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
export function TextPart(props: {
last: boolean
part: SessionMessageAssistantText
message: SessionMessageAssistant
}) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
)
}
export function InlineToolRow(props: {
icon: string
iconColor?: RGBA
color?: RGBA
errorColor?: RGBA
failed?: boolean
denied?: boolean
error?: string
errorExpanded?: boolean
complete: unknown
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
</Switch>
<Show when={props.failed && props.errorExpanded}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
</box>
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
@@ -0,0 +1,34 @@
import { createContext, useContext } from "solid-js"
import type { ModelInfo } from "@opencode/client"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { useConfig } from "../../config"
import type { ThinkingMode } from "../../context/thinking"
export type PendingAction = "steer" | "queue" | "cancel"
export const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
}>()
export function use() {
const ctx = useContext(context)
if (!ctx) throw new Error("useContext must be used within a Session component")
return ctx
}