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/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts
index 882ff2e6c7..25c4d84f97 100644
--- a/packages/opencode/src/cli/cmd/run/runtime.ts
+++ b/packages/opencode/src/cli/cmd/run/runtime.ts
@@ -14,7 +14,7 @@
// 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
-import { createRunDemo } from "./demo"
+import type { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
@@ -135,6 +135,10 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
}
+async function createDemo(input: Parameters[0]) {
+ return (await import("./demo")).createRunDemo(input)
+}
+
async function resolveExitTitle(
ctx: BootContext,
input: RunRuntimeInput,
@@ -425,7 +429,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise {
if (input.demo) {
await ensureSession()
- state.demo = createRunDemo({
+ state.demo = await createDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
@@ -548,7 +552,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise {
state.history = []
includeFiles = true
state.demo = input.demo
- ? createRunDemo({
+ ? await createDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index c247640eba..9022daed89 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,
},
},
{
@@ -1175,6 +1177,7 @@ function getSyntaxRules(theme: Theme) {
scope: ["markup.strikethrough"],
style: {
foreground: theme.textMuted,
+ strikethrough: 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..19c1783ba6 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,20 @@ 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,
+ CodeRenderable,
+ TextTableRenderable,
+ TextRenderable,
+ StyledText,
+ type TextChunk,
+} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
AssistantMessage,
@@ -1525,15 +1538,180 @@ 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 renderBlockquoteBar = (chunks: TextChunk[]) => {
+ let lineStart = true
+ let spaces = 0
+ let replaced = false
+ let skipWhitespace = false
+ return chunks.flatMap((chunk) => {
+ const result: TextChunk[] = []
+ let next = ""
+ const flush = () => {
+ if (!next) return
+ result.push(next === chunk.text ? chunk : { ...chunk, text: next })
+ next = ""
+ }
+ for (const char of chunk.text) {
+ if (skipWhitespace && (char === " " || char === "\t")) {
+ skipWhitespace = false
+ continue
+ }
+ skipWhitespace = false
+ if (lineStart && !replaced && char === " " && spaces < 3) {
+ spaces++
+ next += char
+ continue
+ }
+ if (lineStart && !replaced && char === ">") {
+ flush()
+ result.push({ __isChunk: true, text: "│ ", fg: theme.textMuted, attributes: TextAttributes.NONE })
+ replaced = true
+ skipWhitespace = true
+ continue
+ }
+ next += char
+ if (char === "\n") {
+ lineStart = true
+ spaces = 0
+ replaced = false
+ skipWhitespace = false
+ continue
+ }
+ lineStart = false
+ }
+ flush()
+ return result
+ })
+ }
+ const trimCodeIndent = (value: string) => {
+ const lines = value.split("\n")
+ const indents = lines.filter((line) => line.trim()).map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0)
+ const indent = Math.min(...indents)
+ if (!Number.isFinite(indent) || indent === 0) return value
+ return lines.map((line) => (line.trim() ? line.slice(indent) : line)).join("\n")
+ }
+ const padTableCells = (renderable: TextTableRenderable) => {
+ renderable.content = renderable.content.map((row) =>
+ row.map((cell) => {
+ const content = cell ?? []
+ return [{ __isChunk: true, text: " " }, ...content, { __isChunk: true, text: " " }] satisfies TextChunk[]
+ }),
+ )
+ }
+ const configureMarkdown = (node: MarkdownRenderable | undefined) => {
+ if (!node) return
+ const renderNode: NonNullable = (token, context) => {
+ const content = text()
+ const firstBlock = content.startsWith(token.raw.trimStart())
+
+ if (token.type === "hr") {
+ return new BoxRenderable(node.ctx, {
+ width: "100%",
+ height: 1,
+ border: ["top"],
+ borderColor: theme.border,
+ flexShrink: 0,
+ })
+ }
+
+ if (token.type === "blockquote") {
+ const renderable = context.defaultRender()
+ if (renderable instanceof CodeRenderable) {
+ const code = renderable
+ const onChunks = code.onChunks
+ code.onChunks = (chunks, context) => {
+ const result = onChunks?.call(code, chunks, context)
+ if (result instanceof Promise) return result.then((next) => renderBlockquoteBar(next ?? chunks))
+ return renderBlockquoteBar(result ?? chunks)
+ }
+ }
+ if (!firstBlock && renderable) {
+ renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
+ }
+ return renderable
+ }
+
+ const needsCodeTopGap = token.type === "code" && !firstBlock
+ if (token.type === "code" && /^[ \t]{4,}(```|~~~)/.test(token.raw)) {
+ token.text = trimCodeIndent(token.text)
+ }
+
+ if (token.type === "table") {
+ const renderable = context.defaultRender()
+ if (renderable instanceof TextTableRenderable) padTableCells(renderable)
+ return renderable
+ }
+
+ 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 (token.type === "heading" && token.depth === 1 && !firstBlock && renderable) {
+ renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 2) : 2
+ }
+ 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..8ce611517b 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,32 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
+ const config = TuiConfig.get()
+ config.catch(() => {})
+
+ 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 +216,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 +263,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,
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index a955cb86dc..52a21f2179 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -30,6 +30,7 @@ import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
+import { SessionTimeline } from "@/session/timeline"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
@@ -85,6 +86,7 @@ export const AppLayer = Layer.mergeAll(
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
+ SessionTimeline.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 495497ecb4..5ca73fc95f 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -41,6 +41,7 @@ import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
+import { SessionTimeline } from "@/session/timeline"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
@@ -210,6 +211,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
+ SessionTimeline.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 5cf04719e5..06061e6cf7 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -4,7 +4,7 @@ import * as EffectZod from "@opencode-ai/core/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Log from "@opencode-ai/core/util/log"
-import { SessionRevert } from "./revert"
+import { SessionTimeline } from "./timeline"
import * as Session from "./session"
import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider"
@@ -114,7 +114,7 @@ export const layer = Layer.effect(
const scope = yield* Scope.Scope
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
- const revert = yield* SessionRevert.Service
+ const timeline = yield* SessionTimeline.Service
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
@@ -747,9 +747,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const { msg, part, cwd } = yield* Effect.gen(function* () {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
- if (session.revert) {
- yield* revert.cleanup(session)
- }
+ if (session.revert) yield* timeline.commitPending({ sessionID: session.id })
const agent = yield* agents.get(input.agent)
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
@@ -1378,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
"SessionPrompt.prompt",
)(function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
- yield* revert.cleanup(session)
+ yield* timeline.commitPending({ sessionID: session.id })
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
@@ -1792,7 +1790,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
- Layer.provide(SessionRevert.defaultLayer),
+ Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(
diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts
index 12c81180eb..19dff7e992 100644
--- a/packages/opencode/src/session/revert.ts
+++ b/packages/opencode/src/session/revert.ts
@@ -1,16 +1,10 @@
import { Effect, Layer, Context, Schema } from "effect"
-import { Bus } from "../bus"
-import { Snapshot } from "../snapshot"
-import { Storage } from "@/storage/storage"
-import { SyncEvent } from "../sync"
import * as Log from "@opencode-ai/core/util/log"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import * as Session from "./session"
-import { MessageV2 } from "./message-v2"
-import { SessionID, MessageID, PartID } from "./schema"
-import { SessionRunState } from "./run-state"
-import { SessionSummary } from "./summary"
+import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
+import { SessionTimeline } from "./timeline"
const log = Log.create({ service: "session.revert" })
@@ -18,6 +12,7 @@ export const RevertInput = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: Schema.optional(PartID),
+ files: Schema.optional(RewindFilePolicy),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type RevertInput = Schema.Schema.Type
@@ -32,133 +27,25 @@ export class Service extends Context.Service()("@opencode/Se
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const sessions = yield* Session.Service
- const snap = yield* Snapshot.Service
- const storage = yield* Storage.Service
- const bus = yield* Bus.Service
- const summary = yield* SessionSummary.Service
- const state = yield* SessionRunState.Service
- const sync = yield* SyncEvent.Service
+ const timeline = yield* SessionTimeline.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
- yield* state.assertNotBusy(input.sessionID)
- const all = yield* sessions.messages({ sessionID: input.sessionID })
- let lastUser: MessageV2.User | undefined
- const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
-
- let rev: Session.Info["revert"]
- const patches: Snapshot.Patch[] = []
- for (const msg of all) {
- if (msg.info.role === "user") lastUser = msg.info
- const remaining = []
- for (const part of msg.parts) {
- if (rev) {
- if (part.type === "patch") patches.push(part)
- continue
- }
-
- if (!rev) {
- if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
- const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
- rev = {
- messageID: !partID && lastUser ? lastUser.id : msg.info.id,
- partID,
- }
- }
- remaining.push(part)
- }
- }
- }
-
- if (!rev) return session
-
- rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
- if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
- yield* snap.revert(patches)
- if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
- const range = all.filter((msg) => msg.info.id >= rev.messageID)
- const diffs = yield* summary.computeDiff({ messages: range })
- yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
- yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
- yield* sessions.setRevert({
- sessionID: input.sessionID,
- revert: rev,
- summary: {
- additions: diffs.reduce((sum, x) => sum + x.additions, 0),
- deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
- files: diffs.length,
- },
- })
- return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ return yield* timeline.rewind(input)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
- yield* state.assertNotBusy(input.sessionID)
- const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
- if (!session.revert) return session
- if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
- yield* sessions.clearRevert(input.sessionID)
- return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ return yield* timeline.restore(input)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
- if (!session.revert) return
- const sessionID = session.id
- const msgs = yield* sessions.messages({ sessionID })
- const messageID = session.revert.messageID
- const remove = [] as MessageV2.WithParts[]
- let target: MessageV2.WithParts | undefined
- for (const msg of msgs) {
- if (msg.info.id < messageID) continue
- if (msg.info.id > messageID) {
- remove.push(msg)
- continue
- }
- if (session.revert.partID) {
- target = msg
- continue
- }
- remove.push(msg)
- }
- for (const msg of remove) {
- yield* sync.run(MessageV2.Event.Removed, {
- sessionID,
- messageID: msg.info.id,
- })
- }
- if (session.revert.partID && target) {
- const partID = session.revert.partID
- const idx = target.parts.findIndex((part) => part.id === partID)
- if (idx >= 0) {
- const removeParts = target.parts.slice(idx)
- target.parts = target.parts.slice(0, idx)
- for (const part of removeParts) {
- yield* sync.run(MessageV2.Event.PartRemoved, {
- sessionID,
- messageID: target.info.id,
- partID: part.id,
- })
- }
- }
- }
- yield* sessions.clearRevert(sessionID)
+ yield* timeline.commitPending({ sessionID: session.id })
})
return Service.of({ revert, unrevert, cleanup })
}),
)
-export const defaultLayer = Layer.suspend(() =>
- layer.pipe(
- Layer.provide(SessionRunState.defaultLayer),
- Layer.provide(Session.defaultLayer),
- Layer.provide(Snapshot.defaultLayer),
- Layer.provide(Storage.defaultLayer),
- Layer.provide(Bus.layer),
- Layer.provide(SessionSummary.defaultLayer),
- Layer.provide(SyncEvent.defaultLayer),
- ),
-)
+export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(SessionTimeline.defaultLayer)))
export * as SessionRevert from "./revert"
diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts
index 991c9ccc6b..55fa212693 100644
--- a/packages/opencode/src/session/schema.ts
+++ b/packages/opencode/src/session/schema.ts
@@ -33,3 +33,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
)
export type PartID = Schema.Schema.Type
+
+export const RewindFilePolicy = Schema.Union([Schema.Literal("revert"), Schema.Literal("keep")])
+export type RewindFilePolicy = Schema.Schema.Type
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index f50f8750b3..f27d2e7d1e 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -30,7 +30,7 @@ import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
-import { SessionID, MessageID, PartID } from "./schema"
+import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -166,6 +166,7 @@ const Time = Schema.Struct({
const Revert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
+ files: optionalOmitUndefined(RewindFilePolicy),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
diff --git a/packages/opencode/src/session/timeline.ts b/packages/opencode/src/session/timeline.ts
new file mode 100644
index 0000000000..4c028d135b
--- /dev/null
+++ b/packages/opencode/src/session/timeline.ts
@@ -0,0 +1,159 @@
+import { Effect, Layer, Context, Schema } from "effect"
+import { Bus } from "@/bus"
+import { Snapshot } from "@/snapshot"
+import { Storage } from "@/storage/storage"
+import { zod } from "@opencode-ai/core/effect-zod"
+import { withStatics } from "@opencode-ai/core/schema"
+import * as Session from "./session"
+import { MessageV2 } from "./message-v2"
+import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
+import { SessionRunState } from "./run-state"
+import { SessionSummary } from "./summary"
+
+export const RewindInput = Schema.Struct({
+ sessionID: SessionID,
+ messageID: MessageID,
+ partID: Schema.optional(PartID),
+ files: Schema.optional(RewindFilePolicy),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type RewindInput = Schema.Schema.Type
+
+export interface Interface {
+ readonly rewind: (input: RewindInput) => Effect.Effect
+ readonly restore: (input: { sessionID: SessionID }) => Effect.Effect
+ readonly commitPending: (input: { sessionID: SessionID }) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@opencode/SessionTimeline") {}
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const snap = yield* Snapshot.Service
+ const storage = yield* Storage.Service
+ const bus = yield* Bus.Service
+ const summary = yield* SessionSummary.Service
+ const state = yield* SessionRunState.Service
+
+ const rewind = Effect.fn("SessionTimeline.rewind")(function* (input: RewindInput) {
+ yield* state.assertNotBusy(input.sessionID)
+ const all = yield* sessions.messages({ sessionID: input.sessionID })
+ const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ const files = input.files ?? "revert"
+ let lastUser: MessageV2.User | undefined
+ let rev: Session.Info["revert"]
+ const patches: Snapshot.Patch[] = []
+ const range: MessageV2.WithParts[] = []
+
+ for (const msg of all) {
+ if (msg.info.role === "user") lastUser = msg.info
+ const remaining = []
+ for (const part of msg.parts) {
+ if (rev) {
+ if (files === "revert" && part.type === "patch") patches.push(part)
+ continue
+ }
+
+ if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
+ const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
+ rev = {
+ messageID: !partID && lastUser ? lastUser.id : msg.info.id,
+ partID,
+ ...(files === "keep" && { files }),
+ }
+ }
+ remaining.push(part)
+ }
+ if (rev) range.push(msg)
+ }
+
+ if (!rev) return session
+
+ if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
+ if (files === "revert") {
+ rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
+ yield* snap.revert(patches)
+ if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
+ }
+ const diffs = yield* summary.computeDiff({ messages: range })
+ yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
+ yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
+ yield* sessions.setRevert({
+ sessionID: input.sessionID,
+ revert: rev,
+ summary: {
+ additions: diffs.reduce((sum, x) => sum + x.additions, 0),
+ deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
+ files: diffs.length,
+ },
+ })
+ return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ })
+
+ const restore = Effect.fn("SessionTimeline.restore")(function* (input: { sessionID: SessionID }) {
+ yield* state.assertNotBusy(input.sessionID)
+ const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ if (!session.revert) return session
+ if (session.revert.files !== "keep" && session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
+ yield* sessions.clearRevert(input.sessionID)
+ return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ })
+
+ const commitPending = Effect.fn("SessionTimeline.commitPending")(function* (input: { sessionID: SessionID }) {
+ const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
+ if (!session.revert) return
+ const sessionID = session.id
+ const msgs = yield* sessions.messages({ sessionID })
+ const messageID = session.revert.messageID
+ const remove = [] as MessageV2.WithParts[]
+ let target: MessageV2.WithParts | undefined
+ for (const msg of msgs) {
+ if (msg.info.id < messageID) continue
+ if (msg.info.id > messageID) {
+ remove.push(msg)
+ continue
+ }
+ if (session.revert.partID) {
+ target = msg
+ continue
+ }
+ remove.push(msg)
+ }
+ for (const msg of remove) {
+ yield* sessions.removeMessage({
+ sessionID,
+ messageID: msg.info.id,
+ })
+ }
+ if (session.revert.partID && target) {
+ const idx = target.parts.findIndex((part) => part.id === session.revert?.partID)
+ if (idx >= 0) {
+ for (const part of target.parts.slice(idx)) {
+ yield* sessions.removePart({
+ sessionID,
+ messageID: target.info.id,
+ partID: part.id,
+ })
+ }
+ }
+ }
+ yield* sessions.clearRevert(sessionID)
+ })
+
+ return Service.of({ rewind, restore, commitPending })
+ }),
+)
+
+export const defaultLayer = Layer.suspend(() =>
+ layer.pipe(
+ Layer.provide(SessionRunState.defaultLayer),
+ Layer.provide(Session.defaultLayer),
+ Layer.provide(Snapshot.defaultLayer),
+ Layer.provide(Storage.defaultLayer),
+ Layer.provide(Bus.layer),
+ Layer.provide(SessionSummary.defaultLayer),
+ ),
+)
+
+export * as SessionTimeline from "./timeline"
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 98a69fce96..2d751eaa3a 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -30,8 +30,8 @@ import { SessionSummary } from "../../src/session/summary"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
-import { SessionRevert } from "../../src/session/revert"
import { SessionRunState } from "../../src/session/run-state"
+import { SessionTimeline } from "../../src/session/timeline"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionV2 } from "../../src/v2/session"
@@ -199,7 +199,7 @@ function makeHttp() {
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
- Layer.provide(SessionRevert.defaultLayer),
+ Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts
index c70c17d451..6db8bed922 100644
--- a/packages/opencode/test/session/revert-compact.test.ts
+++ b/packages/opencode/test/session/revert-compact.test.ts
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert"
+import { SessionTimeline } from "../../src/session/timeline"
import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot"
import * as Log from "@opencode-ai/core/util/log"
@@ -18,6 +19,7 @@ void Log.init({ print: false })
const env = Layer.mergeAll(
Session.defaultLayer,
SessionRevert.defaultLayer,
+ SessionTimeline.defaultLayer,
Snapshot.defaultLayer,
CrossSpawnSpawner.defaultLayer,
)
@@ -96,6 +98,51 @@ const tokens = {
cache: { read: 0, write: 0 },
}
+const fileTurn = Effect.fn("test.fileTurn")(function* (input: {
+ sessionID: SessionID
+ dir: string
+ file: string
+ content: string
+}) {
+ const session = yield* Session.Service
+ const snapshot = yield* Snapshot.Service
+ const userMsg = yield* user(input.sessionID)
+ yield* text(input.sessionID, userMsg.id, `${input.file}:${input.content}`)
+ const assistantMsg = yield* assistant(input.sessionID, userMsg.id, input.dir)
+ const before = yield* snapshot.track()
+ if (!before) throw new Error("expected snapshot")
+ yield* write(path.join(input.dir, input.file), input.content)
+ const after = yield* snapshot.track()
+ if (!after) throw new Error("expected snapshot")
+ const patch = yield* snapshot.patch(before)
+ yield* session.updatePart({
+ id: PartID.ascending(),
+ messageID: assistantMsg.id,
+ sessionID: input.sessionID,
+ type: "step-start",
+ snapshot: before,
+ })
+ yield* session.updatePart({
+ id: PartID.ascending(),
+ messageID: assistantMsg.id,
+ sessionID: input.sessionID,
+ type: "step-finish",
+ reason: "stop",
+ snapshot: after,
+ cost: 0,
+ tokens,
+ })
+ yield* session.updatePart({
+ id: PartID.ascending(),
+ messageID: assistantMsg.id,
+ sessionID: input.sessionID,
+ type: "patch",
+ hash: patch.hash,
+ files: patch.files,
+ })
+ return { user: userMsg, assistant: assistantMsg }
+})
+
describe("revert + compact workflow", () => {
it.live(
"should properly handle compact command after revert",
@@ -636,4 +683,58 @@ describe("revert + compact workflow", () => {
{ git: true },
),
)
+
+ it.live(
+ "timeline rewind can keep file changes while hiding future messages",
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const session = yield* Session.Service
+ const timeline = yield* SessionTimeline.Service
+
+ yield* write(path.join(dir, "a.txt"), "a0")
+
+ const info = yield* session.create({})
+ const sid = info.id
+ const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" })
+
+ const rewound = yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" })
+ expect(rewound.revert?.messageID).toBe(turn.user.id)
+ expect(rewound.revert?.files).toBe("keep")
+ expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+
+ yield* timeline.commitPending({ sessionID: rewound.id })
+ expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([])
+ expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+ }),
+ { git: true },
+ ),
+ )
+
+ it.live(
+ "timeline restore keeps files for keep-file rewinds",
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const session = yield* Session.Service
+ const timeline = yield* SessionTimeline.Service
+
+ yield* write(path.join(dir, "a.txt"), "a0")
+
+ const info = yield* session.create({})
+ const sid = info.id
+ const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" })
+
+ yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" })
+ const restored = yield* timeline.restore({ sessionID: sid })
+ expect(restored.revert).toBeUndefined()
+ expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([
+ turn.user.id,
+ turn.assistant.id,
+ ])
+ expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+ }),
+ { git: true },
+ ),
+ )
})
diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts
index e9628ce49f..b2408a2628 100644
--- a/packages/opencode/test/session/schema-decoding.test.ts
+++ b/packages/opencode/test/session/schema-decoding.test.ts
@@ -4,6 +4,7 @@ import { Schema } from "effect"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
+import { SessionTimeline } from "../../src/session/timeline"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Todo } from "../../src/session/todo"
@@ -218,8 +219,8 @@ describe("Session input schemas", () => {
describe("SessionRevert.RevertInput", () => {
const decode = decodeUnknown(SessionRevert.RevertInput)
- test("messageID is required, partID is optional", () => {
- const withPart = { sessionID, messageID, partID }
+ test("messageID is required, partID and file policy are optional", () => {
+ const withPart = { sessionID, messageID, partID, files: "keep" as const }
expect(decode(withPart)).toEqual(withPart)
expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
@@ -232,6 +233,20 @@ describe("SessionRevert.RevertInput", () => {
})
})
+describe("SessionTimeline.RewindInput", () => {
+ const decode = decodeUnknown(SessionTimeline.RewindInput)
+
+ test("accepts optional file policy", () => {
+ const keep = { sessionID, messageID, files: "keep" as const }
+ expect(decode(keep)).toEqual(keep)
+ expect(SessionTimeline.RewindInput.zod.parse(keep)).toEqual(keep)
+
+ const revert = { sessionID, messageID, partID, files: "revert" as const }
+ expect(decode(revert)).toEqual(revert)
+ expect(SessionTimeline.RewindInput.zod.parse(revert)).toEqual(revert)
+ })
+})
+
describe("SessionSummary.DiffInput", () => {
const decode = decodeUnknown(SessionSummary.DiffInput)
diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts
index 38531d15b4..0601b7d9f6 100644
--- a/packages/opencode/test/session/session-schema.test.ts
+++ b/packages/opencode/test/session/session-schema.test.ts
@@ -63,13 +63,14 @@ describe("Session schema", () => {
revert: {
messageID: MessageID.ascending(),
partID: undefined,
+ files: undefined,
snapshot: undefined,
diff: undefined,
},
}) as Record
expect(Object.hasOwn(encoded.summary as Record, "diffs")).toBe(false)
- for (const key of ["partID", "snapshot", "diff"]) {
+ for (const key of ["partID", "files", "snapshot", "diff"]) {
expect(Object.hasOwn(encoded.revert as Record, key)).toBe(false)
}
})
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 251a4acf3f..136d174733 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -19,8 +19,8 @@ import path from "path"
import { Session } from "@/session/session"
import { LLM } from "../../src/session/llm"
import { SessionPrompt } from "../../src/session/prompt"
-import { SessionRevert } from "../../src/session/revert"
import { SessionSummary } from "../../src/session/summary"
+import { SessionTimeline } from "../../src/session/timeline"
import { MessageV2 } from "../../src/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { provideTmpdirServer } from "../fixture/fixture"
@@ -150,7 +150,7 @@ function makeHttp() {
TestLLMServer.layer,
SessionSummary.defaultLayer,
SessionPrompt.layer.pipe(
- Layer.provide(SessionRevert.defaultLayer),
+ Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provideMerge(run),
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index bf3201a5c0..1701e6305d 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -3844,6 +3844,7 @@ export class Session2 extends HeyApiClient {
workspace?: string
messageID?: string
partID?: string
+ files?: "revert" | "keep"
},
options?: Options,
) {
@@ -3857,6 +3858,7 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
{ in: "body", key: "partID" },
+ { in: "body", key: "files" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index ae7e9767ce..787ddd561d 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -5,6 +5,12 @@ export type ClientOptions = {
}
export type Event =
+ | EventTuiPromptAppend
+ | EventTuiCommandExecute
+ | EventTuiToastShow1
+ | EventTuiSessionSelect
+ | EventServerConnected
+ | EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@@ -24,10 +30,6 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
- | EventTuiPromptAppend
- | EventTuiCommandExecute
- | EventTuiToastShow1
- | EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@@ -75,8 +77,6 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
- | EventServerConnected
- | EventGlobalDisposed
export type OAuth = {
type: "oauth"
@@ -103,6 +103,61 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
+export type EventTuiPromptAppend = {
+ id: string
+ type: "tui.prompt.append"
+ properties: {
+ text: string
+ }
+}
+
+export type EventTuiCommandExecute = {
+ id: string
+ type: "tui.command.execute"
+ properties: {
+ command:
+ | "session.list"
+ | "session.new"
+ | "session.share"
+ | "session.interrupt"
+ | "session.compact"
+ | "session.page.up"
+ | "session.page.down"
+ | "session.line.up"
+ | "session.line.down"
+ | "session.half.page.up"
+ | "session.half.page.down"
+ | "session.first"
+ | "session.last"
+ | "prompt.clear"
+ | "prompt.submit"
+ | "agent.cycle"
+ | string
+ }
+}
+
+export type EventTuiToastShow = {
+ id: string
+ type: "tui.toast.show"
+ properties: {
+ title?: string
+ message: string
+ variant: "info" | "success" | "warning" | "error"
+ duration?: number
+ }
+}
+
+export type EventTuiSessionSelect = {
+ id: string
+ type: "tui.session.select"
+ properties: {
+ /**
+ * Session ID to navigate to
+ */
+ sessionID: string
+ }
+}
+
export type PermissionRequest = {
id: string
sessionID: string
@@ -280,61 +335,6 @@ export type SessionStatus =
type: "busy"
}
-export type EventTuiPromptAppend = {
- id: string
- type: "tui.prompt.append"
- properties: {
- text: string
- }
-}
-
-export type EventTuiCommandExecute = {
- id: string
- type: "tui.command.execute"
- properties: {
- command:
- | "session.list"
- | "session.new"
- | "session.share"
- | "session.interrupt"
- | "session.compact"
- | "session.page.up"
- | "session.page.down"
- | "session.line.up"
- | "session.line.down"
- | "session.half.page.up"
- | "session.half.page.down"
- | "session.first"
- | "session.last"
- | "prompt.clear"
- | "prompt.submit"
- | "agent.cycle"
- | string
- }
-}
-
-export type EventTuiToastShow = {
- id: string
- type: "tui.toast.show"
- properties: {
- title?: string
- message: string
- variant: "info" | "success" | "warning" | "error"
- duration?: number
- }
-}
-
-export type EventTuiSessionSelect = {
- id: string
- type: "tui.session.select"
- properties: {
- /**
- * Session ID to navigate to
- */
- sessionID: string
- }
-}
-
export type Project = {
id: string
worktree: string
@@ -762,6 +762,7 @@ export type Session = {
revert?: {
messageID: string
partID?: string
+ files?: "revert" | "keep"
snapshot?: string
diff?: string
}
@@ -778,6 +779,12 @@ export type GlobalEvent = {
project?: string
workspace?: string
payload:
+ | EventTuiPromptAppend
+ | EventTuiCommandExecute
+ | EventTuiToastShow
+ | EventTuiSessionSelect
+ | EventServerConnected
+ | EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@@ -797,10 +804,6 @@ export type GlobalEvent = {
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
- | EventTuiPromptAppend
- | EventTuiCommandExecute
- | EventTuiToastShow
- | EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@@ -848,8 +851,6 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
- | EventServerConnected
- | EventGlobalDisposed
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@@ -1450,6 +1451,7 @@ export type GlobalSession = {
revert?: {
messageID: string
partID?: string
+ files?: "revert" | "keep"
snapshot?: string
diff?: string
}
@@ -1913,6 +1915,7 @@ export type SyncEventSessionUpdated = {
revert?: {
messageID: string
partID?: string
+ files?: "revert" | "keep"
snapshot?: string
diff?: string
} | null
@@ -2330,6 +2333,22 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
+export type EventServerConnected = {
+ id: string
+ type: "server.connected"
+ properties: {
+ [key: string]: unknown
+ }
+}
+
+export type EventGlobalDisposed = {
+ id: string
+ type: "global.disposed"
+ properties: {
+ [key: string]: unknown
+ }
+}
+
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
@@ -3044,22 +3063,6 @@ export type EventSessionNextCompactionEnded = {
}
}
-export type EventServerConnected = {
- id: string
- type: "server.connected"
- properties: {
- [key: string]: unknown
- }
-}
-
-export type EventGlobalDisposed = {
- id: string
- type: "global.disposed"
- properties: {
- [key: string]: unknown
- }
-}
-
export type SessionInfo = {
id: string
parentID?: string
@@ -5935,6 +5938,7 @@ export type SessionRevertData = {
body?: {
messageID: string
partID?: string
+ files?: "revert" | "keep"
}
path: {
sessionID: string