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
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 @@ -404,6 +406,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 @@ -497,7 +507,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 @@ -652,6 +652,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()
},
},
])

createEffect(() => {
Expand Down
147 changes: 133 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 @@ -10,6 +10,7 @@ import {
Show,
Switch,
useContext,
Index,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
Expand All @@ -26,6 +27,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 @@ -78,6 +80,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"

addDefaultParsers(parsers.parsers)

Expand All @@ -99,6 +102,7 @@ const context = createContext<{
showTimestamps: () => boolean
showDetails: () => boolean
diffWrapMode: () => "word" | "none"
markdownAll: () => boolean
sync: ReturnType<typeof useSync>
}>()

Expand Down Expand Up @@ -151,6 +155,7 @@ export function Session() {
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
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 wide = createMemo(() => dimensions().width > 120)
Expand Down Expand Up @@ -975,6 +980,7 @@ export function Session() {
showTimestamps,
showDetails,
diffWrapMode,
markdownAll,
sync,
}}
>
Expand Down Expand Up @@ -1172,12 +1178,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 @@ -1205,7 +1213,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 @@ -1375,38 +1408,124 @@ 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()}
/>
</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
Loading