Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { useDialog } from "../../ui/dialog"
import { DialogAlert } from "../../ui/dialog-alert"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import { splitUserMessageText } from "./user-message-text"
import type { PromptInfo } from "../../component/prompt/history"
import { DialogConfirm } from "../../ui/dialog-confirm"
import { DialogTimeline } from "./dialog-timeline"
Expand Down Expand Up @@ -1371,19 +1372,11 @@ function UserMessage(props: {
}) {
const ctx = use()
const local = useLocal()
const text = createMemo(() => {
const texts = props.parts
.map((x) => {
if (x.type === "text" && !x.synthetic) {
return x.text
}
return null
})
.filter(Boolean)
return texts.join("\n\n")
})
const body = createMemo(() => splitUserMessageText(props.parts))
const text = createMemo(() => body().text)
const markdownText = createMemo(() => body().markdown)
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending !== undefined && props.index > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
Expand All @@ -1394,7 +1387,7 @@ function UserMessage(props: {

return (
<>
<Show when={text()}>
<Show when={text() || markdownText()}>
<box
id={props.message.id}
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
Expand All @@ -1417,7 +1410,21 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.text}>{text()}</text>
<Show when={text()}>
<text fg={theme.text}>{text()}</text>
</Show>
<Show when={markdownText()}>
<markdown
syntaxStyle={syntax()}
streaming={false}
internalBlockMode="top-level"
content={markdownText()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={hover() ? theme.backgroundElement : theme.backgroundPanel}
/>
</Show>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
Expand Down
29 changes: 29 additions & 0 deletions packages/tui/src/routes/session/user-message-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Part, TextPart } from "@opencode-ai/sdk/v2"

/**
* A text part injected by a plugin or a tool can opt into markdown rendering by
* setting `metadata: { render: "markdown" }` on the part. Text a person typed
* keeps rendering literally, so a prompt containing markdown syntax is echoed
* back exactly as it was entered.
*/
export function isMarkdownPart(part: TextPart) {
return part.metadata?.["render"] === "markdown"
}

/**
* Splits the visible text of a user message into the literal body and the body
* that opted into markdown. Synthetic parts stay hidden, matching the previous
* behavior of the message bubble.
*/
export function splitUserMessageText(parts: Part[]) {
const visible = parts.flatMap((part) =>
part.type === "text" && !part.synthetic && part.text ? [part] : [],
)
return {
text: visible.flatMap((part) => (isMarkdownPart(part) ? [] : [part.text])).join("\n\n"),
markdown: visible
.flatMap((part) => (isMarkdownPart(part) ? [part.text.trim()] : []))
.filter(Boolean)
.join("\n\n"),
}
}
52 changes: 52 additions & 0 deletions packages/tui/test/component/user-message-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import type { Part, TextPart } from "@opencode-ai/sdk/v2"
import { splitUserMessageText } from "../../src/routes/session/user-message-text"

function textPart(text: string, extra: Partial<TextPart> = {}): Part {
return { id: "prt", sessionID: "ses", messageID: "msg", type: "text", text, ...extra }
}

describe("user message text", () => {
test("keeps typed text literal", () => {
expect(splitUserMessageText([textPart("# not a heading")])).toEqual({
text: "# not a heading",
markdown: "",
})
})

test("routes a part that opted in to the markdown body", () => {
expect(splitUserMessageText([textPart("# heading", { metadata: { render: "markdown" } })])).toEqual({
text: "",
markdown: "# heading",
})
})

test("keeps both bodies when a message mixes typed and injected parts", () => {
const parts = [
textPart("look at this"),
textPart("**done**", { metadata: { render: "markdown" } }),
textPart("and this"),
]
expect(splitUserMessageText(parts)).toEqual({
text: "look at this\n\nand this",
markdown: "**done**",
})
})

test("ignores synthetic and empty parts", () => {
const parts = [
textPart("hidden", { synthetic: true }),
textPart("also hidden", { synthetic: true, metadata: { render: "markdown" } }),
textPart(""),
textPart("visible"),
]
expect(splitUserMessageText(parts)).toEqual({ text: "visible", markdown: "" })
})

test("treats an unknown render value as literal text", () => {
expect(splitUserMessageText([textPart("plain", { metadata: { render: "html" } })])).toEqual({
text: "plain",
markdown: "",
})
})
})
Loading