feat(tui): add fullscreen markdown demo

This commit is contained in:
Kit Langton 2026-05-10 13:28:00 -04:00
commit 13b2917f74
8 changed files with 600 additions and 55 deletions

View file

@ -7,6 +7,8 @@
"packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts --demo",
"dev:run-demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts run --interactive --demo",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",

View file

@ -13,6 +13,8 @@
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"dev:demo": "bun run --conditions=browser ./src/index.ts --demo",
"dev:run-demo": "bun run --conditions=browser ./src/index.ts run --interactive --demo",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit"
},

View file

@ -0,0 +1,181 @@
export const SAMPLE_MARKDOWN = [
"# Direct Mode Demo",
"",
"This is a realistic assistant response for direct-mode formatting checks.",
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
"",
"## Summary",
"",
"- Restored the final markdown flush so the last block is committed on idle.",
"- Switched markdown scrollback commits back to top-level block boundaries.",
"- Added footer-level regression coverage for split-footer rendering.",
"",
"## Status",
"",
"| Area | Before | After | Notes |",
"| --- | --- | --- | --- |",
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
"",
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
"",
"```ts",
"const result = { markdown: true, tables: 2, stable: true }",
"```",
"",
"## Files",
"",
"| File | Change |",
"| --- | --- |",
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
"",
"Next step: run `/fmt table` if you want a tighter table-only sample.",
].join("\n")
export const SAMPLE_TABLE = [
"# Table Sample",
"",
"| Kind | Example | Notes |",
"| --- | --- | --- |",
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
"| Unicode | `漢字` | Wide characters should remain aligned |",
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
"| Status | done | Final row should still appear after idle |",
].join("\n")
export const MARKDOWN_PATTERNS = {
"md-code": [
"# Interleaved Code",
"",
"Start with a short conclusion before any code appears.",
"",
"```ts",
"export function parse(input: string) {",
" return input.trim().split(/\\s+/)",
"}",
"```",
"",
"Then continue with prose immediately after the code block. This should not inherit code styling or indentation.",
"",
"```tsx",
"<Show when={props.enabled}>",
" <markdown content={props.text} streaming />",
"</Show>",
"```",
"",
"Final paragraph after a second fence with `inline code`, **bold text**, and _emphasis_ mixed together.",
].join("\n"),
"md-fence": [
"# Fence Boundaries",
"",
"The renderer should recover cleanly around multiple fences and nearby paragraphs.",
"",
"```bash",
"bun run test -- --grep markdown",
"```",
"Text directly after a fence.",
"```json",
"{",
' "status": "ok",',
' "items": ["one", "two"]',
"}",
"```",
"Trailing paragraph after JSON. The next fence intentionally has no language.",
"```",
"plain fenced text",
"with multiple lines",
"```",
].join("\n"),
"md-list": [
"# Lists With Code",
"",
"1. First ordered item with `inline code`.",
"2. Second ordered item before a nested list:",
" - Nested bullet with a long phrase that should wrap without swallowing the marker or changing indentation.",
" - Nested bullet before fenced code:",
"",
" ```ts",
" const nested = true",
" ```",
"",
"3. Third ordered item after the nested fence.",
"",
"- Top-level bullet after ordered list.",
"- Another bullet with a paragraph below.",
"",
" Continuation paragraph should stay associated with the bullet without becoming code.",
].join("\n"),
"md-table-code": [
"# Tables And Code",
"",
"| Case | Input | Expected |",
"| --- | --- | --- |",
"| Inline code | `const x = 1` | stays inline |",
"| Escaped pipe | `A\\|B` | one cell |",
"| Long token | `LongTokenWithoutNaturalBreaks_1234567890_abcdefghijklmnopqrstuvwxyz` | wraps or scrolls predictably |",
"",
"A code block follows the table:",
"",
"```ts",
"const rows = [",
' { case: "inline code", expected: "stays inline" },',
' { case: "escaped pipe", expected: "one cell" },',
"]",
"```",
"",
"And then another compact table:",
"",
"| A | B |",
"| - | - |",
"| done | yes |",
].join("\n"),
"md-inline": [
"# Inline Markdown",
"",
"This paragraph mixes [a normal link](https://opencode.ai), `https://example.com/code-link`, `inline code`, **strong**, _emphasis_, and ~~strikethrough~~.",
"",
"> Blockquote with `inline code` and [a link](https://example.com) should keep quote styling while wrapping.",
"",
"A horizontal rule follows.",
"",
"---",
"",
"After the rule, text should resume normal spacing.",
].join("\n"),
"md-kitchen": [
"# Markdown Kitchen Sink",
"",
"This combines headings, paragraphs, lists, blockquotes, tables, inline code, and multiple code fences.",
"",
"## Steps",
"",
"1. Read the response.",
"2. Notice `inline code` before a block.",
"",
"```ts",
"type Result = { ok: boolean; reason?: string }",
"const result: Result = { ok: true }",
"```",
"",
"3. Continue the list after the block.",
"",
"> Quoted note after the list. It should not merge into the previous item.",
"",
"| Feature | Stress |",
"| --- | --- |",
"| Markdown | prose/code/table interleave |",
"| Renderer | wrapping and spacing |",
"",
"```diff",
"- const renderer = oldMarkdown",
"+ const renderer = experimentalMarkdown",
"```",
"",
"Final paragraph with [docs](https://opencode.ai/docs) and `https://example.com/from-code`.",
].join("\n"),
} as const
export const MARKDOWN_PATTERN_KINDS = Object.keys(MARKDOWN_PATTERNS) as Array<keyof typeof MARKDOWN_PATTERNS>

View file

@ -19,9 +19,11 @@ import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { writeSessionOutput } from "./stream"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
import { MARKDOWN_PATTERN_KINDS, MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
const KINDS = [
"markdown",
...MARKDOWN_PATTERN_KINDS,
"table",
"text",
"reasoning",
@ -51,53 +53,9 @@ function questionKind(value: string | undefined): QuestionKind | undefined {
return QUESTIONS.find((item) => item === next)
}
const SAMPLE_MARKDOWN = [
"# Direct Mode Demo",
"",
"This is a realistic assistant response for direct-mode formatting checks.",
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
"",
"## Summary",
"",
"- Restored the final markdown flush so the last block is committed on idle.",
"- Switched markdown scrollback commits back to top-level block boundaries.",
"- Added footer-level regression coverage for split-footer rendering.",
"",
"## Status",
"",
"| Area | Before | After | Notes |",
"| --- | --- | --- | --- |",
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
"",
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
"",
"```ts",
"const result = { markdown: true, tables: 2, stable: true }",
"```",
"",
"## Files",
"",
"| File | Change |",
"| --- | --- |",
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
"",
"Next step: run `/fmt table` if you want a tighter table-only sample.",
].join("\n")
const SAMPLE_TABLE = [
"# Table Sample",
"",
"| Kind | Example | Notes |",
"| --- | --- | --- |",
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
"| Unicode | `漢字` | Wide characters should remain aligned |",
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
"| Status | done | Final row should still appear after idle |",
].join("\n")
function markdownPattern(value: string): keyof typeof MARKDOWN_PATTERNS | undefined {
if (value in MARKDOWN_PATTERNS) return value as keyof typeof MARKDOWN_PATTERNS
}
type Ref = {
msg: string
@ -1031,6 +989,12 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
return true
}
const pattern = markdownPattern(kind)
if (pattern) {
await emitText(state, body || MARKDOWN_PATTERNS[pattern], signal)
return true
}
if (kind === "table") {
await emitText(state, body || SAMPLE_TABLE, signal)
return true

View file

@ -962,6 +962,8 @@ function getSyntaxRules(theme: Theme) {
style: {
foreground: theme.markdownHeading,
bold: true,
italic: true,
underline: true,
},
},
{

View file

@ -0,0 +1,285 @@
import type {
Agent,
AssistantMessage,
Config,
Message,
Model,
Part,
Path,
Project,
Provider,
Session,
} from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
const sessionID = "demo_tui_markdown"
const userMessageID = "demo_tui_user"
const assistantMessageID = "demo_tui_assistant"
const now = Date.now()
const markdown = [
"# Fullscreen TUI Markdown Demo",
"",
"This fake assistant response runs through the fullscreen session timeline without calling an LLM.",
"Use it to compare spacing, wrapping, code fence boundaries, table behavior, and inline markdown rendering.",
"",
"## Baseline",
"",
SAMPLE_MARKDOWN,
"",
"## Table Baseline",
"",
SAMPLE_TABLE,
"",
...Object.entries(MARKDOWN_PATTERNS).flatMap(([name, value]) => ["## " + name, "", value, ""]),
].join("\n")
const model = {
id: "demo",
providerID: "demo",
api: {
id: "demo",
url: "https://example.com/demo",
npm: "demo",
},
name: "Demo",
capabilities: {
temperature: false,
reasoning: true,
attachment: false,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: true,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128_000,
output: 16_000,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
} satisfies Model
const provider = {
id: "demo",
name: "Demo",
source: "custom",
env: [],
options: {},
models: {
demo: model,
},
} satisfies Provider
const agent = {
name: "build",
description: "Demo agent",
mode: "primary",
native: true,
permission: [],
model: {
providerID: "demo",
modelID: "demo",
},
options: {},
} satisfies Agent
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
},
})
}
export function createTuiDemo(input: { directory: string }) {
const paths = {
home: process.env.HOME ?? input.directory,
state: input.directory,
config: input.directory,
worktree: input.directory,
directory: input.directory,
} satisfies Path
const project = {
id: "demo_project",
worktree: input.directory,
vcs: "git",
name: "Markdown Demo",
time: {
created: now,
updated: now,
},
sandboxes: [],
} satisfies Project
const session = {
id: sessionID,
slug: "markdown-demo",
projectID: project.id,
directory: input.directory,
title: "Markdown Rendering Demo",
agent: agent.name,
model: {
id: model.id,
providerID: provider.id,
},
version: "demo",
time: {
created: now,
updated: now + 2,
},
} satisfies Session
const user = {
id: userMessageID,
sessionID,
role: "user",
time: {
created: now,
},
agent: agent.name,
model: {
providerID: provider.id,
modelID: model.id,
},
} satisfies Message
const assistant = {
id: assistantMessageID,
sessionID,
role: "assistant",
time: {
created: now + 1,
completed: now + 2,
},
parentID: userMessageID,
modelID: model.id,
providerID: provider.id,
mode: "demo",
agent: agent.name,
path: {
cwd: input.directory,
root: input.directory,
},
cost: 0,
tokens: {
input: 120,
output: 3_200,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
} satisfies AssistantMessage
const messages = [
{
info: user,
parts: [
{
id: "demo_tui_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Show me the fullscreen TUI markdown rendering stress cases.",
time: {
start: now,
end: now,
},
},
],
},
{
info: assistant,
parts: [
{
id: "demo_tui_assistant_text",
sessionID,
messageID: assistantMessageID,
type: "text",
text: markdown,
time: {
start: now + 1,
end: now + 2,
},
},
],
},
] satisfies Array<{ info: Message; parts: Part[] }>
const fetch = (async (...args: Parameters<typeof globalThis.fetch>) => {
const request = new Request(args[0], args[1])
const url = new URL(request.url)
const pathname = url.pathname
if (request.method === "GET" && pathname === "/path") return json(paths)
if (request.method === "GET" && pathname === "/project/current") return json(project)
if (request.method === "GET" && pathname === "/config/providers") {
return json({ providers: [provider], default: { build: "demo/demo" } })
}
if (request.method === "GET" && pathname === "/provider") {
return json({ all: [provider], default: { build: "demo/demo" }, connected: [provider.id] })
}
if (request.method === "GET" && pathname === "/experimental/console") {
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
}
if (request.method === "GET" && pathname === "/agent") return json([agent])
if (request.method === "GET" && pathname === "/config") {
return json({ model: "demo/demo", default_agent: agent.name } satisfies Config)
}
if (request.method === "GET" && pathname === "/session") return json([session])
if (request.method === "GET" && pathname === "/command") return json([])
if (request.method === "GET" && pathname === "/lsp") return json([])
if (request.method === "GET" && pathname === "/mcp") return json({})
if (request.method === "GET" && pathname === "/experimental/resource") return json({})
if (request.method === "GET" && pathname === "/formatter") return json([])
if (request.method === "GET" && pathname === "/session/status") return json({ [sessionID]: { type: "idle" } })
if (request.method === "GET" && pathname === "/provider/auth") return json({})
if (request.method === "GET" && pathname === "/vcs") return json({ branch: "demo", default_branch: "dev" })
if (request.method === "GET" && pathname === "/experimental/workspace") return json([])
if (request.method === "GET" && pathname === "/experimental/workspace/status") return json([])
if (request.method === "GET" && pathname === `/session/${sessionID}`) return json(session)
if (request.method === "GET" && pathname === `/session/${sessionID}/message`) return json(messages)
if (request.method === "GET" && pathname === `/session/${sessionID}/todo`) return json([])
if (request.method === "GET" && pathname === `/session/${sessionID}/diff`) return json([])
if (request.method === "GET" && pathname === `/session/${sessionID}/children`) return json([])
return json({ message: `Unhandled demo endpoint: ${request.method} ${pathname}` }, 404)
}) as typeof globalThis.fetch
const events = {
subscribe: async () => () => {},
} satisfies EventSource
return {
sessionID,
fetch,
events,
}
}

View file

@ -21,7 +21,18 @@ import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import {
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
TextAttributes,
RGBA,
type MarkdownOptions,
type MarkdownRenderable,
TextRenderable,
StyledText,
type TextChunk,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
AssistantMessage,
@ -1525,15 +1536,87 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
const text = createMemo(() => props.part.text.trim())
const diffCache = new Map<string, TextChunk[]>()
const colorDiffChunks = (text: string) => {
const key = `${theme.diffAdded.toString()}:${theme.diffRemoved.toString()}:${text}`
const cached = diffCache.get(key)
if (cached) return cached
let line: "added" | "removed" | undefined
let start = true
const chunks = (text.match(/[^\n]+|\n/g) ?? [text]).map((part): TextChunk => {
if (start && part !== "\n") {
line = part.startsWith("+") ? "added" : part.startsWith("-") ? "removed" : undefined
start = false
}
const next = {
__isChunk: true,
text: part,
...(line === "added" ? { fg: theme.diffAdded } : {}),
...(line === "removed" ? { fg: theme.diffRemoved } : {}),
} satisfies TextChunk
if (part === "\n") {
line = undefined
start = true
}
return next
})
diffCache.set(key, chunks)
if (diffCache.size > 20) diffCache.delete(diffCache.keys().next().value!)
return chunks
}
const configureMarkdown = (node: MarkdownRenderable | undefined) => {
if (!node) return
const renderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
if (token.type === "hr") {
return new BoxRenderable(node.ctx, {
width: "100%",
height: 1,
border: ["top"],
borderColor: theme.border,
flexShrink: 0,
})
}
const needsCodeTopGap = token.type === "code" && !text().startsWith(token.raw.trimStart())
if (token.type === "code" && token.lang?.trim().toLowerCase() === "diff") {
const renderable = new TextRenderable(node.ctx, {
content: new StyledText(colorDiffChunks(token.text)),
width: "100%",
flexShrink: 0,
})
if (needsCodeTopGap) renderable.marginTop = 1
return renderable
}
const renderable = context.defaultRender()
if (needsCodeTopGap && renderable) {
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
}
return renderable
}
// OpenTUI Solid constructs elements with only `{ id }`, so constructor-only
// MarkdownOptions need to be installed on the renderable directly.
const target = node as unknown as {
_internalBlockMode: "top-level"
_renderNode: typeof renderNode
}
target._internalBlockMode = "top-level"
target._renderNode = renderNode
}
return (
<Show when={props.part.text.trim()}>
<Show when={text()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={syntax()}
streaming={true}
content={props.part.text.trim()}
ref={configureMarkdown}
tableOptions={{ style: "grid", widthMode: "content" }}
content={text()}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
@ -1545,7 +1628,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={props.part.text.trim()}
content={text()}
conceal={ctx.conceal()}
fg={theme.text}
/>

View file

@ -111,6 +111,10 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("demo", {
type: "boolean",
describe: "open a fake fullscreen TUI session for renderer debugging",
}),
handler: async (args) => {
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@ -130,7 +134,6 @@ export const TuiThreadCommand = cmd({
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
} catch {
@ -138,6 +141,31 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const config = TuiConfig.get()
if (args.demo) {
const { createTuiDemo } = await import("./demo")
const { tui } = await import("./app")
const demo = createTuiDemo({ directory: cwd })
await tui({
url: "http://opencode.demo",
config: await config,
directory: cwd,
fetch: demo.fetch,
events: demo.events,
args: {
continue: false,
sessionID: demo.sessionID,
agent: args.agent,
model: args.model,
prompt: await input(args.prompt),
fork: false,
},
})
return
}
const file = await target()
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
@ -187,8 +215,6 @@ export const TuiThreadCommand = cmd({
}
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
@ -236,7 +262,7 @@ export const TuiThreadCommand = cmd({
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
config: await config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,