diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index cbdaf0cfa0c7..fcc7a7c50adc 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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" @@ -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)) @@ -1394,7 +1387,7 @@ function UserMessage(props: { return ( <> - + alwaysSeparate.add(el)} @@ -1417,7 +1410,21 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()} + + {text()} + + + + diff --git a/packages/tui/src/routes/session/user-message-text.ts b/packages/tui/src/routes/session/user-message-text.ts new file mode 100644 index 000000000000..be53de730dc8 --- /dev/null +++ b/packages/tui/src/routes/session/user-message-text.ts @@ -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"), + } +} diff --git a/packages/tui/test/component/user-message-text.test.ts b/packages/tui/test/component/user-message-text.test.ts new file mode 100644 index 000000000000..9b5896f2273a --- /dev/null +++ b/packages/tui/test/component/user-message-text.test.ts @@ -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 = {}): 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: "", + }) + }) +})