From f89caddf8c4d38a2c5ba3f5a378baa6d04c8ee09 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:18:48 -0300 Subject: [PATCH] feat(tui): let injected text parts opt into markdown rendering A user message renders its text through a plain text node, so a part that a plugin or a tool injects with session.prompt shows its markup literally. The assistant path already renders through the markdown component. Text parts can now set metadata.render to "markdown" to pick the markdown component instead. Anything a person typed keeps rendering literally, so a prompt containing markdown syntax is still echoed back as it was entered. TextPartInput already carries metadata and resolvePart spreads the input part into the stored part, so no schema or server change is needed. --- packages/tui/src/routes/session/index.tsx | 35 ++++++++----- .../src/routes/session/user-message-text.ts | 29 +++++++++++ .../test/component/user-message-text.test.ts | 52 +++++++++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 packages/tui/src/routes/session/user-message-text.ts create mode 100644 packages/tui/test/component/user-message-text.test.ts diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61abe4abd8d9..1d8687af5f38 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: "", + }) + }) +})