From 0c1dfa918662c92a5ba67207f912da9179b5986f Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 10 Sep 2026 18:07:14 -0400 Subject: [PATCH] refactor(tui): extract shared session rendering primitives (#48393) --- packages/tui/src/routes/session/index.tsx | 283 +----------------- .../tui/src/routes/session/message-parts.tsx | 269 +++++++++++++++++ .../tui/src/routes/session/render-context.tsx | 34 +++ 3 files changed, 306 insertions(+), 280 deletions(-) create mode 100644 packages/tui/src/routes/session/message-parts.tsx create mode 100644 packages/tui/src/routes/session/render-context.tsx diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f2fa83c68a5..eb25cc88932 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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["data"] - mutatePending: (action: PendingAction, inboxID: string) => Promise - 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 ( - - - - - - - - - - - - - - - - - ) -} - -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 ( - - - - {props.title ? "Thinking: " + props.title : "Thinking"} - - - - - - {props.open ? "- " : "+ "} - - Thought - - : - - - {props.title} - - - - {props.title ? " · " : ""} - {props.duration} - - - - - - ) -} - -function TextPart(props: { last: boolean; part: SessionMessageAssistantText; message: SessionMessageAssistant }) { - const ctx = use() - const theme = useTheme() - const { currentSyntax: syntax } = useThemes() - const plugins = usePlugin() - return ( - - - {/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */} - - - - ) -} - // 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 ( - - - - }> - {(status) => ( - - - - {props.children} - - - )} - - - - {props.pending}} when={props.complete || props.failed}> - - - {props.icon} - - - {props.failed && !props.complete ? (props.failure ?? props.children) : props.children} - - } - > - {(status) => ( - - {props.failed && !props.complete ? (props.failure ?? props.children) : props.children} - - )} - - - - - - - - {props.error} - - - - ) -} - -function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) { - return ( - - - {props.children} - - {props.status} - - ) -} - function StatusBadge(props: { children: string }) { const theme = useTheme() return ( diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx new file mode 100644 index 00000000000..97ddd09ea3b --- /dev/null +++ b/packages/tui/src/routes/session/message-parts.tsx @@ -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 ( + + + + + + + + + + + + + + + + + ) +} + +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 ( + + + + {props.title ? "Thinking: " + props.title : "Thinking"} + + + + + + {props.open ? "- " : "+ "} + + Thought + + : + + + {props.title} + + + + {props.title ? " · " : ""} + {props.duration} + + + + + + ) +} + +export function TextPart(props: { + last: boolean + part: SessionMessageAssistantText + message: SessionMessageAssistant +}) { + const ctx = use() + const theme = useTheme() + const { currentSyntax: syntax } = useThemes() + const plugins = usePlugin() + return ( + + + {/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */} + + + + ) +} + +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 ( + + + + }> + {(status) => ( + + + + {props.children} + + + )} + + + + {props.pending}} when={props.complete || props.failed}> + + + {props.icon} + + + {props.failed && !props.complete ? (props.failure ?? props.children) : props.children} + + } + > + {(status) => ( + + {props.failed && !props.complete ? (props.failure ?? props.children) : props.children} + + )} + + + + + + + + {props.error} + + + + ) +} + +function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) { + return ( + + + {props.children} + + {props.status} + + ) +} diff --git a/packages/tui/src/routes/session/render-context.tsx b/packages/tui/src/routes/session/render-context.tsx new file mode 100644 index 00000000000..652f287c06f --- /dev/null +++ b/packages/tui/src/routes/session/render-context.tsx @@ -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["data"] + mutatePending: (action: PendingAction, inboxID: string) => Promise + 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 +}