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