Skip to content
2 changes: 1 addition & 1 deletion packages/app/src/components/dialog-edit-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function DialogEditProject(props: { project: LocalProject }) {

return (
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1421,7 +1421,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
}
>
<HoverCard openDelay={150} closeDelay={100} placement="right-start" gutter={16} trigger={item}>
<HoverCard openDelay={150} closeDelay={100} placement="right-start" gutter={28} trigger={item}>
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
<MessageNav
messages={hoverMessages() ?? []}
Expand Down Expand Up @@ -1730,7 +1730,7 @@ export default function Layout(props: ParentProps) {
trigger={trigger}
onOpenChange={setOpen}
>
<div class="-m-3 flex flex-col w-72">
<div class="-m-3 p-2 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
Expand Down
18 changes: 13 additions & 5 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -824,10 +824,22 @@ export default function Page() {
})

const isWorking = createMemo(() => status().type !== "idle")

const autoScroll = createAutoScroll({
working: isWorking,
working: () => true,
})

createEffect(
on(
isWorking,
(working, prev) => {
if (!working || prev) return
autoScroll.forceScrollToBottom()
},
{ defer: true },
),
)

let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined

Expand Down Expand Up @@ -1340,10 +1352,6 @@ export default function Page() {
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200": !showTabs(),
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
}}
>
<SessionTurn
Expand Down
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 @@ -11,6 +11,7 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { loadTheme } from "../theme-loader"

const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
Expand Down Expand Up @@ -134,6 +135,15 @@ export const RunCommand = cmd({
}

const execute = async (sdk: OpencodeClient, sessionID: string) => {
let theme
try {
const configResult = await sdk.config.get()
const themeName = configResult.data?.theme
theme = loadTheme(themeName)
} catch {
theme = loadTheme()
}

const printEvent = (color: string, type: string, title: string) => {
UI.println(
color + `|`,
Expand Down Expand Up @@ -185,7 +195,7 @@ export const RunCommand = cmd({
if (outputJsonEvent("text", { part })) continue
const isPiped = !process.stdout.isTTY
if (!isPiped) UI.println()
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
process.stdout.write((isPiped ? part.text : UI.markdown(part.text, theme)) + EOL)
if (!isPiped) UI.println()
}
}
Expand Down
118 changes: 106 additions & 12 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"

import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
Expand All @@ -25,7 +26,9 @@ import {
type ScrollAcceleration,
TextAttributes,
RGBA,
StyledText,
} from "@opentui/core"
import { Index } from "solid-js"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
Expand Down Expand Up @@ -74,6 +77,7 @@ import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { renderMarkdownThemedStyled, parseMarkdownSegments } from "@/cli/markdown-renderer"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -1353,26 +1357,116 @@ 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()

// Parse markdown into segments - use Index to prevent recreation
const segments = createMemo(() => parseMarkdownSegments(props.part.text?.trim() ?? ""))

return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
/>
<Show when={props.part.text?.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} 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>
</Show>
)
}

// Render text segments with custom renderer (tables, inline formatting)
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} />
}

// Render code blocks with tree-sitter highlighting
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>
)
}

// Prose and Diff components kept for potential future use with stable rendering
function Diff(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()
})
// Don't pass fg prop - chunks already have colors
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