feat(tui): add fullscreen markdown demo
This commit is contained in:
parent
5469155eed
commit
13b2917f74
8 changed files with 600 additions and 55 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
181
packages/opencode/src/cli/cmd/demo-fixtures.ts
Normal file
181
packages/opencode/src/cli/cmd/demo-fixtures.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
export const SAMPLE_MARKDOWN = [
|
||||
"# Direct Mode Demo",
|
||||
"",
|
||||
"This is a realistic assistant response for direct-mode formatting checks.",
|
||||
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"- Restored the final markdown flush so the last block is committed on idle.",
|
||||
"- Switched markdown scrollback commits back to top-level block boundaries.",
|
||||
"- Added footer-level regression coverage for split-footer rendering.",
|
||||
"",
|
||||
"## Status",
|
||||
"",
|
||||
"| Area | Before | After | Notes |",
|
||||
"| --- | --- | --- | --- |",
|
||||
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
|
||||
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
|
||||
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
|
||||
"",
|
||||
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
|
||||
"",
|
||||
"```ts",
|
||||
"const result = { markdown: true, tables: 2, stable: true }",
|
||||
"```",
|
||||
"",
|
||||
"## Files",
|
||||
"",
|
||||
"| File | Change |",
|
||||
"| --- | --- |",
|
||||
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
|
||||
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
|
||||
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
|
||||
"",
|
||||
"Next step: run `/fmt table` if you want a tighter table-only sample.",
|
||||
].join("\n")
|
||||
|
||||
export const SAMPLE_TABLE = [
|
||||
"# Table Sample",
|
||||
"",
|
||||
"| Kind | Example | Notes |",
|
||||
"| --- | --- | --- |",
|
||||
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
|
||||
"| Unicode | `漢字` | Wide characters should remain aligned |",
|
||||
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
|
||||
"| Status | done | Final row should still appear after idle |",
|
||||
].join("\n")
|
||||
|
||||
export const MARKDOWN_PATTERNS = {
|
||||
"md-code": [
|
||||
"# Interleaved Code",
|
||||
"",
|
||||
"Start with a short conclusion before any code appears.",
|
||||
"",
|
||||
"```ts",
|
||||
"export function parse(input: string) {",
|
||||
" return input.trim().split(/\\s+/)",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"Then continue with prose immediately after the code block. This should not inherit code styling or indentation.",
|
||||
"",
|
||||
"```tsx",
|
||||
"<Show when={props.enabled}>",
|
||||
" <markdown content={props.text} streaming />",
|
||||
"</Show>",
|
||||
"```",
|
||||
"",
|
||||
"Final paragraph after a second fence with `inline code`, **bold text**, and _emphasis_ mixed together.",
|
||||
].join("\n"),
|
||||
"md-fence": [
|
||||
"# Fence Boundaries",
|
||||
"",
|
||||
"The renderer should recover cleanly around multiple fences and nearby paragraphs.",
|
||||
"",
|
||||
"```bash",
|
||||
"bun run test -- --grep markdown",
|
||||
"```",
|
||||
"Text directly after a fence.",
|
||||
"```json",
|
||||
"{",
|
||||
' "status": "ok",',
|
||||
' "items": ["one", "two"]',
|
||||
"}",
|
||||
"```",
|
||||
"Trailing paragraph after JSON. The next fence intentionally has no language.",
|
||||
"```",
|
||||
"plain fenced text",
|
||||
"with multiple lines",
|
||||
"```",
|
||||
].join("\n"),
|
||||
"md-list": [
|
||||
"# Lists With Code",
|
||||
"",
|
||||
"1. First ordered item with `inline code`.",
|
||||
"2. Second ordered item before a nested list:",
|
||||
" - Nested bullet with a long phrase that should wrap without swallowing the marker or changing indentation.",
|
||||
" - Nested bullet before fenced code:",
|
||||
"",
|
||||
" ```ts",
|
||||
" const nested = true",
|
||||
" ```",
|
||||
"",
|
||||
"3. Third ordered item after the nested fence.",
|
||||
"",
|
||||
"- Top-level bullet after ordered list.",
|
||||
"- Another bullet with a paragraph below.",
|
||||
"",
|
||||
" Continuation paragraph should stay associated with the bullet without becoming code.",
|
||||
].join("\n"),
|
||||
"md-table-code": [
|
||||
"# Tables And Code",
|
||||
"",
|
||||
"| Case | Input | Expected |",
|
||||
"| --- | --- | --- |",
|
||||
"| Inline code | `const x = 1` | stays inline |",
|
||||
"| Escaped pipe | `A\\|B` | one cell |",
|
||||
"| Long token | `LongTokenWithoutNaturalBreaks_1234567890_abcdefghijklmnopqrstuvwxyz` | wraps or scrolls predictably |",
|
||||
"",
|
||||
"A code block follows the table:",
|
||||
"",
|
||||
"```ts",
|
||||
"const rows = [",
|
||||
' { case: "inline code", expected: "stays inline" },',
|
||||
' { case: "escaped pipe", expected: "one cell" },',
|
||||
"]",
|
||||
"```",
|
||||
"",
|
||||
"And then another compact table:",
|
||||
"",
|
||||
"| A | B |",
|
||||
"| - | - |",
|
||||
"| done | yes |",
|
||||
].join("\n"),
|
||||
"md-inline": [
|
||||
"# Inline Markdown",
|
||||
"",
|
||||
"This paragraph mixes [a normal link](https://opencode.ai), `https://example.com/code-link`, `inline code`, **strong**, _emphasis_, and ~~strikethrough~~.",
|
||||
"",
|
||||
"> Blockquote with `inline code` and [a link](https://example.com) should keep quote styling while wrapping.",
|
||||
"",
|
||||
"A horizontal rule follows.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"After the rule, text should resume normal spacing.",
|
||||
].join("\n"),
|
||||
"md-kitchen": [
|
||||
"# Markdown Kitchen Sink",
|
||||
"",
|
||||
"This combines headings, paragraphs, lists, blockquotes, tables, inline code, and multiple code fences.",
|
||||
"",
|
||||
"## Steps",
|
||||
"",
|
||||
"1. Read the response.",
|
||||
"2. Notice `inline code` before a block.",
|
||||
"",
|
||||
"```ts",
|
||||
"type Result = { ok: boolean; reason?: string }",
|
||||
"const result: Result = { ok: true }",
|
||||
"```",
|
||||
"",
|
||||
"3. Continue the list after the block.",
|
||||
"",
|
||||
"> Quoted note after the list. It should not merge into the previous item.",
|
||||
"",
|
||||
"| Feature | Stress |",
|
||||
"| --- | --- |",
|
||||
"| Markdown | prose/code/table interleave |",
|
||||
"| Renderer | wrapping and spacing |",
|
||||
"",
|
||||
"```diff",
|
||||
"- const renderer = oldMarkdown",
|
||||
"+ const renderer = experimentalMarkdown",
|
||||
"```",
|
||||
"",
|
||||
"Final paragraph with [docs](https://opencode.ai/docs) and `https://example.com/from-code`.",
|
||||
].join("\n"),
|
||||
} as const
|
||||
|
||||
export const MARKDOWN_PATTERN_KINDS = Object.keys(MARKDOWN_PATTERNS) as Array<keyof typeof MARKDOWN_PATTERNS>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -962,6 +962,8 @@ function getSyntaxRules(theme: Theme) {
|
|||
style: {
|
||||
foreground: theme.markdownHeading,
|
||||
bold: true,
|
||||
italic: true,
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
285
packages/opencode/src/cli/cmd/tui/demo.ts
Normal file
285
packages/opencode/src/cli/cmd/tui/demo.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import type {
|
||||
Agent,
|
||||
AssistantMessage,
|
||||
Config,
|
||||
Message,
|
||||
Model,
|
||||
Part,
|
||||
Path,
|
||||
Project,
|
||||
Provider,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
|
||||
|
||||
const sessionID = "demo_tui_markdown"
|
||||
const userMessageID = "demo_tui_user"
|
||||
const assistantMessageID = "demo_tui_assistant"
|
||||
const now = Date.now()
|
||||
|
||||
const markdown = [
|
||||
"# Fullscreen TUI Markdown Demo",
|
||||
"",
|
||||
"This fake assistant response runs through the fullscreen session timeline without calling an LLM.",
|
||||
"Use it to compare spacing, wrapping, code fence boundaries, table behavior, and inline markdown rendering.",
|
||||
"",
|
||||
"## Baseline",
|
||||
"",
|
||||
SAMPLE_MARKDOWN,
|
||||
"",
|
||||
"## Table Baseline",
|
||||
"",
|
||||
SAMPLE_TABLE,
|
||||
"",
|
||||
...Object.entries(MARKDOWN_PATTERNS).flatMap(([name, value]) => ["## " + name, "", value, ""]),
|
||||
].join("\n")
|
||||
|
||||
const model = {
|
||||
id: "demo",
|
||||
providerID: "demo",
|
||||
api: {
|
||||
id: "demo",
|
||||
url: "https://example.com/demo",
|
||||
npm: "demo",
|
||||
},
|
||||
name: "Demo",
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: true,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 128_000,
|
||||
output: 16_000,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
} satisfies Model
|
||||
|
||||
const provider = {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
demo: model,
|
||||
},
|
||||
} satisfies Provider
|
||||
|
||||
const agent = {
|
||||
name: "build",
|
||||
description: "Demo agent",
|
||||
mode: "primary",
|
||||
native: true,
|
||||
permission: [],
|
||||
model: {
|
||||
providerID: "demo",
|
||||
modelID: "demo",
|
||||
},
|
||||
options: {},
|
||||
} satisfies Agent
|
||||
|
||||
function json(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTuiDemo(input: { directory: string }) {
|
||||
const paths = {
|
||||
home: process.env.HOME ?? input.directory,
|
||||
state: input.directory,
|
||||
config: input.directory,
|
||||
worktree: input.directory,
|
||||
directory: input.directory,
|
||||
} satisfies Path
|
||||
|
||||
const project = {
|
||||
id: "demo_project",
|
||||
worktree: input.directory,
|
||||
vcs: "git",
|
||||
name: "Markdown Demo",
|
||||
time: {
|
||||
created: now,
|
||||
updated: now,
|
||||
},
|
||||
sandboxes: [],
|
||||
} satisfies Project
|
||||
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "markdown-demo",
|
||||
projectID: project.id,
|
||||
directory: input.directory,
|
||||
title: "Markdown Rendering Demo",
|
||||
agent: agent.name,
|
||||
model: {
|
||||
id: model.id,
|
||||
providerID: provider.id,
|
||||
},
|
||||
version: "demo",
|
||||
time: {
|
||||
created: now,
|
||||
updated: now + 2,
|
||||
},
|
||||
} satisfies Session
|
||||
|
||||
const user = {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
agent: agent.name,
|
||||
model: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
} satisfies Message
|
||||
|
||||
const assistant = {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now + 1,
|
||||
completed: now + 2,
|
||||
},
|
||||
parentID: userMessageID,
|
||||
modelID: model.id,
|
||||
providerID: provider.id,
|
||||
mode: "demo",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: input.directory,
|
||||
root: input.directory,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 3_200,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
} satisfies AssistantMessage
|
||||
|
||||
const messages = [
|
||||
{
|
||||
info: user,
|
||||
parts: [
|
||||
{
|
||||
id: "demo_tui_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Show me the fullscreen TUI markdown rendering stress cases.",
|
||||
time: {
|
||||
start: now,
|
||||
end: now,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: assistant,
|
||||
parts: [
|
||||
{
|
||||
id: "demo_tui_assistant_text",
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: markdown,
|
||||
time: {
|
||||
start: now + 1,
|
||||
end: now + 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies Array<{ info: Message; parts: Part[] }>
|
||||
|
||||
const fetch = (async (...args: Parameters<typeof globalThis.fetch>) => {
|
||||
const request = new Request(args[0], args[1])
|
||||
const url = new URL(request.url)
|
||||
const pathname = url.pathname
|
||||
|
||||
if (request.method === "GET" && pathname === "/path") return json(paths)
|
||||
if (request.method === "GET" && pathname === "/project/current") return json(project)
|
||||
if (request.method === "GET" && pathname === "/config/providers") {
|
||||
return json({ providers: [provider], default: { build: "demo/demo" } })
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/provider") {
|
||||
return json({ all: [provider], default: { build: "demo/demo" }, connected: [provider.id] })
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/experimental/console") {
|
||||
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/agent") return json([agent])
|
||||
if (request.method === "GET" && pathname === "/config") {
|
||||
return json({ model: "demo/demo", default_agent: agent.name } satisfies Config)
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/session") return json([session])
|
||||
if (request.method === "GET" && pathname === "/command") return json([])
|
||||
if (request.method === "GET" && pathname === "/lsp") return json([])
|
||||
if (request.method === "GET" && pathname === "/mcp") return json({})
|
||||
if (request.method === "GET" && pathname === "/experimental/resource") return json({})
|
||||
if (request.method === "GET" && pathname === "/formatter") return json([])
|
||||
if (request.method === "GET" && pathname === "/session/status") return json({ [sessionID]: { type: "idle" } })
|
||||
if (request.method === "GET" && pathname === "/provider/auth") return json({})
|
||||
if (request.method === "GET" && pathname === "/vcs") return json({ branch: "demo", default_branch: "dev" })
|
||||
if (request.method === "GET" && pathname === "/experimental/workspace") return json([])
|
||||
if (request.method === "GET" && pathname === "/experimental/workspace/status") return json([])
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}`) return json(session)
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/message`) return json(messages)
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/todo`) return json([])
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/diff`) return json([])
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/children`) return json([])
|
||||
|
||||
return json({ message: `Unhandled demo endpoint: ${request.method} ${pathname}` }, 404)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
const events = {
|
||||
subscribe: async () => () => {},
|
||||
} satisfies EventSource
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
fetch,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,18 @@ import { useEvent } from "@tui/context/event"
|
|||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import {
|
||||
BoxRenderable,
|
||||
ScrollBoxRenderable,
|
||||
addDefaultParsers,
|
||||
TextAttributes,
|
||||
RGBA,
|
||||
type MarkdownOptions,
|
||||
type MarkdownRenderable,
|
||||
TextRenderable,
|
||||
StyledText,
|
||||
type TextChunk,
|
||||
} from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
|
|
@ -1525,15 +1536,87 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
|||
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const text = createMemo(() => props.part.text.trim())
|
||||
const diffCache = new Map<string, TextChunk[]>()
|
||||
const colorDiffChunks = (text: string) => {
|
||||
const key = `${theme.diffAdded.toString()}:${theme.diffRemoved.toString()}:${text}`
|
||||
const cached = diffCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
let line: "added" | "removed" | undefined
|
||||
let start = true
|
||||
const chunks = (text.match(/[^\n]+|\n/g) ?? [text]).map((part): TextChunk => {
|
||||
if (start && part !== "\n") {
|
||||
line = part.startsWith("+") ? "added" : part.startsWith("-") ? "removed" : undefined
|
||||
start = false
|
||||
}
|
||||
const next = {
|
||||
__isChunk: true,
|
||||
text: part,
|
||||
...(line === "added" ? { fg: theme.diffAdded } : {}),
|
||||
...(line === "removed" ? { fg: theme.diffRemoved } : {}),
|
||||
} satisfies TextChunk
|
||||
if (part === "\n") {
|
||||
line = undefined
|
||||
start = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
diffCache.set(key, chunks)
|
||||
if (diffCache.size > 20) diffCache.delete(diffCache.keys().next().value!)
|
||||
return chunks
|
||||
}
|
||||
const configureMarkdown = (node: MarkdownRenderable | undefined) => {
|
||||
if (!node) return
|
||||
const renderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
|
||||
if (token.type === "hr") {
|
||||
return new BoxRenderable(node.ctx, {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
border: ["top"],
|
||||
borderColor: theme.border,
|
||||
flexShrink: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const needsCodeTopGap = token.type === "code" && !text().startsWith(token.raw.trimStart())
|
||||
if (token.type === "code" && token.lang?.trim().toLowerCase() === "diff") {
|
||||
const renderable = new TextRenderable(node.ctx, {
|
||||
content: new StyledText(colorDiffChunks(token.text)),
|
||||
width: "100%",
|
||||
flexShrink: 0,
|
||||
})
|
||||
if (needsCodeTopGap) renderable.marginTop = 1
|
||||
return renderable
|
||||
}
|
||||
|
||||
const renderable = context.defaultRender()
|
||||
if (needsCodeTopGap && renderable) {
|
||||
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
|
||||
}
|
||||
return renderable
|
||||
}
|
||||
|
||||
// OpenTUI Solid constructs elements with only `{ id }`, so constructor-only
|
||||
// MarkdownOptions need to be installed on the renderable directly.
|
||||
const target = node as unknown as {
|
||||
_internalBlockMode: "top-level"
|
||||
_renderNode: typeof renderNode
|
||||
}
|
||||
target._internalBlockMode = "top-level"
|
||||
target._renderNode = renderNode
|
||||
}
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<Show when={text()}>
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
streaming={true}
|
||||
content={props.part.text.trim()}
|
||||
ref={configureMarkdown}
|
||||
tableOptions={{ style: "grid", widthMode: "content" }}
|
||||
content={text()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
|
|
@ -1545,7 +1628,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
|||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={props.part.text.trim()}
|
||||
content={text()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.text}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ export const TuiThreadCommand = cmd({
|
|||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("demo", {
|
||||
type: "boolean",
|
||||
describe: "open a fake fullscreen TUI session for renderer debugging",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||
|
|
@ -130,7 +134,6 @@ export const TuiThreadCommand = cmd({
|
|||
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||
// chdir so the thread and worker share the same directory key.
|
||||
const next = resolveThreadDirectory(args.project)
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(next)
|
||||
} catch {
|
||||
|
|
@ -138,6 +141,31 @@ export const TuiThreadCommand = cmd({
|
|||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
const config = TuiConfig.get()
|
||||
|
||||
if (args.demo) {
|
||||
const { createTuiDemo } = await import("./demo")
|
||||
const { tui } = await import("./app")
|
||||
const demo = createTuiDemo({ directory: cwd })
|
||||
await tui({
|
||||
url: "http://opencode.demo",
|
||||
config: await config,
|
||||
directory: cwd,
|
||||
fetch: demo.fetch,
|
||||
events: demo.events,
|
||||
args: {
|
||||
continue: false,
|
||||
sessionID: demo.sessionID,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt: await input(args.prompt),
|
||||
fork: false,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const file = await target()
|
||||
const env = sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
|
|
@ -187,8 +215,6 @@ export const TuiThreadCommand = cmd({
|
|||
}
|
||||
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
|
|
@ -236,7 +262,7 @@ export const TuiThreadCommand = cmd({
|
|||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
config: await config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue