Skip to content

Commit 0c1dfa9

Browse files
authored
refactor(tui): extract shared session rendering primitives (#48393)
1 parent 8f4d706 commit 0c1dfa9

3 files changed

Lines changed: 306 additions & 280 deletions

File tree

packages/tui/src/routes/session/index.tsx

Lines changed: 3 additions & 280 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
batch,
3-
createContext,
43
createEffect,
54
createMemo,
65
createSignal,
@@ -12,7 +11,6 @@ import {
1211
onMount,
1312
Show,
1413
Switch,
15-
useContext,
1614
type Accessor,
1715
} from "solid-js"
1816
import path from "node:path"
@@ -29,7 +27,6 @@ import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from
2927
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
3028
import { Prompt, type PromptRef } from "../../component/prompt"
3129
import type {
32-
ModelInfo,
3330
SessionMessageInfo,
3431
SessionMessageAssistant,
3532
SessionMessageAssistantReasoning,
@@ -111,6 +108,9 @@ import { createDelayedPresence } from "../../util/delayed-presence"
111108
import { SessionLocationMissing } from "./location-missing"
112109
import { isRecord } from "../../util/record"
113110
import { createHistoryPrepend } from "./history"
111+
import { context, use, type PendingAction } from "./render-context"
112+
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
113+
export { InlineToolRow } from "./message-parts"
114114

115115
addDefaultParsers(parsers.parsers)
116116

@@ -121,34 +121,6 @@ const BACKGROUND_TOOL_HINT_DELAY = 3_000
121121
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
122122
const TRANSCRIPT_TAIL_ROWS = 40
123123
const TRANSCRIPT_BACKFILL_CHUNK = 60
124-
type PendingAction = "steer" | "queue" | "cancel"
125-
126-
const context = createContext<{
127-
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
128-
width: number
129-
/**
130-
* Shared reactive terminal size. Transcript-row components must read this
131-
* instead of calling useTerminalDimensions(), which registers one renderer
132-
* resize listener per mounted component and grows with transcript length.
133-
*/
134-
terminal: { width: number; height: number }
135-
sessionID: string
136-
thinkingMode: () => ThinkingMode
137-
markdownMode: () => "source" | "rendered"
138-
groupExploration: () => boolean
139-
diffWrapMode: () => "word" | "none"
140-
models: () => ModelInfo[]
141-
messageIndex: (messageID: string) => number | undefined
142-
config: ReturnType<typeof useConfig>["data"]
143-
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
144-
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
145-
}>()
146-
147-
function use() {
148-
const ctx = useContext(context)
149-
if (!ctx) throw new Error("useContext must be used within a Session component")
150-
return ctx
151-
}
152124

153125
export function Session(props: {
154126
scrollRef?: (scroll: ScrollBoxRenderable | undefined) => void
@@ -2462,160 +2434,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
24622434
)
24632435
}
24642436

2465-
const INLINE_TOOL_ICON_WIDTH = 2
2466-
2467-
function ReasoningPart(props: {
2468-
last: boolean
2469-
part: SessionMessageAssistantReasoning
2470-
message: SessionMessageAssistant
2471-
}) {
2472-
const theme = useTheme()
2473-
const { currentSyntax: syntax } = useThemes()
2474-
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
2475-
const ctx = use()
2476-
// Collapsed by default in hide mode: a single line throughout, so the
2477-
// layout never shifts. Click to open the full markdown block, click to close.
2478-
const [expanded, setExpanded] = createSignal(false)
2479-
2480-
const content = createMemo(() => reasoningContent(props.part))
2481-
const isDone = createMemo(
2482-
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
2483-
)
2484-
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
2485-
const duration = createMemo(() => {
2486-
const end = props.part.time?.completed ?? props.message.time.completed
2487-
const start = props.part.time?.created ?? props.message.time.created
2488-
return end === undefined ? 0 : Math.max(0, end - start)
2489-
})
2490-
const summary = createMemo(() => reasoningSummary(content()))
2491-
const toggle = () => {
2492-
if (!inMinimal()) return
2493-
setExpanded((prev) => !prev)
2494-
}
2495-
2496-
return (
2497-
<Show when={content()}>
2498-
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
2499-
<box
2500-
border={!inMinimal() || expanded() ? ["left"] : undefined}
2501-
customBorderChars={SplitBorder.customBorderChars}
2502-
borderColor={theme.raise(theme.background.default)}
2503-
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
2504-
>
2505-
<box onMouseUp={toggle}>
2506-
<ReasoningHeader
2507-
toggleable={inMinimal()}
2508-
open={!inMinimal() || expanded()}
2509-
done={isDone()}
2510-
title={inMinimal() && !expanded() ? summary().title : null}
2511-
duration={isDone() ? Locale.duration(duration()) : undefined}
2512-
/>
2513-
</box>
2514-
</box>
2515-
<Show when={!inMinimal() || expanded()}>
2516-
<box marginTop={1}>
2517-
<box
2518-
border={["left"]}
2519-
customBorderChars={SplitBorder.customBorderChars}
2520-
borderColor={theme.raise(theme.background.default)}
2521-
paddingLeft={inMinimal() ? 3 : 1}
2522-
>
2523-
<code
2524-
filetype="markdown"
2525-
drawUnstyledText={false}
2526-
streaming={true}
2527-
syntaxStyle={thinkingSyntax()}
2528-
content={content()}
2529-
conceal={ctx.markdownMode() === "rendered"}
2530-
fg={theme.text.subdued}
2531-
/>
2532-
</box>
2533-
</box>
2534-
</Show>
2535-
</box>
2536-
</Show>
2537-
)
2538-
}
2539-
2540-
function reasoningContent(part: SessionMessageAssistantReasoning) {
2541-
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
2542-
return part.text.replace("[REDACTED]", "").trim()
2543-
}
2544-
2545-
function ReasoningHeader(props: {
2546-
toggleable: boolean
2547-
open: boolean
2548-
done: boolean
2549-
title: string | null
2550-
duration?: string
2551-
}) {
2552-
const theme = useTheme()
2553-
const fg = () =>
2554-
props.open
2555-
? RGBA.fromValues(
2556-
theme.text.feedback.warning.default.r,
2557-
theme.text.feedback.warning.default.g,
2558-
theme.text.feedback.warning.default.b,
2559-
0.6,
2560-
)
2561-
: theme.text.feedback.warning.default
2562-
2563-
return (
2564-
<Switch>
2565-
<Match when={!props.done}>
2566-
<box flexDirection="row">
2567-
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
2568-
</box>
2569-
</Match>
2570-
<Match when={true}>
2571-
<text fg={fg()} wrapMode="none">
2572-
<Show when={props.toggleable}>
2573-
<span>{props.open ? "- " : "+ "}</span>
2574-
</Show>
2575-
<span>Thought</span>
2576-
<Show when={props.title || props.duration}>
2577-
<span>: </span>
2578-
</Show>
2579-
<Show when={props.title}>
2580-
<span>{props.title}</span>
2581-
</Show>
2582-
<Show when={props.duration}>
2583-
<span>
2584-
{props.title ? " · " : ""}
2585-
{props.duration}
2586-
</span>
2587-
</Show>
2588-
</text>
2589-
</Match>
2590-
</Switch>
2591-
)
2592-
}
2593-
2594-
function TextPart(props: { last: boolean; part: SessionMessageAssistantText; message: SessionMessageAssistant }) {
2595-
const ctx = use()
2596-
const theme = useTheme()
2597-
const { currentSyntax: syntax } = useThemes()
2598-
const plugins = usePlugin()
2599-
return (
2600-
<Show when={props.part.text.trim()}>
2601-
<box paddingLeft={3} flexShrink={0}>
2602-
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
2603-
<markdown
2604-
syntaxStyle={syntax()}
2605-
renderNode={plugins.markdown()}
2606-
content={props.part.text.trim()}
2607-
streaming={props.message.time.completed === undefined}
2608-
internalBlockMode="top-level"
2609-
tableOptions={{ style: "grid", cellPaddingX: 1 }}
2610-
conceal={ctx.markdownMode() === "rendered"}
2611-
fg={theme.markdown.text}
2612-
bg={theme.background.default}
2613-
/>
2614-
</box>
2615-
</Show>
2616-
)
2617-
}
2618-
26192437
// Pending messages moved to individual tool pending functions
26202438

26212439
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
@@ -2918,101 +2736,6 @@ function InlineTool(props: {
29182736
)
29192737
}
29202738

2921-
export function InlineToolRow(props: {
2922-
icon: string
2923-
iconColor?: RGBA
2924-
color?: RGBA
2925-
errorColor?: RGBA
2926-
failed?: boolean
2927-
denied?: boolean
2928-
error?: string
2929-
errorExpanded?: boolean
2930-
complete: unknown
2931-
pending: string
2932-
failure?: string
2933-
spinner?: boolean
2934-
status?: JSX.Element
2935-
children: JSX.Element
2936-
onMouseOver?: () => void
2937-
onMouseOut?: () => void
2938-
onMouseUp?: () => void
2939-
}) {
2940-
return (
2941-
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
2942-
<Switch>
2943-
<Match when={props.spinner}>
2944-
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
2945-
{(status) => (
2946-
<box flexDirection="row" gap={1}>
2947-
<Spinner color={props.color} />
2948-
<InlineToolLabel color={props.color} status={status()}>
2949-
{props.children}
2950-
</InlineToolLabel>
2951-
</box>
2952-
)}
2953-
</Show>
2954-
</Match>
2955-
<Match when={true}>
2956-
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
2957-
<box flexDirection="row">
2958-
<text
2959-
width={INLINE_TOOL_ICON_WIDTH}
2960-
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
2961-
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
2962-
>
2963-
{props.icon}
2964-
</text>
2965-
<Show
2966-
when={props.status}
2967-
fallback={
2968-
<text
2969-
flexGrow={1}
2970-
fg={props.failed ? props.errorColor : props.color}
2971-
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
2972-
>
2973-
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
2974-
</text>
2975-
}
2976-
>
2977-
{(status) => (
2978-
<InlineToolLabel
2979-
color={props.failed ? props.errorColor : props.color}
2980-
denied={props.denied}
2981-
status={status()}
2982-
>
2983-
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
2984-
</InlineToolLabel>
2985-
)}
2986-
</Show>
2987-
</box>
2988-
</Show>
2989-
</Match>
2990-
</Switch>
2991-
<Show when={props.failed && props.errorExpanded}>
2992-
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
2993-
<text fg={props.errorColor}>{props.error}</text>
2994-
</box>
2995-
</Show>
2996-
</box>
2997-
)
2998-
}
2999-
3000-
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
3001-
return (
3002-
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
3003-
<text
3004-
maxWidth="100%"
3005-
flexShrink={0}
3006-
fg={props.color}
3007-
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
3008-
>
3009-
{props.children}
3010-
</text>
3011-
{props.status}
3012-
</box>
3013-
)
3014-
}
3015-
30162739
function StatusBadge(props: { children: string }) {
30172740
const theme = useTheme()
30182741
return (

0 commit comments

Comments
 (0)