Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
969dc48
feat(markdown): enhanced markdown renderer with tables, themes, and s…
ryanwyler Feb 7, 2026
f419b65
feat(tui): add toggle for markdown rendering on all messages
ryanwyler Feb 7, 2026
3e74fce
fix(markdown): handle nested inline markdown inside bold/italic in ta…
ryanwyler Feb 8, 2026
4521827
refactor(tui): move markdown toggle from Session to System category
ryanwyler Feb 8, 2026
14cad66
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Feb 19, 2026
4d96eff
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Feb 19, 2026
d53fdde
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Feb 20, 2026
d0b5796
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Feb 21, 2026
973e442
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Feb 26, 2026
432f291
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Feb 26, 2026
7920f95
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 5, 2026
33f49fc
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 8, 2026
c59a458
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 8, 2026
ef3dc87
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 13, 2026
e380bf2
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 20, 2026
f901746
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 20, 2026
5bc10bd
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 24, 2026
61e6051
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 24, 2026
470b1d9
Merge branch 'dev' into feat/gignit--markdown-render
ariane-emory Mar 24, 2026
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
12 changes: 11 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import { loadTheme } from "../theme-loader"
import type { MarkdownTheme } from "../markdown-renderer"

type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
Expand Down Expand Up @@ -409,6 +411,14 @@ export const RunCommand = cmd({
}

async function execute(sdk: OpencodeClient) {
let theme: MarkdownTheme | undefined
try {
const cfg = await sdk.config.get()
theme = loadTheme(cfg.data?.theme)
} catch {
theme = loadTheme()
}

function tool(part: ToolPart) {
try {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
Expand Down Expand Up @@ -502,7 +512,7 @@ export const RunCommand = cmd({
continue
}
UI.empty()
UI.println(text)
process.stdout.write(UI.markdown(text, theme) + EOL)
UI.empty()
}

Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,17 @@ function App() {
dialog.clear()
},
},
{
title: kv.get("markdown_all_messages", false)
? "Render markdown: agent messages only"
: "Render markdown: all messages",
value: "app.toggle.markdown_all",
category: "System",
onSelect: (dialog) => {
kv.set("markdown_all_messages", !kv.get("markdown_all_messages", false))
dialog.clear()
},
},
])

sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
Expand Down
148 changes: 134 additions & 14 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Show,
Switch,
useContext,
Index,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
Expand All @@ -27,6 +28,7 @@ import {
type ScrollAcceleration,
TextAttributes,
RGBA,
StyledText,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
Expand Down Expand Up @@ -80,6 +82,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { renderMarkdownThemedStyled, parseMarkdownSegments } from "@/cli/markdown-renderer"
import { useTuiConfig } from "../../context/tui-config"

addDefaultParsers(parsers.parsers)
Expand All @@ -103,6 +106,7 @@ const context = createContext<{
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
markdownAll: () => boolean
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
Expand Down Expand Up @@ -157,6 +161,7 @@ export function Session() {
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [markdownAll, setMarkdownAll] = kv.signal("markdown_all_messages", false)
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)

Expand Down Expand Up @@ -636,6 +641,7 @@ export function Session() {
dialog.clear()
},
},

{
title: showHeader() ? "Hide header" : "Show header",
value: "session.toggle.header",
Expand Down Expand Up @@ -1041,6 +1047,7 @@ export function Session() {
showDetails,
showGenericToolOutput,
diffWrapMode,
markdownAll,
sync,
tui: tuiConfig,
}}
Expand Down Expand Up @@ -1239,12 +1246,14 @@ function UserMessage(props: {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
const { theme } = useTheme()
const tui = useTheme()
const theme = tui.theme
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
const segments = createMemo(() => parseMarkdownSegments(text()?.text?.trim() ?? ""))

const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))

Expand Down Expand Up @@ -1272,7 +1281,32 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.text}>{text()?.text}</text>
<Show when={ctx.markdownAll()} fallback={<text fg={theme.text}>{text()?.text}</text>}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={tui.syntax()}
streaming={false}
content={text()?.text?.trim() ?? ""}
conceal={ctx.conceal()}
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<box flexDirection="column">
<Index each={segments()}>
{(segment) => (
<Show
when={segment().type === "code"}
fallback={<Prose segment={segment() as any} theme={tui.theme} width={ctx.width - 5} />}
>
<CodeBlock segment={segment() as any} syntax={tui.syntax()} />
</Show>
)}
</Index>
</box>
</Match>
</Switch>
</Show>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
Expand Down Expand Up @@ -1452,16 +1486,36 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
)
}

// ============================================================================
// Markdown Rendering Components
// ============================================================================

const LANGS: Record<string, string> = {
js: "javascript",
ts: "typescript",
jsx: "typescript",
tsx: "typescript",
py: "python",
rb: "ruby",
sh: "shell",
bash: "shell",
zsh: "shell",
yml: "yaml",
md: "markdown",
}

function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
const tui = useTheme()
const segments = createMemo(() => parseMarkdownSegments(props.part.text?.trim() ?? ""))

return (
<Show when={props.part.text.trim()}>
<Show when={props.part.text?.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={syntax()}
syntaxStyle={tui.syntax()}
streaming={true}
content={props.part.text.trim()}
conceal={ctx.conceal()}
Expand All @@ -1470,22 +1524,88 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
/>
<box flexDirection="column">
<Index each={segments()}>
{(segment) => (
<Show
when={segment().type === "code"}
fallback={<Prose segment={segment() as any} theme={tui.theme} width={ctx.width - 3} />}
>
<CodeBlock segment={segment() as any} syntax={tui.syntax()} />
</Show>
)}
</Index>
</box>
</Match>
</Switch>
</box>
</Show>
)
}

function Prose(props: { segment: { type: "text"; content: string }; theme: any; width: number }) {
let el: any
const styled = createMemo(() => {
if (!props.segment.content) return new StyledText([])
const result = renderMarkdownThemedStyled(props.segment.content, props.theme, { cols: props.width })
return new StyledText(
result.chunks.map((c) => ({
__isChunk: true as const,
text: c.text,
fg: c.fg ? RGBA.fromInts(c.fg.r, c.fg.g, c.fg.b, c.fg.a) : props.theme.text,
bg: c.bg ? RGBA.fromInts(c.bg.r, c.bg.g, c.bg.b, c.bg.a) : undefined,
attributes: c.attributes,
})),
)
})
createEffect(() => {
if (el) el.content = styled()
})
return <text ref={el} />
}

function CodeBlock(props: { segment: { type: "code"; content: string; language: string }; syntax: any }) {
const ctx = use()
const lang = () => LANGS[props.segment.language] || props.segment.language

return (
<box paddingLeft={2}>
<code
filetype={lang()}
content={props.segment.content}
syntaxStyle={props.syntax}
drawUnstyledText={true}
streaming={false}
conceal={ctx.conceal()}
/>
</box>
)
}

function MarkdownDiff(props: { content: string; theme: ReturnType<typeof useTheme>["theme"] }) {
let el: any
const styled = createMemo(() => {
const chunks = props.content.split("\n").map((line) => {
const t = line.trim()
const fg = t.startsWith("+")
? props.theme.diffAdded
: t.startsWith("-")
? props.theme.diffRemoved
: props.theme.markdownCodeBlock
return { __isChunk: true as const, text: " " + line + "\n", fg }
})
return new StyledText(chunks)
})
createEffect(() => {
if (el) el.content = styled()
})
return (
<box paddingLeft={2}>
<text ref={el} />
</box>
)
}

// Pending messages moved to individual tool pending functions

function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
Expand Down
Loading