diff --git a/package.json b/package.json
index 5faf8be920..c8c55bcbf7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index f6977296f9..0a7e6590cc 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -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"
},
diff --git a/packages/opencode/src/cli/cmd/demo-fixtures.ts b/packages/opencode/src/cli/cmd/demo-fixtures.ts
new file mode 100644
index 0000000000..0da3fb5b5e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/demo-fixtures.ts
@@ -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",
+ "",
+ " ",
+ "",
+ "```",
+ "",
+ "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
diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts
index d0d72ce002..7a5bd05541 100644
--- a/packages/opencode/src/cli/cmd/run/demo.ts
+++ b/packages/opencode/src/cli/cmd/run/demo.ts
@@ -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
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index c247640eba..0c4c86b13a 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -962,6 +962,8 @@ function getSyntaxRules(theme: Theme) {
style: {
foreground: theme.markdownHeading,
bold: true,
+ italic: true,
+ underline: true,
},
},
{
diff --git a/packages/opencode/src/cli/cmd/tui/demo.ts b/packages/opencode/src/cli/cmd/tui/demo.ts
new file mode 100644
index 0000000000..a3b0648a71
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/demo.ts
@@ -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) => {
+ 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,
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index b0b48ec42c..85d0c62dc4 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -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()
+ 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 = (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 (
-
+
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 7230dae16a..ee5b7d2bda 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -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,