Compare commits

...
Author SHA1 Message Date
David Hill 8f1728f1a8 feat(tui): add a simple markdown experiment for the prompt and user messages
Move the Slack-style Markdown parser to @opencode/util so the TUI, session UI, and app share it. Behind the simple_markdown experiment, the TUI prompt styles marks while typing with muted markers, continues and ends lists on newline, and removes list markers on Backspace. User messages in the timeline render marks, lists, quotes, and code.
2026-10-03 11:48:16 -06:00
David Hill 920e5e1f09 feat(app): render simple markdown in the composer and user messages 2026-10-03 11:16:17 -06:00
23 changed files with 1871 additions and 304 deletions

No files matched your search

@@ -262,6 +262,73 @@ story("renders a draft once and supports editing, caret restoration, and failure
await expect(input).toHaveText("Preserve this draft on failure")
})
story("edits Slack-style lists and submits them as Markdown", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
await input.click()
await page.keyboard.type("Plan:")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("- one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("two")
await page.keyboard.press("Shift+Enter")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("1. first @rev")
await expect(input.locator("ul > li")).toHaveText(["one", "two"])
await expect(input.locator("ol > li")).toHaveText(["first @rev"])
await expect(component.locator('[data-suggestion-id="agent:review"]')).toBeVisible()
await page.keyboard.press("Enter")
await expect(input.locator("ol > li [data-mention=agent]")).toHaveText("@review")
await input.locator("li", { hasText: "two" }).click()
await page.keyboard.press("Home")
await page.keyboard.press("Backspace")
await expect(input.locator("ul > li")).toHaveText(["one"])
await page.keyboard.press("Enter")
await expect(component.getByRole("status")).toContainText("Submitted:")
expect(await component.getByRole("status").textContent()).toBe("Submitted: Plan:\n- one\ntwo\n1. first @review ")
})
story("previews Slack-style inline marks while typing", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
await input.click()
await page.keyboard.type("*hello* _link_ `code` done")
await expect(input.locator('[data-md="bold"]')).toHaveText("*hello*")
await expect(input.locator('[data-md="bold"]')).toHaveCSS("font-weight", "600")
await expect(input.locator('[data-md="italic"]')).toHaveText("_link_")
await expect(input.locator('[data-md="code"]')).toHaveText("`code`")
await expect(input.locator('[data-md="marker"]')).toHaveCount(6)
await page.keyboard.press("Enter")
await expect(component.getByRole("status")).toContainText("Submitted:")
expect(await component.getByRole("status").textContent()).toBe("Submitted: *hello* _link_ `code` done")
})
story("fades overflowing prompt text above the controls until the caret line is at the end", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const viewport = component.locator('[data-component="composer-scroll"] .scroll-view__viewport')
const mask = () => viewport.evaluate((element) => getComputedStyle(element).maskImage)
const clear = "linear-gradient(rgb(0, 0, 0) 100%, rgba(0, 0, 0, 0))"
await input.click()
await expect.poll(mask).toBe(clear)
for (let line = 1; line <= 12; line++) {
if (line > 1) await page.keyboard.press("Shift+Enter")
await page.keyboard.type(`Line ${line}`)
}
await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect.poll(mask).toBe(clear)
await viewport.evaluate((element) => {
element.scrollTop = 0
})
await expect.poll(mask).toBe("linear-gradient(rgb(0, 0, 0) calc(100% - 24px), rgba(0, 0, 0, 0))")
})
story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
@@ -5,6 +5,7 @@ import { createBlobReference } from "@/runtime/persistence/drafts"
import { uuid } from "@/runtime/persistence/uuid"
import type { ComposerPrompt } from "../types"
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
import { getCursorPosition } from "../editor/dom"
import type { AttachmentDestination } from "./destination"
import { uploads } from "./uploads"
@@ -47,7 +48,7 @@ export function createComposerAttachments(
const prompt = input.capture()
const editor = input.editor()
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
}
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
@@ -263,17 +264,6 @@ async function attachmentMime(file: File) {
return "text/plain"
}
function cursorPosition(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
if (!editor.contains(range.startContainer)) return 0
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString().replace(/\u200B/g, "").length
}
function largePaste(text: string) {
if (text.length >= 8000) return true
return text.split("\n").length - 1 >= 120
@@ -241,6 +241,14 @@ export const MultilineDraft = {
render: () => <ComposerStory prompt={text("Review the implementation\nThen run the focused tests")} />,
}
export const ListDraft = {
render: () => (
<ComposerStory
prompt={text("Plan the change:\n- Read the timeline code\n- Add focused tests\n1. Build the app\n2. Ship it")}
/>
),
}
export const MixedAttachments = {
render: () => (
<ComposerStory
+179 -18
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { getCursorPosition, getTextLength, setCursorPosition } from "./dom"
import type { ComposerPrompt } from "../types"
import { getCursorPosition, readComposerEditor, renderComposerEditor, setCursorPosition } from "./dom"
const br = () => document.createElement("br")
const text = (value: string) => document.createTextNode(value)
@@ -9,51 +10,211 @@ const pill = () => {
element.textContent = "@file"
return element
}
const element = (tag: string, ...children: Node[]) => {
const node = document.createElement(tag)
node.append(...children)
return node
}
const promptText = (prompt: ComposerPrompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
// Breaks count as one character and zero-width characters count as none.
// Each caret row is [position, anchor child index or null for the container, anchor offset].
test.each<{ name: string; nodes: () => Node[]; length: number; caret: [number, number | null, number][] }>([
// Breaks count as one character and zero-width characters count as none. List markers exist only in the prompt.
// Each caret row is [position, child index path from the container (empty for the container), anchor offset].
test.each<{ name: string; nodes: () => Node[]; text: string; caret: [number, number[], number][] }>([
{
name: "zero-width characters",
nodes: () => [text("ab\u200B"), br(), text("cd")],
length: 5,
text: "ab\ncd",
caret: [
[3, 2, 0],
[4, 2, 1],
[3, [2], 0],
[4, [2], 1],
],
},
{
name: "pills and breaks",
nodes: () => [text("ab"), pill(), br(), text("cd")],
length: 10,
text: "ab@file\ncd",
caret: [
[2, 0, 2],
[7, null, 2],
[8, 3, 0],
[2, [0], 2],
[7, [], 2],
[8, [3], 0],
],
},
{
name: "blank lines",
nodes: () => [text("a"), br(), br(), text("b")],
length: 4,
text: "a\n\nb",
caret: [
[2, null, 2],
[3, 3, 0],
[2, [], 2],
[3, [3], 0],
],
},
])("maps text length and the caret across $name", (row) => {
{
name: "list items",
nodes: () => [text("intro\n"), element("ul", element("li", text("a")), element("li", br())), text("after")],
text: "intro\n- a\n- \nafter",
caret: [
[8, [1, 0, 0], 0],
[9, [1, 0, 0], 1],
[12, [1, 1], 0],
[13, [2], 0],
],
},
{
name: "numbered items and line blocks",
nodes: () => {
const list = element("ol", element("li", text("x")), element("li", text("y")))
if (list instanceof HTMLOListElement) list.start = 3
return [list, element("div", br()), element("div", text("z"))]
},
text: "3. x\n4. y\n\nz",
caret: [
[3, [0, 0, 0], 0],
[10, [1], 0],
[11, [2, 0], 0],
],
},
])("maps the prompt and the caret across $name", (row) => {
const container = document.createElement("div")
container.append(...row.nodes())
document.body.appendChild(container)
expect(getTextLength(container)).toBe(row.length)
row.caret.forEach(([position, child, offset]) => {
expect(promptText(readComposerEditor(container, true).prompt)).toBe(row.text)
row.caret.forEach(([position, path, offset]) => {
setCursorPosition(container, position)
const selection = window.getSelection()
expect(selection?.anchorNode).toBe(child === null ? container : container.childNodes[child]!)
expect(selection?.anchorNode).toBe(path.reduce<Node>((node, index) => node.childNodes[index]!, container))
expect(selection?.anchorOffset).toBe(offset)
expect(getCursorPosition(container)).toBe(position)
})
container.remove()
})
test.each<{ text: string; lists: string[] }>([
{ text: "intro\n- a\n- b\nafter", lists: ["UL:a,b"] },
{ text: "intro\n\n- a\n\nafter", lists: ["UL:a"] },
{ text: "- a\n", lists: ["UL:a"] },
{ text: "- \n1. a\n2. b\n4. c", lists: ["UL:", "OL:a,b", "OL:c"] },
{ text: "- spaced", lists: ["UL: spaced"] },
{ text: "```\n- code\n```", lists: [] },
{ text: "* star\n• dot\n - indented\n1) paren", lists: [] },
])("renders $text with lists that read back unchanged", (row) => {
const container = document.createElement("div")
document.body.appendChild(container)
renderComposerEditor(container, [{ type: "text", content: row.text, start: 0, end: row.text.length }], true)
expect(
Array.from(container.querySelectorAll("ul, ol")).map(
(list) => `${list.tagName}:${Array.from(list.children, (item) => item.textContent).join(",")}`,
),
).toEqual(row.lists)
const read = readComposerEditor(container, true)
expect(promptText(read.prompt)).toBe(row.text)
expect(read.stale).toBe(false)
renderComposerEditor(container, [{ type: "text", content: row.text, start: 0, end: row.text.length }], false)
expect(container.querySelectorAll("li")).toHaveLength(0)
expect(promptText(readComposerEditor(container, false).prompt)).toBe(row.text)
container.remove()
})
test("previews inline styles with their markers in the text", () => {
const container = document.createElement("div")
document.body.appendChild(container)
const value = "*hi* _it_ ~no~ `a*b*` https://x.dev\n- **bold** item\n> quote\n```\nx _y_\n```"
renderComposerEditor(container, [{ type: "text", content: value, start: 0, end: value.length }], true)
expect(
Array.from(container.querySelectorAll("[data-md]:not([data-md=marker])"), (span) => {
const markers = Array.from(span.children, (child) => (child instanceof HTMLElement ? child.dataset.md : ""))
return `${span.getAttribute("data-md")}:${span.textContent}:${markers.filter((md) => md === "marker").length}`
}),
).toEqual([
"bold:*hi*:2",
"italic:_it_:2",
"strike:~no~:2",
"code:`a*b*`:2",
"link:https://x.dev:0",
"bold:**bold**:2",
"quote:> quote:1",
"code-block:```\nx _y_\n```:2",
])
const read = readComposerEditor(container, true)
expect(promptText(read.prompt)).toBe(value)
expect(read.stale).toBe(false)
renderComposerEditor(container, [{ type: "text", content: value, start: 0, end: value.length }], false)
expect(container.querySelectorAll("[data-md]")).toHaveLength(0)
container.remove()
})
test("restyles typed markers and keeps the caret outside a closed style", () => {
const container = document.createElement("div")
document.body.appendChild(container)
container.append(text("say *hi*"))
setCursorPosition(container, 8)
expect(readComposerEditor(container, true).stale).toBe(true)
renderComposerEditor(container, [{ type: "text", content: "say *hi* now", start: 0, end: 12 }], true)
setCursorPosition(container, 8)
expect(window.getSelection()?.anchorNode?.textContent).toBe(" now")
expect(window.getSelection()?.anchorOffset).toBe(0)
setCursorPosition(container, 5)
expect(window.getSelection()?.anchorNode?.textContent).toBe("hi")
expect(window.getSelection()?.anchorOffset).toBe(0)
setCursorPosition(container, 4)
expect(window.getSelection()?.anchorNode?.textContent).toBe("say ")
container.remove()
})
test("keeps mentions inside rendered list items", () => {
const container = document.createElement("div")
document.body.appendChild(container)
const prompt: ComposerPrompt = [
{ type: "text", content: "- see ", start: 0, end: 6 },
{ type: "file", path: "src/app.tsx", content: "@src/app.tsx", start: 6, end: 18 },
{ type: "text", content: "\n- done", start: 18, end: 25 },
]
renderComposerEditor(container, prompt, true)
const read: ComposerPrompt = readComposerEditor(container, true).prompt
expect(container.querySelector("li [data-mention=file]")?.textContent).toBe("@src/app.tsx")
expect(read).toEqual(prompt)
container.remove()
})
test.each([
{ name: "a typed dash", typed: "intro\n- ", text: "intro\n- " },
{ name: "a typed number", typed: "1. ", text: "1. " },
{ name: "a typed asterisk", typed: "* ", text: "- " },
{ name: "a typed bullet", typed: "• ", text: "- " },
])("turns $name at the start of a line into a list item", (row) => {
const container = document.createElement("div")
document.body.appendChild(container)
container.append(text(row.typed))
setCursorPosition(container, row.typed.length)
const read = readComposerEditor(container, true)
expect(promptText(read.prompt)).toBe(row.text)
expect(read.cursor).toBe(row.text.length)
expect(read.stale).toBe(true)
expect(readComposerEditor(container, false).stale).toBe(false)
container.remove()
})
test("moves extra lines out of a list item", () => {
const container = document.createElement("div")
document.body.appendChild(container)
container.append(element("ul", element("li", text("a\nb"))))
const read = readComposerEditor(container, true)
expect(promptText(read.prompt)).toBe("- a\nb")
expect(read.stale).toBe(true)
container.remove()
})
+451 -68
View File
@@ -1,87 +1,470 @@
function getNodeLength(node: Node): number {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
return (node.textContent ?? "").replace(/\u200B/g, "").length
import { Skill } from "@opencode/schema/skill"
import {
parseUserMarkdownInline,
userMarkdownLines,
type UserMarkdownInline,
type UserMarkdownLine,
type UserMarkdownMention,
} from "@opencode/util/user-markdown"
import { isAttachment } from "../prompt-parts"
import type { ComposerAttachment, ComposerPrompt } from "../types"
type EditorPart = Exclude<ComposerPrompt[number], ComposerAttachment>
type MentionPart = Exclude<EditorPart, { type: "text" }>
type LineKind = "text" | "bullet" | "ordered"
type ComposerLine = ReturnType<typeof composerLines>[number]
// Text segments map characters one to one. Mentions and breaks are atomic. Blocks mark where empty content starts.
type Segment = { node: Node; start: number; length: number; kind: "text" | "atom" | "block" }
// A styled span over prompt text. Markers stay in the text so the editor and the prompt keep the same characters.
type Decoration = { kind: string; start: number; end: number; children: Decoration[] }
const ZERO_WIDTH = /\u200B/g
const BLOCKS = new Set(["DIV", "P", "LI"])
const mentionParts = new WeakMap<HTMLElement, MentionPart>()
/**
* Replaces the editor content with the prompt text and mentions. With `markdown`, `- ` and `1. ` lines render as
* list items whose markers exist only in the prompt text, and inline styles render with their markers dimmed.
*/
export function renderComposerEditor(editor: HTMLElement, prompt: ComposerPrompt, markdown: boolean) {
const parts = prompt.filter((part): part is EditorPart => !isAttachment(part))
const text = parts.map((part) => part.content).join("")
const lines = composerLines(text, parts, markdown)
const nodes: Node[] = []
let index = 0
while (index < lines.length) {
const line = lines[index]!
let end = index + 1
while (end < lines.length && continues(lines[end - 1]!, lines[end]!)) end++
if (line.kind === "text") {
// The newline before a list stays in the text; the newline after a list belongs to the list.
const stop = end < lines.length ? lines[end]!.start : text.length
const decorations = lines.slice(index, end).flatMap((entry) => entry.decorations)
if (line.start < stop) nodes.push(...build(parts, line.start, stop, decorations))
else if (index > 0) nodes.push(emptyBlock("div"))
index = end
continue
}
const list = document.createElement(line.kind === "ordered" ? "ol" : "ul")
if (list instanceof HTMLOListElement && line.value !== 1) list.start = line.value
list.append(
...lines.slice(index, end).map((item) => {
const content = build(parts, item.content, item.end, item.decorations)
if (content.length === 0) return emptyBlock("li")
const element = document.createElement("li")
element.append(...content)
return element
}),
)
nodes.push(list)
index = end
}
editor.replaceChildren(...nodes)
}
export function getTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
let length = 0
for (const child of Array.from(node.childNodes)) {
length += getTextLength(child)
/**
* Reads the prompt and caret from the editor. `stale` means the editor shows different lists or styles than the
* prompt text describes, for example right after the user types `- ` at the start of a line or closes `*bold*`.
*/
export function readComposerEditor(editor: HTMLElement, markdown: boolean) {
const result = walk(editor, selectionPoint(editor))
const cursor = Math.min(result.cursor ?? result.text.length, result.text.length)
const lineStart = result.text.lastIndexOf("\n", cursor - 1) + 1
// Slack also turns `* ` and `• ` into bullets; the prompt keeps the `- ` marker.
const shortcut =
markdown &&
cursor === lineStart + 2 &&
/^[*•] $/.test(result.text.slice(lineStart, cursor)) &&
result.lines[result.text.slice(0, lineStart).split("\n").length - 1] === "text"
const parts = shortcut ? replaceCharacter(result.parts, lineStart, "-") : result.parts
const text = shortcut ? `${result.text.slice(0, lineStart)}-${result.text.slice(lineStart + 1)}` : result.text
const expected = composerLines(text, parts, markdown)
const empty = parts.every((part) => part.type === "text" && !part.content.replace(/\n/g, ""))
return {
prompt: empty ? [{ type: "text" as const, content: "", start: 0, end: 0 }] : parts,
cursor: empty ? 0 : cursor,
stale:
expected.length !== result.lines.length ||
expected.some((entry, index) => entry.kind !== result.lines[index]) ||
signature(expected.flatMap((entry) => entry.decorations)).join() !== result.styles.join(),
}
return length
}
export function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset)
return getTextLength(preCaretRange.cloneContents())
return walk(parent, selectionPoint(parent)).cursor ?? 0
}
export function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position
let node = parent.firstChild
while (node) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill = node.nodeType === Node.ELEMENT_NODE && !!(node as HTMLElement).dataset.mention
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
range.setStart(node, remaining)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
const segments = walk(parent).segments
const candidates = segments.filter(
(segment) => segment.start <= position && position <= segment.start + segment.length,
)
// At a style edge, prefer the least styled text so typing beside `*bold*` stays outside it.
const target =
candidates
.filter((segment) => segment.kind === "text")
.sort((a, b) => styleDepth(parent, a.node) - styleDepth(parent, b.node))[0] ?? candidates[0]
const next = target ? undefined : segments.find((segment) => segment.start > position)
const range = document.createRange()
if (target) place(range, target, position - target.start)
if (next) place(range, next, 0)
if (!target && !next) range.selectNodeContents(parent)
range.collapse(!!(target || next))
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
/** Applies Slack list editing: Shift+Enter adds an item, and Backspace at the start of an item removes its bullet. */
export function editComposerList(editor: HTMLElement, event: KeyboardEvent) {
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return false
if (!(event.key === "Enter" && event.shiftKey) && !(event.key === "Backspace" && !event.shiftKey)) return false
const selection = window.getSelection()
if (!selection?.rangeCount || !selection.isCollapsed) return false
const range = selection.getRangeAt(0)
const container = range.startContainer
const item = (container instanceof Element ? container : container.parentElement)?.closest("li")
if (!item || !editor.contains(item)) return false
if (event.key === "Enter") return document.execCommand("insertParagraph")
const before = document.createRange()
before.setStart(item, 0)
before.setEnd(container, range.startOffset)
if (before.toString().replace(ZERO_WIDTH, "")) return false
return document.execCommand("outdent")
}
function composerLines(text: string, parts: EditorPart[], markdown: boolean) {
const lines = userMarkdownLines(text)
const mentions = markdown ? mentionRanges(parts) : []
const fences = new Map<number, number>()
lines.reduce((open, line, index) => {
if (line.kind !== "fence") return open
if (open < 0) return index
fences.set(open, index)
return -1
}, -1)
return lines.map((line, index) => {
// Only the markers the editor writes back render as lists, so rendering never rewrites the prompt text.
const marker = line.kind === "ordered" ? `${line.value}. ` : "- "
const kind: LineKind =
markdown && (line.kind === "bullet" || line.kind === "ordered") && text.startsWith(marker, line.start)
? line.kind
: "text"
const content = line.start + (kind === "text" ? 0 : marker.length)
const decorations = markdown ? lineDecorations(text, line, content, lines[fences.get(index) ?? -1], mentions) : []
return { kind, start: line.start, end: line.end, content, value: line.value, decorations }
})
}
function lineDecorations(
text: string,
line: UserMarkdownLine,
content: number,
close: UserMarkdownLine | undefined,
mentions: UserMarkdownMention[],
) {
if (close) {
return [
styled("code-block", line.start, close.end, [
markerSpan(line.start, line.end),
markerSpan(close.start, close.end),
]),
]
}
if (line.kind === "fence" || line.kind === "code") return []
const start = line.kind === "quote" ? line.content : content
const inline = inlineDecorations(parseUserMarkdownInline(text, start, line.end, mentions))
if (line.kind !== "quote") return inline
return [styled("quote", line.start, line.end, [markerSpan(line.start, line.content), ...inline])]
}
function inlineDecorations(nodes: UserMarkdownInline[]): Decoration[] {
return nodes.flatMap((node) => {
if (node.type === "text" || node.type === "mention") return []
if (node.type === "link") return [styled("link", node.start, node.end, [])]
return [
styled(node.type, node.start, node.end, [
markerSpan(node.start, node.start + node.marker),
...(node.type === "code" ? [] : inlineDecorations(node.children)),
markerSpan(node.end - node.marker, node.end),
]),
]
})
}
function styled(kind: string, start: number, end: number, children: Decoration[]): Decoration {
return { kind, start, end, children }
}
function markerSpan(start: number, end: number) {
return styled("marker", start, end, [])
}
function signature(decorations: Decoration[]): string[] {
return decorations.flatMap((decoration) => [
`${decoration.kind}:${decoration.start}-${decoration.end}`,
...signature(decoration.children),
])
}
function mentionRanges(parts: EditorPart[]) {
let offset = 0
return parts.flatMap((part): UserMarkdownMention[] => {
const start = offset
offset += part.content.length
return part.type === "text" ? [] : [{ start, end: offset, type: part.type === "agent" ? "agent" : "file" }]
})
}
function styleDepth(root: HTMLElement, node: Node) {
let depth = 0
for (let element = node.parentElement; element && element !== root; element = element.parentElement) {
if (element.dataset.md) depth++
}
return depth
}
function continues(previous: ComposerLine, line: ComposerLine) {
if (previous.kind !== line.kind) return false
return line.kind !== "ordered" || line.value === previous.value + 1
}
function walk(root: HTMLElement, point?: { node: Node; offset: number }) {
const parts: EditorPart[] = []
const segments: Segment[] = []
// The kind of each prompt line as the editor shows it. Lines inside an item after its first line are continuations.
const lines: (LineKind | "continuation")[] = ["text"]
// Styled spans in document order, in the same form as `signature`.
const styles: string[] = []
let text = ""
let buffer = ""
// A block ends its last line without a newline; the next inline content owes one.
let owed = false
let item: LineKind | undefined
let cursor: number | undefined
const write = (value: string) => {
text += value
buffer += value
for (const char of value) if (char === "\n") lines.push(item ? "continuation" : "text")
}
const settle = () => {
if (!owed) return
owed = false
write("\n")
}
const flush = () => {
if (buffer) parts.push({ type: "text", content: buffer, start: text.length - buffer.length, end: text.length })
buffer = ""
}
const position = () => text.length + (owed ? 1 : 0)
const children = (element: Node) => {
const nodes = Array.from(element.childNodes)
nodes.forEach((child, index) => {
if (point?.node === element && point.offset === index) cursor ??= position()
visit(child)
})
if (point?.node === element && point.offset >= nodes.length) cursor ??= position()
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const data = node.textContent ?? ""
if (point?.node === node) cursor ??= position() + data.slice(0, point.offset).replace(ZERO_WIDTH, "").length
const value = data.replace(ZERO_WIDTH, "")
if (!value) return
settle()
segments.push({ node, start: text.length, length: value.length, kind: "text" })
write(value)
return
}
if (!(node instanceof HTMLElement)) return
if (node.dataset.mention) {
settle()
flush()
const part = mentionPart(node, text.length)
segments.push({ node, start: text.length, length: part.content.length, kind: "atom" })
parts.push(part)
text += part.content
return
}
if (node.dataset.md) {
const slot = styles.push("") - 1
const start = position()
children(node)
styles[slot] = `${node.dataset.md}:${start}-${text.length}`
return
}
if ((isPill || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
if (remaining === 0) {
range.setStartBefore(node)
}
if (remaining > 0 && isPill) {
range.setStartAfter(node)
}
if (remaining > 0 && isBreak) {
const next = node.nextSibling
if (next && next.nodeType === Node.TEXT_NODE) {
range.setStart(next, 0)
}
if (!next || next.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(node)
}
}
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
if (node.tagName === "BR") {
if (placeholder(node)) return
settle()
segments.push({ node, start: text.length, length: 1, kind: "atom" })
write("\n")
return
}
if (!BLOCKS.has(node.tagName)) {
children(node)
return
}
const after = owed
owed = false
if (after || (text && !text.endsWith("\n"))) write("\n")
const kind = node.tagName !== "LI" ? undefined : node.parentElement?.tagName === "OL" ? "ordered" : "bullet"
if (kind) {
lines[lines.length - 1] = kind
write(kind === "ordered" ? `${ordinal(node)}. ` : "- ")
item = kind
}
segments.push({ node, start: text.length, length: 0, kind: "block" })
children(node)
if (kind) item = undefined
owed = true
}
children(root)
flush()
return { parts, segments, lines, styles, text, cursor }
}
function selectionPoint(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount) return
const range = selection.getRangeAt(0)
if (!editor.contains(range.startContainer)) return
return { node: range.startContainer, offset: range.startOffset }
}
function place(range: Range, segment: Segment, offset: number) {
if (segment.kind === "text") {
range.setStart(segment.node, domOffset(segment.node.textContent ?? "", offset))
return
}
if (segment.kind === "block") {
range.setStart(segment.node, 0)
return
}
if (offset > 0) range.setStartAfter(segment.node)
else range.setStartBefore(segment.node)
}
function domOffset(data: string, visible: number) {
let count = 0
for (let index = 0; index < data.length; index++) {
if (count >= visible) return index
if (data[index] !== "\u200B") count++
}
return data.length
}
// A trailing break only gives an empty block its height; it does not start a line.
function placeholder(node: HTMLElement) {
let next = node.nextSibling
while (next?.nodeType === Node.TEXT_NODE && !(next.textContent ?? "").replace(ZERO_WIDTH, "")) next = next.nextSibling
return !next && BLOCKS.has(node.parentElement?.tagName ?? "")
}
function ordinal(item: HTMLElement) {
const list = item.parentElement
const start = list instanceof HTMLOListElement ? list.start : 1
return (
start +
Array.from(list?.children ?? [])
.filter((child) => child.tagName === "LI")
.indexOf(item)
)
}
function emptyBlock(tag: "div" | "li") {
const element = document.createElement(tag)
element.append(document.createElement("br"))
return element
}
function replaceCharacter(parts: EditorPart[], position: number, value: string) {
return parts.map((part) => {
if (part.type !== "text" || position < part.start || position >= part.end) return part
const offset = position - part.start
return { ...part, content: part.content.slice(0, offset) + value + part.content.slice(offset + 1) }
})
}
function build(parts: EditorPart[], start: number, end: number, decorations: Decoration[]): Node[] {
let at = start
const nodes = decorations.flatMap((decoration) => {
const before = fill(parts, at, decoration.start)
const element = document.createElement("span")
element.dataset.md = decoration.kind
element.append(...build(parts, decoration.start, decoration.end, decoration.children))
at = decoration.end
return [...before, element]
})
return [...nodes, ...fill(parts, at, end)]
}
remaining -= length
node = node.nextSibling
function fill(parts: EditorPart[], start: number, end: number) {
let offset = 0
return parts.flatMap<Node>((part) => {
const from = offset
offset += part.content.length
if (part.type !== "text") return from >= start && from < end ? [mentionElement(part)] : []
const value = part.content.slice(Math.max(0, start - from), Math.max(0, end - from))
return value ? [document.createTextNode(value)] : []
})
}
function mentionElement(part: MentionPart) {
const mention = document.createElement("span")
mentionParts.set(mention, part)
mention.textContent = part.content
mention.contentEditable = "false"
mention.dir = "auto"
mention.style.unicodeBidi = "isolate"
mention.dataset.mention = part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
if (part.type === "agent") mention.dataset.name = part.name
if (part.type === "skill") {
mention.dataset.id = part.id
mention.dataset.name = part.name
}
if (part.type === "file") {
mention.dataset.path = part.path
if (part.mime) mention.dataset.mime = part.mime
if (part.filename) mention.dataset.filename = part.filename
}
return mention
}
const fallbackRange = document.createRange()
const fallbackSelection = window.getSelection()
const last = parent.lastChild
if (last && last.nodeType === Node.TEXT_NODE) {
const len = last.textContent ? last.textContent.length : 0
fallbackRange.setStart(last, len)
function mentionPart(element: HTMLElement, start: number): MentionPart {
const content = element.textContent ?? ""
const original = mentionParts.get(element)
const end = start + content.length
if (element.dataset.mention === "agent") {
return {
...(original?.type === "agent" ? original : {}),
type: "agent",
name: element.dataset.name ?? content.slice(1),
content,
start,
end,
}
}
if (element.dataset.mention === "skill") {
return {
...(original?.type === "skill" ? original : {}),
type: "skill",
id: Skill.ID.make(element.dataset.id ?? content.slice(1)),
name: Skill.Name.make(element.dataset.name ?? content.slice(1)),
content,
start,
end,
}
}
if (!last || last.nodeType !== Node.TEXT_NODE) {
fallbackRange.selectNodeContents(parent)
return {
...(original?.type === "file" ? original : {}),
type: "file",
path: element.dataset.path ?? content.slice(1),
content,
start,
end,
...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
}
fallbackRange.collapse(false)
fallbackSelection?.removeAllRanges()
fallbackSelection?.addRange(fallbackRange)
}
@@ -2,6 +2,90 @@
content: "\200B";
}
/* Text hidden below fades into the controls row. The fade clears at the end so the last line stays readable. */
@property --composer-scroll-fade {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@keyframes composer-scroll-fade {
from {
--composer-scroll-fade: 24px;
}
to {
--composer-scroll-fade: 0px;
}
}
@supports (animation-timeline: --composer-scroll) {
[data-component="composer-scroll"] .scroll-view__viewport {
/* Revealing the caret keeps its line above the fade. */
scroll-padding-bottom: 24px;
scroll-timeline: --composer-scroll y;
animation: composer-scroll-fade linear both;
animation-timeline: --composer-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
mask-image: linear-gradient(to bottom, black calc(100% - var(--composer-scroll-fade)), transparent);
}
}
[data-component="composer-editor"] :is(ul, ol) {
margin: 0;
padding-inline-start: 20px;
list-style-position: outside;
}
[data-component="composer-editor"] ul {
list-style-type: disc;
}
[data-component="composer-editor"] ol {
list-style-type: decimal;
}
[data-component="composer-editor"] li {
unicode-bidi: plaintext;
}
[data-component="composer-editor"] [data-md="marker"] {
color: var(--v2-text-text-faint);
}
[data-component="composer-editor"] [data-md="bold"] {
font-weight: 600;
}
[data-component="composer-editor"] [data-md="italic"] {
font-style: italic;
}
[data-component="composer-editor"] [data-md="strike"] {
text-decoration: line-through;
}
[data-component="composer-editor"] [data-md="link"] {
text-decoration: underline;
text-underline-offset: 2px;
}
[data-component="composer-editor"] [data-md="quote"] {
color: var(--v2-text-text-muted);
}
[data-component="composer-editor"] :is([data-md="code"], [data-md="code-block"]) {
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
}
[data-component="composer-editor"] [data-md="code"] {
padding: 0 2px;
border-radius: 4px;
background: color-mix(in oklch, currentColor 10%, transparent);
}
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
[data-slot="composer-controls"][data-overflow-start="true"] {
--mask-start: 16px;
+31 -139
View File
@@ -29,10 +29,10 @@ import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
import type { ComposerAttachment, ComposerComment, ComposerOption, ComposerPrompt, ComposerSuggestion } from "../types"
import type { ComposerAttachment, ComposerComment, ComposerOption, ComposerSuggestion } from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import { editComposerList, getCursorPosition, readComposerEditor, renderComposerEditor, setCursorPosition } from "./dom"
import "../attachments/attachments.css"
import "./editor.css"
@@ -93,7 +93,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
props.controller.onCursor(composerCursor(editor))
props.controller.onCursor(getCursorPosition(editor))
}
const mode = createMemo(() => state.mode)
const buttons = createMemo(() => ({
@@ -104,12 +104,21 @@ export function ComposerEditor(props: ComposerEditorProps) {
createEffect(() => {
const parts = props.controller.parts()
const markdown = mode() === "normal"
if (!editor) return
if (localInput) {
localInput = false
return
}
renderComposerEditor(editor, parts)
const active = document.activeElement === editor
renderComposerEditor(editor, parts, markdown)
if (!active) return
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
})
return (
@@ -208,14 +217,27 @@ export function ComposerEditor(props: ComposerEditorProps) {
"text-align": "start",
}}
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const markdown = state.mode === "normal"
const read = readComposerEditor(event.currentTarget, markdown)
// Rendering resets IME composition, so a new list or style waits for the next input.
if (read.stale && !event.isComposing) {
renderComposerEditor(event.currentTarget, read.prompt, markdown)
setCursorPosition(event.currentTarget, read.cursor)
}
const attachments = props.controller.parts().filter(isAttachment)
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
props.controller.onInput(
read.prompt.map((part) => part.content).join(""),
[...read.prompt, ...attachments],
read.cursor,
)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
if (state.mode === "normal" && editComposerList(event.currentTarget, event)) {
event.preventDefault()
return
}
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
@@ -239,7 +261,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
const caret = selection.getRangeAt(0).getBoundingClientRect()
if (!caret.height) return
const bounds = viewport.getBoundingClientRect()
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
// The bottom margin matches the fade in editor.css so the caret line stays clear of it.
if (caret.bottom > bounds.bottom - 24) viewport.scrollTop += caret.bottom - bounds.bottom + 24
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
})
}}
@@ -361,137 +384,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
)
}
const mentionParts = new WeakMap<HTMLElement, Exclude<ComposerPrompt[number], ComposerAttachment | { type: "text" }>>()
function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (isAttachment(part)) return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
mention.textContent = part.content
mention.contentEditable = "false"
mention.dir = "auto"
mention.style.unicodeBidi = "isolate"
mention.dataset.mention =
part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type
if (part.type === "agent") mention.dataset.name = part.name
if (part.type === "skill") {
mention.dataset.id = part.id
mention.dataset.name = part.name
}
if (part.type === "file") {
mention.dataset.path = part.path
if (part.mime) mention.dataset.mime = part.mime
if (part.filename) mention.dataset.filename = part.filename
}
return [mention]
}),
)
if (!active) return
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}
function parseComposerEditor(editor: HTMLDivElement) {
const parts: Exclude<ComposerPrompt[number], ComposerAttachment>[] = []
let buffer = ""
let position = 0
const flush = () => {
if (!buffer) return
parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length })
position += buffer.length
buffer = ""
}
const mention = (element: HTMLElement) => {
flush()
const content = element.textContent ?? ""
const original = mentionParts.get(element)
if (element.dataset.mention === "agent") {
parts.push({
...(original?.type === "agent" ? original : {}),
type: "agent",
name: element.dataset.name ?? content.slice(1),
content,
start: position,
end: position + content.length,
})
position += content.length
return
}
if (element.dataset.mention === "skill") {
parts.push({
...(original?.type === "skill" ? original : {}),
type: "skill",
id: Skill.ID.make(element.dataset.id ?? content.slice(1)),
name: Skill.Name.make(element.dataset.name ?? content.slice(1)),
content,
start: position,
end: position + content.length,
})
position += content.length
return
}
parts.push({
...(original?.type === "file" ? original : {}),
type: "file",
path: element.dataset.path ?? content.slice(1),
content,
start: position,
end: position + content.length,
...(element.dataset.mime ? { mime: element.dataset.mime } : {}),
...(element.dataset.filename ? { filename: element.dataset.filename } : {}),
})
position += content.length
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
return
}
if (!(node instanceof HTMLElement)) return
if (node.dataset.mention) {
mention(node)
return
}
if (node.tagName === "BR") {
buffer += "\n"
return
}
Array.from(node.childNodes).forEach(visit)
}
Array.from(editor.childNodes).forEach((node, index, nodes) => {
visit(node)
if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
})
flush()
if (
parts.every((part) => part.type === "text") &&
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
) {
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
}
if (parts.length > 0) return parts
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
}
function composerCursor(editor: HTMLDivElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(editor)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}
export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
@@ -4,6 +4,7 @@ import { useFilteredList } from "@opencode/ui/hooks"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
import type { Upload } from "../attachments/uploads"
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
import { getCursorPosition, setCursorPosition } from "./dom"
import type {
ComposerAttachment,
ComposerComment,
@@ -257,8 +258,9 @@ export function createComposerEditor(input: {
const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => {
requestAnimationFrame(() => {
editor?.focus()
setEditorCursor(editor, cursor)
if (!editor) return
editor.focus()
setCursorPosition(editor, cursor)
})
}
@@ -273,7 +275,7 @@ export function createComposerEditor(input: {
const selection = window.getSelection()
if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
if (!canNavigateHistory(direction, text, getCursorPosition(editor), state.historyIndex >= 0)) return false
const entries = input.history.entries(state.mode)
if (direction === "up") {
if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
@@ -477,33 +479,3 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(editor)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}
function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
if (!editor) return
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
let remaining = cursor
let node = walker.nextNode()
while (node) {
const length = node.textContent?.length ?? 0
if (remaining <= length) {
const range = document.createRange()
range.setStart(node, remaining)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return
}
remaining -= length
node = walker.nextNode()
}
}
@@ -59,3 +59,25 @@ story("renders user image, file attachment, file reference, and agent reference"
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.getByText("@explore", { exact: true })).toBeVisible()
})
story("renders simple Markdown in user messages", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "markdown" } })
const message = timeline.locator('[data-slot="user-message-text"]')
await expect(message.locator("strong")).toHaveText("review")
await expect(message.locator("em")).toHaveText("shipping")
await expect(message.locator('[data-highlight="file"]')).toHaveText("@src/a.ts")
await expect(message.locator("ul")).toHaveCSS("list-style-type", "disc")
await expect(message.locator("ul > li")).toHaveText([
"Check the old parse path",
"Keep https://opencode.ai/docs working",
])
await expect(message.locator("ul s")).toHaveText("old")
await expect(message.locator("ul code")).toHaveText("parse")
await expect(message.getByRole("link", { name: "https://opencode.ai/docs", exact: true })).toHaveAttribute(
"href",
"https://opencode.ai/docs",
)
await expect(message.locator("ol > li")).toHaveText(["Run tests", "Ship it"])
await expect(message.locator("blockquote")).toHaveText("Quoted note")
await expect(message.locator("pre")).toHaveText("const value = *raw*")
})
@@ -158,6 +158,68 @@
color: var(--user-message-mention-prefix-color);
}
:is([data-slot="user-message-paragraph"], li, blockquote) {
unicode-bidi: plaintext;
}
ul,
ol {
margin: 0;
padding-inline-start: 20px;
list-style-position: outside;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
blockquote {
margin: 0;
padding-inline-start: 8px;
border-inline-start: 2px solid color-mix(in oklch, currentColor 30%, transparent);
}
strong {
font-weight: 600;
}
a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
direction: ltr;
unicode-bidi: isolate;
}
code {
direction: ltr;
unicode-bidi: isolate;
padding: 0 4px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
background: color-mix(in oklch, currentColor 10%, transparent);
}
[data-slot="user-message-code-block"] {
margin: 2px 0;
padding: 4px 8px;
border-radius: 6px;
direction: ltr;
unicode-bidi: isolate;
white-space: pre-wrap;
background: color-mix(in oklch, currentColor 10%, transparent);
code {
padding: 0;
background: none;
}
}
max-width: 100%;
}
@@ -28,6 +28,7 @@ import type {
} from "@opencode/client/promise"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { attached, typeLabel } from "../components/message-file"
import { parseUserMarkdown, type UserMarkdownBlock, type UserMarkdownInline } from "@opencode/util/user-markdown"
export async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body
@@ -303,7 +304,7 @@ export function CurrentUserMessageDisplay(props: {
>
<div data-slot="user-message-body">
<div data-slot="user-message-text" dir="auto" data-comments={comments().length > 0 ? "true" : undefined}>
<CurrentHighlightedText text={props.text} files={inlineFiles()} agents={agents()} />
<UserMessageMarkdown text={props.text} files={inlineFiles()} agents={agents()} />
<Show when={comments().length > 0}>
<UserMessageComments comments={comments()} bounded />
</Show>
@@ -359,46 +360,87 @@ export function CurrentUserMessageDisplay(props: {
)
}
function CurrentHighlightedText(props: {
text: string
files: PromptFileAttachment[]
agents: PromptAgentAttachment[]
}) {
const segments = createMemo(() => {
const references = [
function UserMessageMarkdown(props: { text: string; files: PromptFileAttachment[]; agents: PromptAgentAttachment[] }) {
const blocks = createMemo(() =>
parseUserMarkdown(props.text, [
...props.files.flatMap((file) =>
file.mention ? [{ start: file.mention.start, end: file.mention.end, type: "file" as const }] : [],
),
...props.agents.flatMap((agent) =>
agent.mention ? [{ start: agent.mention.start, end: agent.mention.end, type: "agent" as const }] : [],
),
].sort((a, b) => a.start - b.start)
const result: HighlightSegment[] = []
let last = 0
references.forEach((reference) => {
if (reference.start < last) return
if (reference.start > last) result.push({ text: props.text.slice(last, reference.start) })
result.push({ text: props.text.slice(reference.start, reference.end), type: reference.type })
last = reference.end
})
if (last < props.text.length) result.push({ text: props.text.slice(last) })
return result
})
]),
)
return <For each={blocks()}>{(block) => <UserMarkdownBlockView block={block} />}</For>
}
function UserMarkdownBlockView(props: { block: UserMarkdownBlock }) {
const block = props.block
if (block.type === "code") {
return (
<pre data-slot="user-message-code-block">
<code>{block.text}</code>
</pre>
)
}
if (block.type === "quote") {
return (
<blockquote>
<UserMarkdownInlineView nodes={block.children} />
</blockquote>
)
}
if (block.type === "list") {
const items = (
<For each={block.items}>
{(item) => (
<li value={block.ordered ? item.value : undefined}>
<UserMarkdownInlineView nodes={item.children} />
</li>
)}
</For>
)
if (block.ordered) return <ol start={block.items[0]?.value}>{items}</ol>
return <ul>{items}</ul>
}
return (
<For each={segments()}>
{(segment) => (
<span data-highlight={segment.type}>
<Show when={segment.type && segment.text.startsWith("@")} fallback={segment.text}>
<span data-slot="user-message-mention-prefix">@</span>
{segment.text.slice(1)}
</Show>
</span>
)}
</For>
<div data-slot="user-message-paragraph">
<UserMarkdownInlineView nodes={block.children} />
</div>
)
}
type HighlightSegment = { text: string; type?: "file" | "agent" }
function UserMarkdownInlineView(props: { nodes: UserMarkdownInline[] }) {
return (
<For each={props.nodes}>
{(node) => {
if (node.type === "text") return node.text
if (node.type === "code") return <code>{node.text}</code>
if (node.type === "link") {
return (
<a href={node.text} class="external-link" target="_blank" rel="noopener noreferrer">
{node.text}
</a>
)
}
if (node.type === "mention") {
return (
<span data-highlight={node.mention}>
<Show when={node.text.startsWith("@")} fallback={node.text}>
<span data-slot="user-message-mention-prefix">@</span>
{node.text.slice(1)}
</Show>
</span>
)
}
const children = <UserMarkdownInlineView nodes={node.children} />
if (node.type === "bold") return <strong>{children}</strong>
if (node.type === "italic") return <em>{children}</em>
return <s>{children}</s>
}}
</For>
)
}
export function SessionCompactionMessage(props: { message: SessionMessageCompaction; error: string }) {
const i18n = useI18n()
@@ -576,6 +576,49 @@ const RichUserAttachments = {
),
}
const markdownPrompt = [
"Please *review* @src/a.ts before _shipping_:",
"- Check the ~old~ `parse` path",
"- Keep https://opencode.ai/docs working",
"1. Run tests",
"2. Ship it",
"> Quoted note",
"```",
"const value = *raw*",
"```",
].join("\n")
const MarkdownPrompt = {
render: () => (
<CurrentSessionTimelineStory
title="Prompt with simple Markdown"
description="Slack-style marks, lists, quotes, and code render in the user message, and mentions stay highlighted."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_markdown_user",
type: "user",
text: markdownPrompt,
files: [
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 16, end: 25 },
},
],
time: { created: 1_735_689_633_000 },
},
],
}}
width="560px"
/>
),
}
const conversationScenarios = {
reasoning: AgentReasoning,
hidden: WorkingWithoutReasoningDetails,
@@ -586,6 +629,7 @@ const conversationScenarios = {
interruption: InterruptedTurn,
models: AliasedModelNotices,
attachments: RichUserAttachments,
markdown: MarkdownPrompt,
}
export const Conversation = {
+1 -1
View File
@@ -74,7 +74,7 @@ export function generateSyntax(theme: ResolvedThemeTokens) {
rule(["comment.todo", "comment.note"], feedback.info.base, { italic: true, bold: true }),
rule(["attribute", "annotation"], feedback.warning.base),
rule(["tag"], feedback.error.base),
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.muted),
rule(["markup.strikethrough", "markup.list.unchecked", "markup.marker", "debug"], theme.text.muted),
rule(["markup.list.checked"], feedback.success.base),
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
@@ -13,7 +13,13 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "simple_markdown",
title: "Simple Markdown",
description: "Preview Slack-style Markdown while you type a prompt, and render it in your sent messages",
},
]
export function DialogExperiments() {
const config = useConfig()
@@ -28,6 +28,13 @@ import { useExit } from "../../context/exit"
import { promptOffsetWidth } from "../../prompt/display"
import { expandPromptInputPastedText, realignPromptInputMentions } from "../../prompt/mention"
import { parseSlashHead } from "../../prompt/parse"
import {
promptListMarker,
promptListNewline,
promptMarkdownRanges,
type PromptMarkdownRange,
type PromptMarkdownStyle,
} from "../../prompt/markdown"
import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
@@ -337,6 +344,22 @@ export function Prompt(props: PromptProps) {
const skillStyleId = syntax().getStyleId("extmark.skill")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0
let markdownTypeId = 0
const markdownStyleIds = createMemo(() => {
const style = syntax()
const id = (scope: string) => style.getStyleId(scope) ?? undefined
return {
marker: id("markup.marker"),
list: id("markup.list"),
quote: id("markup.quote"),
bold: id("markup.strong"),
italic: id("markup.italic"),
strike: id("markup.strikethrough"),
code: id("markup.raw.inline"),
"code-block": id("markup.raw.block"),
link: id("markup.link"),
} satisfies Record<PromptMarkdownStyle, number | undefined>
})
const event = useEvent()
event.on("tui.prompt.append", (evt, { directory }) => {
@@ -371,6 +394,7 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
const markdown = createMemo(() => config.experimental?.simple_markdown === true && store.mode === "normal")
let disposed = false
let pasteQueue = Promise.resolve()
@@ -803,6 +827,7 @@ export function Prompt(props: PromptProps) {
})
}
})
decorateMarkdown()
}
function syncExtmarksWithPromptParts() {
@@ -877,6 +902,67 @@ export function Prompt(props: PromptProps) {
)
}
// Every extmark change rebuilds all prompt highlights, so only the ranges that changed are replaced.
function decorateMarkdown() {
if (!input || input.isDestroyed || markdownTypeId === 0) return
const styles = markdownStyleIds()
const ranges = markdown()
? promptMarkdownRanges(input.plainText, input.extmarks.getAllForTypeId(promptPartTypeId))
: []
const next = new Map<string, PromptMarkdownRange & { styleId: number }>(
ranges.flatMap((range) => {
const styleId = styles[range.style]
if (styleId === undefined) return []
return [[`${range.start}:${range.end}:${styleId}:${range.depth}`, { ...range, styleId }] as const]
}),
)
input.extmarks.getAllForTypeId(markdownTypeId).forEach((extmark) => {
if (next.delete(`${extmark.start}:${extmark.end}:${extmark.styleId}:${extmark.priority}`)) return
input.extmarks.delete(extmark.id)
})
next.forEach((range) =>
input.extmarks.create({
start: range.start,
end: range.end,
styleId: range.styleId,
priority: range.depth,
typeId: markdownTypeId,
}),
)
}
createEffect(on([markdown, markdownStyleIds], () => decorateMarkdown()))
// Slack list editing: a newline continues the list or ends it on an empty item, and Backspace removes a marker.
function editMarkdownList(change: { start: number; end: number; text: string } | undefined) {
if (!change || input.hasSelection()) return false
input.cursorOffset = change.start
const start = input.logicalCursor
input.cursorOffset = change.end
const end = input.logicalCursor
input.deleteRange(start.row, start.col, end.row, end.col)
if (change.text) input.insertText(change.text)
}
const markdownListKeys = (command: string, run: () => void | false) =>
config.keybinds
.get(command)
.flatMap((binding) => (typeof binding.key === "string" ? [{ bind: binding.key, run }] : []))
Keymap.createLayer(() => ({
priority: 1,
enabled: !disabled() && markdown(),
commands: [
...markdownListKeys("input.newline", () =>
editMarkdownList(promptListNewline(input.plainText, input.cursorOffset)),
),
...markdownListKeys("input.backspace", () => {
const marker = promptListMarker(input.plainText, input.cursorOffset)
return editMarkdownList(marker && { ...marker, text: "" })
}),
],
}))
const stashCommands = createMemo(() =>
[
{
@@ -1760,6 +1846,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
decorateMarkdown()
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
@@ -1808,6 +1895,7 @@ export function Prompt(props: PromptProps) {
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
if (markdownTypeId === 0) markdownTypeId = input.extmarks.registerType("markdown")
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
+12
View File
@@ -11,6 +11,18 @@ export function promptOffsetWidth(value: string) {
return width
}
/** Display offset at every string index. Indices inside a grapheme map to the grapheme start. */
export function displayOffsets(value: string) {
const offsets = new Array<number>(value.length + 1).fill(0)
let width = 0
for (const part of graphemes.segment(value)) {
offsets.fill(width, part.index, part.index + part.segment.length)
width += part.segment === "\n" ? 1 : stringWidth(part.segment)
}
offsets[value.length] = width
return offsets
}
function displayOffsetIndex(value: string, offset: number) {
if (offset <= 0) return 0
+136
View File
@@ -0,0 +1,136 @@
import {
parseUserMarkdownInline,
userMarkdownLines,
type UserMarkdownInline,
type UserMarkdownLine,
type UserMarkdownMention,
} from "@opencode/util/user-markdown"
import { displayOffsets } from "./display"
export type PromptMarkdownStyle =
| "marker"
| "list"
| "quote"
| "bold"
| "italic"
| "strike"
| "code"
| "code-block"
| "link"
/** A styled prompt range in textarea display offsets. Deeper ranges draw over the ranges that contain them. */
export type PromptMarkdownRange = { start: number; end: number; style: PromptMarkdownStyle; depth: number }
type Range = { start: number; end: number }
/** Slack-style Markdown ranges for live prompt styling. Markers stay in the text and get their own style. */
export function promptMarkdownRanges(text: string, mentions: readonly Range[] = []): PromptMarkdownRange[] {
const offsets = displayOffsets(text)
// Mentions arrive in display offsets; the parser works on string indices.
const indices = new Map(offsets.map((offset, index) => [offset, index] as const).reverse())
const atoms = mentions.flatMap((mention): UserMarkdownMention[] => {
const start = indices.get(mention.start)
const end = indices.get(mention.end)
return start === undefined || end === undefined ? [] : [{ start, end, type: "file" }]
})
const lines = userMarkdownLines(text)
const fences = new Map<number, UserMarkdownLine>()
lines.reduce(
(open, line) => {
if (line.kind !== "fence") return open
if (!open) return line
fences.set(open.start, line)
return undefined
},
undefined as UserMarkdownLine | undefined,
)
return lines
.flatMap((line) => lineRanges(text, line, fences.get(line.start), atoms))
.flatMap((range) => withoutMentions(range, atoms))
.map((range) => ({ ...range, start: offsets[range.start]!, end: offsets[range.end]! }))
}
/** The textarea edit for a newline in a list item: start the next item, or end the list on an empty item. */
export function promptListNewline(text: string, offset: number) {
const item = listItem(text, offset)
if (!item) return
if (!text.slice(item.line.content, item.line.end).trim()) {
return { start: item.offsets[item.line.start]!, end: item.offsets[item.line.end]!, text: "" }
}
const prefix = text.slice(item.line.start, item.line.content).trimEnd()
const next = item.line.kind === "ordered" ? prefix.replace(/\d+/, String(item.line.value + 1)) : prefix
return { start: offset, end: offset, text: `\n${next} ` }
}
/** The list marker that Backspace removes when the cursor is at the start of the item content. */
export function promptListMarker(text: string, offset: number) {
const item = listItem(text, offset)
if (!item || item.index !== item.line.content) return
return { start: item.offsets[item.line.start]!, end: item.offsets[item.line.content]! }
}
function lineRanges(
text: string,
line: UserMarkdownLine,
close: UserMarkdownLine | undefined,
mentions: UserMarkdownMention[],
): PromptMarkdownRange[] {
if (close) {
return [
{ start: line.start, end: close.end, style: "code-block", depth: 0 },
{ start: line.start, end: line.end, style: "marker", depth: 1 },
{ start: close.start, end: close.end, style: "marker", depth: 1 },
]
}
if (line.kind === "fence" || line.kind === "code") return []
const quote = line.kind === "quote"
const inline = inlineRanges(parseUserMarkdownInline(text, line.content, line.end, mentions), quote ? 1 : 0)
if (quote) {
return [
{ start: line.start, end: line.end, style: "quote", depth: 0 },
{ start: line.start, end: line.content, style: "marker", depth: 1 },
...inline,
]
}
if (line.kind === "bullet" || line.kind === "ordered") {
return [{ start: line.start, end: line.content, style: "list", depth: 0 }, ...inline]
}
return inline
}
function inlineRanges(nodes: UserMarkdownInline[], depth: number): PromptMarkdownRange[] {
return nodes.flatMap((node): PromptMarkdownRange[] => {
if (node.type === "text" || node.type === "mention") return []
if (node.type === "link") return [{ start: node.start, end: node.end, style: "link", depth }]
return [
{ start: node.start, end: node.end, style: node.type, depth },
{ start: node.start, end: node.start + node.marker, style: "marker", depth: depth + 1 },
...(node.type === "code" ? [] : inlineRanges(node.children, depth + 1)),
{ start: node.end - node.marker, end: node.end, style: "marker", depth: depth + 1 },
]
})
}
// Mentions keep their own style, so Markdown ranges skip over them.
function withoutMentions(range: PromptMarkdownRange, mentions: Range[]) {
return mentions.reduce(
(parts, mention) =>
parts.flatMap((part) => {
if (mention.end <= part.start || mention.start >= part.end) return [part]
return [
...(part.start < mention.start ? [{ ...part, end: mention.start }] : []),
...(mention.end < part.end ? [{ ...part, start: mention.end }] : []),
]
}),
[range],
)
}
function listItem(text: string, offset: number) {
const offsets = displayOffsets(text)
const index = offsets.indexOf(offset)
if (index < 0) return
const line = userMarkdownLines(text).find((entry) => entry.start <= index && index <= entry.end)
if (!line || (line.kind !== "bullet" && line.kind !== "ordered") || index < line.content) return
return { line, index, offsets }
}
+11 -1
View File
@@ -114,6 +114,7 @@ import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDisplay } from "./message-parts"
import { UserMessageMarkdown } from "./user-message-markdown"
import { defaultVerbosity, type GroupKind, type SessionEntry } from "./grouping/session"
import { SessionGroupView } from "./group-view"
import { useEntryAnchor } from "./anchor-view"
@@ -2215,6 +2216,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
const config = useConfig()
const markdown = createMemo(() => config.data.experimental?.simple_markdown === true)
const mentions = createMemo(() =>
[...(props.message.files ?? []), ...(props.message.agents ?? []), ...skills()].flatMap((part) =>
part.mention ? [{ start: part.mention.start, end: part.mention.end, type: "file" as const }] : [],
),
)
const updatePendingSteer = async (action: "queue" | "cancel") => {
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
@@ -2267,7 +2275,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
flexShrink={0}
>
<text fg={theme.text.base}>{props.message.text}</text>
<Show when={markdown()} fallback={<text fg={theme.text.base}>{props.message.text}</text>}>
<UserMessageMarkdown text={props.message.text} mentions={mentions()} />
</Show>
<Show when={skills().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={skills()}>
@@ -0,0 +1,103 @@
import { For, createMemo } from "solid-js"
import {
parseUserMarkdown,
type UserMarkdownBlock,
type UserMarkdownInline,
type UserMarkdownMention,
} from "@opencode/util/user-markdown"
import { useTheme } from "../../context/theme"
/** A user message with Slack-style Markdown: marks, links, lists, quotes, and code. Mentions stay literal. */
export function UserMessageMarkdown(props: { text: string; mentions: readonly UserMarkdownMention[] }) {
const blocks = createMemo(() => parseUserMarkdown(props.text, [...props.mentions]))
return (
<box flexDirection="column">
<For each={blocks()}>{(block) => <Block block={block} />}</For>
</box>
)
}
// Parsed blocks and nodes are immutable, so each one renders its shape once.
function Block(props: { block: UserMarkdownBlock }) {
const theme = useTheme()
const block = props.block
if (block.type === "code") {
return (
<box backgroundColor={theme.background.base} paddingLeft={1} paddingRight={1}>
<text fg={theme.markdown.codeBlock}>{block.text}</text>
</box>
)
}
if (block.type === "quote") {
return (
<box border={["left"]} borderColor={theme.markdown.blockQuote} paddingLeft={1}>
<text fg={theme.markdown.blockQuote}>
<Inline nodes={block.children} />
</text>
</box>
)
}
if (block.type === "list") {
return (
<box flexDirection="column">
<For each={block.items}>
{(item) => (
<box flexDirection="row">
<text flexShrink={0} fg={block.ordered ? theme.markdown.listEnumeration : theme.markdown.listItem}>
{block.ordered ? `${item.value}. ` : "• "}
</text>
<text fg={theme.text.base}>
<Inline nodes={item.children} />
</text>
</box>
)}
</For>
</box>
)
}
return (
<text fg={theme.text.base}>
<Inline nodes={block.children} />
</text>
)
}
function Inline(props: { nodes: UserMarkdownInline[] }) {
const theme = useTheme()
return (
<For each={props.nodes}>
{(node) => {
if (node.type === "text" || node.type === "mention") return node.text
if (node.type === "code") {
return <span style={{ fg: theme.markdown.code, bg: theme.background.base }}>{node.text}</span>
}
if (node.type === "link") {
return (
<a href={node.text} style={{ fg: theme.markdown.link, underline: true }}>
{node.text}
</a>
)
}
if (node.type === "bold") {
return (
<span style={{ fg: theme.markdown.strong, bold: true }}>
<Inline nodes={node.children} />
</span>
)
}
if (node.type === "italic") {
return (
<span style={{ fg: theme.markdown.emphasis, italic: true }}>
<Inline nodes={node.children} />
</span>
)
}
return (
<span style={{ strikethrough: true }}>
<Inline nodes={node.children} />
</span>
)
}}
</For>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import { promptListMarker, promptListNewline, promptMarkdownRanges } from "../../src/prompt/markdown"
const styled = (text: string, mentions: { start: number; end: number }[] = []) =>
promptMarkdownRanges(text, mentions).map((range) => `${range.style}@${range.depth}:${range.start}-${range.end}`)
describe("prompt markdown", () => {
test("styles Slack marks with their markers in display offsets", () => {
expect(styled("*hi* `x`")).toEqual([
"bold@0:0-4",
"marker@1:0-1",
"marker@1:3-4",
"code@0:5-8",
"marker@1:5-6",
"marker@1:7-8",
])
// Wide characters count two cells before the bold range.
expect(styled("中文 _ok_")).toEqual(["italic@0:5-9", "marker@1:5-6", "marker@1:8-9"])
})
test("styles list markers, quotes, and fenced code, but not marks inside code", () => {
expect(styled("- *a*\n> q\n```\n*no*\n```")).toEqual([
"list@0:0-2",
"bold@0:2-5",
"marker@1:2-3",
"marker@1:4-5",
"quote@0:6-9",
"marker@1:6-8",
"code-block@0:10-22",
"marker@1:10-13",
"marker@1:19-22",
])
})
test("leaves mentions to their own style", () => {
expect(styled("*see @a_b_.ts now*", [{ start: 5, end: 13 }])).toEqual([
"bold@0:0-5",
"bold@0:13-18",
"marker@1:0-1",
"marker@1:17-18",
])
})
test.each([
{ name: "continues a bullet", text: "- one", offset: 5, edit: { start: 5, end: 5, text: "\n- " } },
{ name: "keeps the marker and indent", text: " * one", offset: 7, edit: { start: 7, end: 7, text: "\n * " } },
{ name: "numbers the next item", text: "9. nine", offset: 7, edit: { start: 7, end: 7, text: "\n10. " } },
{ name: "ends the list on an empty item", text: "- one\n- ", offset: 8, edit: { start: 6, end: 8, text: "" } },
{ name: "ignores the cursor before the content", text: "- one", offset: 1, edit: undefined },
{ name: "ignores plain lines", text: "one", offset: 3, edit: undefined },
{ name: "ignores lines in fenced code", text: "```\n- one\n```", offset: 9, edit: undefined },
])("newline $name", (row) => {
expect(promptListNewline(row.text, row.offset)).toEqual(row.edit)
})
test("Backspace removes a list marker only at the start of the item content", () => {
expect(promptListMarker("x\n 1. one", 7)).toEqual({ start: 2, end: 7 })
expect(promptListMarker("x\n 1. one", 8)).toBeUndefined()
expect(promptListMarker("x\none", 2)).toBeUndefined()
})
})
@@ -0,0 +1,58 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { ConfigProvider } from "../../src/config"
import { ThemeProvider } from "../../src/context/theme"
import { UserMessageMarkdown } from "../../src/routes/session/user-message-markdown"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("renders Slack-style Markdown in a user message without its markers", async () => {
const text = [
"Please *review* @src/_a_.ts before _shipping_:",
"- Check the ~old~ `parse` path",
"2. Run tests",
"> Quoted note",
"```",
"const value = *raw*",
"```",
].join("\n")
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode: "dark" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<UserMessageMarkdown text={text} mentions={[{ start: 16, end: 27, type: "file" }]} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 8 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Please"))
const lines = app
.captureCharFrame()
.split("\n")
.map((line) => line.trimEnd())
expect(lines.slice(0, 6)).toEqual([
"Please review @src/_a_.ts before shipping:",
"• Check the old parse path",
"2. Run tests",
"│ Quoted note",
" const value = *raw*",
"",
])
const spans = app.captureSpans().lines
const span = (line: number, text: string) => spans[line]?.spans.find((item) => item.text === text)
expect(span(0, "review")?.attributes ?? 0).toBe(TextAttributes.BOLD)
expect(span(0, "shipping")?.attributes ?? 0).toBe(TextAttributes.ITALIC)
expect(span(1, "old")?.attributes ?? 0).toBe(TextAttributes.STRIKETHROUGH)
} finally {
app.renderer.destroy()
}
})
+88
View File
@@ -0,0 +1,88 @@
import { expect, test } from "bun:test"
import {
parseUserMarkdown,
type UserMarkdownBlock,
type UserMarkdownInline,
type UserMarkdownMention,
} from "./user-markdown.js"
const inline = (nodes: UserMarkdownInline[]): string =>
nodes
.map((node) => {
if (node.type === "text") return node.text
if (node.type === "mention") return `[${node.mention}:${node.text}]`
if (node.type === "code") return `<code>${node.text}</code>`
if (node.type === "link") return `<a>${node.text}</a>`
const tag = { bold: "b", italic: "i", strike: "s" }[node.type]
return `<${tag}>${inline(node.children)}</${tag}>`
})
.join("")
const blocks = (value: UserMarkdownBlock[]) =>
value.map((block) => {
if (block.type === "code") return `<pre>${block.text}</pre>`
if (block.type === "quote") return `<quote>${inline(block.children)}</quote>`
if (block.type === "paragraph") return `<p>${inline(block.children)}</p>`
const tag = block.ordered ? "ol" : "ul"
return `<${tag}>${block.items.map((item) => `<li${block.ordered ? ` ${item.value}` : ""}>${inline(item.children)}</li>`).join("")}</${tag}>`
})
test.each<{ name: string; text: string; mentions?: UserMarkdownMention[]; html: string[] }>([
{ name: "plain text", text: "Line one\n\nLine two", html: ["<p>Line one\n\nLine two</p>"] },
{
name: "lists",
text: "Plan:\n- one\n* two\n• three\n - nested\n1. first\n3. third",
html: [
"<p>Plan:</p>",
"<ul><li>one</li><li>two</li><li>three</li><li>nested</li></ul>",
"<ol><li 1>first</li><li 3>third</li></ol>",
],
},
{
name: "a blank line before a block",
text: "intro\n\n- a\n\nafter",
html: ["<p>intro\n\n</p>", "<ul><li>a</li></ul>", "<p>\nafter</p>"],
},
{
name: "Slack marks and Markdown doubles",
text: "*bold* _italic_ ~strike~ `code` **double** ~~gone~~ *_both_*",
html: [
"<p><b>bold</b> <i>italic</i> <s>strike</s> <code>code</code> <b>double</b> <s>gone</s> <b><i>both</i></b></p>",
],
},
{
name: "markers inside words or beside spaces",
text: "snake_case_name 2*3*4 a * b * c ~/dir *open\nclose* *trailing *",
html: ["<p>snake_case_name 2*3*4 a * b * c ~/dir *open\nclose* *trailing *</p>"],
},
{
name: "code spans that hide marks",
text: "`*not bold*` ``a ` b``",
html: ["<p><code>*not bold*</code> <code>a ` b</code></p>"],
},
{
name: "fenced code",
text: "```ts\nconst a = *b*\n- item\n```\n```\nunclosed *bold*",
html: ["<pre>const a = *b*\n- item</pre>", "<p>```\nunclosed <b>bold</b></p>"],
},
{
name: "quotes",
text: "> quoted *bold*\n>second\nafter",
html: ["<quote>quoted <b>bold</b>\nsecond</quote>", "<p>after</p>"],
},
{
name: "mentions as atoms",
text: "*see @src/_a_.ts* @build",
mentions: [
{ start: 5, end: 16, type: "file" },
{ start: 18, end: 24, type: "agent" },
],
html: ["<p><b>see [file:@src/_a_.ts]</b> [agent:@build]</p>"],
},
{
name: "links",
text: "Visit https://example.com/a_b_(c)), or (https://x.dev). Not xhttps://no.",
html: ["<p>Visit <a>https://example.com/a_b_(c)</a>), or (<a>https://x.dev</a>). Not xhttps://no.</p>"],
},
])("parses $name", (row) => {
expect(blocks(parseUserMarkdown(row.text, row.mentions))).toEqual(row.html)
})
+278
View File
@@ -0,0 +1,278 @@
export type UserMarkdownMention = { start: number; end: number; type: "file" | "agent" }
// Styled spans keep their source range, including markers of `marker` characters at each end.
export type UserMarkdownInline =
| { type: "text"; text: string }
| { type: "mention"; text: string; mention: UserMarkdownMention["type"] }
| { type: "code"; text: string; start: number; end: number; marker: number }
| { type: "link"; text: string; start: number; end: number }
| { type: "bold" | "italic" | "strike"; children: UserMarkdownInline[]; start: number; end: number; marker: number }
export type UserMarkdownBlock =
| { type: "paragraph"; children: UserMarkdownInline[] }
| { type: "quote"; children: UserMarkdownInline[] }
| { type: "code"; text: string }
| { type: "list"; ordered: boolean; items: { value: number; children: UserMarkdownInline[] }[] }
export type UserMarkdownLine = {
kind: "text" | "fence" | "code" | "quote" | "bullet" | "ordered"
start: number
end: number
/** Offset where the line content starts after its quote or list marker. */
content: number
value: number
}
const FENCE = /^[ \t]*```/
const FENCE_CLOSE = /^[ \t]*```[ \t]*$/
const QUOTE = /^>[ \t]?/
const BULLET = /^[ \t]*[-*•][ \t]+/
const ORDERED = /^[ \t]*(\d{1,9})\.[ \t]+/
const URL_PATTERN = /^https?:\/\/[^\s<>"'`]+/
const WORD = /[\p{L}\p{N}]/u
const SPACE = /\s/
const MARKS = { "*": "bold", _: "italic", "~": "strike" } as const
/** Slack-style lines: fenced code, quotes, bullets, and numbered items. Unclosed fences stay text. */
export function userMarkdownLines(text: string): UserMarkdownLine[] {
let offset = 0
const lines = text.split("\n").map((line) => {
const start = offset
offset += line.length + 1
return { text: line, start, end: start + line.length }
})
const result: UserMarkdownLine[] = []
for (let index = 0; index < lines.length; index++) {
const line = lines[index]!
const close = FENCE.test(line.text) ? fenceClose(lines, index) : -1
if (close < 0) {
result.push(classify(line))
continue
}
lines.slice(index, close + 1).forEach((code, position, block) =>
result.push({
kind: position === 0 || position === block.length - 1 ? "fence" : "code",
start: code.start,
end: code.end,
content: code.start,
value: 0,
}),
)
index = close
}
return result
}
export function parseUserMarkdownInline(
text: string,
start: number,
end: number,
mentions: UserMarkdownMention[] = [],
): UserMarkdownInline[] {
return parseInline(text, start, end, new Map(mentions.map((mention) => [mention.start, mention])), "")
}
export function parseUserMarkdown(text: string, mentions: UserMarkdownMention[] = []): UserMarkdownBlock[] {
const lookup = new Map(mentions.map((mention) => [mention.start, mention]))
const lines = userMarkdownLines(text)
const inline = (line: UserMarkdownLine) => parseInline(text, line.content, line.end, lookup, "")
const joined = (group: UserMarkdownLine[]) =>
group.flatMap((line, index) => [...(index > 0 ? [{ type: "text" as const, text: "\n" }] : []), ...inline(line)])
const blocks: UserMarkdownBlock[] = []
let index = 0
while (index < lines.length) {
const kind = lines[index]!.kind
const end = kind === "fence" ? fenceEnd(lines, index) : groupEnd(lines, index, kind)
const group = lines.slice(index, end)
index = end
if (kind === "fence") {
const code = group.slice(1, -1)
blocks.push({ type: "code", text: code.length ? text.slice(code[0]!.start, code.at(-1)!.end) : "" })
continue
}
if (kind === "quote") {
blocks.push({ type: "quote", children: joined(group) })
continue
}
if (kind === "bullet" || kind === "ordered") {
blocks.push({
type: "list",
ordered: kind === "ordered",
items: group.map((line) => ({ value: line.value, children: inline(line) })),
})
continue
}
// A trailing newline at the end of a block does not render, so a blank last line before another block needs one more.
const children = [
...joined(group),
...(end < lines.length && group.at(-1)!.start === group.at(-1)!.end
? [{ type: "text" as const, text: "\n" }]
: []),
]
if (children.length) blocks.push({ type: "paragraph", children })
}
return blocks
}
function classify(line: { text: string; start: number; end: number }): UserMarkdownLine {
const quote = QUOTE.exec(line.text)
if (quote) return { kind: "quote", start: line.start, end: line.end, content: line.start + quote[0].length, value: 0 }
const bullet = BULLET.exec(line.text)
if (bullet)
return { kind: "bullet", start: line.start, end: line.end, content: line.start + bullet[0].length, value: 0 }
const ordered = ORDERED.exec(line.text)
if (ordered) {
return {
kind: "ordered",
start: line.start,
end: line.end,
content: line.start + ordered[0].length,
value: Number(ordered[1]),
}
}
return { kind: "text", start: line.start, end: line.end, content: line.start, value: 0 }
}
function fenceClose(lines: { text: string }[], open: number) {
for (let index = open + 1; index < lines.length; index++) {
if (FENCE_CLOSE.test(lines[index]!.text)) return index
}
return -1
}
function fenceEnd(lines: UserMarkdownLine[], open: number) {
for (let index = open + 1; index < lines.length; index++) {
if (lines[index]!.kind === "fence") return index + 1
}
return lines.length
}
function groupEnd(lines: UserMarkdownLine[], start: number, kind: UserMarkdownLine["kind"]) {
let end = start
while (end < lines.length && lines[end]!.kind === kind) end++
return end
}
// Marks follow Slack: they open after a non-word character, close before one, and never span lines.
function parseInline(
text: string,
start: number,
end: number,
mentions: Map<number, UserMarkdownMention>,
open: string,
): UserMarkdownInline[] {
const nodes: UserMarkdownInline[] = []
// Closers do not depend on the opener, so a failed search fails for every later opener of that run.
const failed = new Set<string>()
let from = start
let index = start
const take = (node: UserMarkdownInline, next: number) => {
if (index > from) nodes.push({ type: "text", text: text.slice(from, index) })
nodes.push(node)
index = next
from = next
}
while (index < end) {
const mention = mentions.get(index)
if (mention && mention.end <= end) {
take({ type: "mention", text: text.slice(index, mention.end), mention: mention.type }, mention.end)
continue
}
const char = text[index]!
if (char === "`") {
const run = runLength(text, index, end, char)
const close = failed.has(char + run) ? -1 : codeClose(text, index + run, end, run)
if (close > index + run) {
take(
{ type: "code", text: text.slice(index + run, close), start: index, end: close + run, marker: run },
close + run,
)
continue
}
failed.add(char + run)
index += run
continue
}
const url =
text.startsWith("http", index) && !WORD.test(text[index - 1] ?? "") ? link(text.slice(index, end)) : undefined
if (url) {
take({ type: "link", text: url, start: index, end: index + url.length }, index + url.length)
continue
}
const mark = char === "*" || char === "_" || char === "~" ? MARKS[char] : undefined
if (!mark) {
index++
continue
}
const run = runLength(text, index, end, char)
const opens =
run <= 2 &&
!open.includes(char) &&
!WORD.test(text[index - 1] ?? "") &&
index + run < end &&
!SPACE.test(text[index + run]!)
const close = opens && !failed.has(char + run) ? markClose(text, index + run, end, char, run, mentions) : -1
if (close > 0) {
const children = parseInline(text, index + run, close, mentions, open + char)
take({ type: mark, children, start: index, end: close + run, marker: run }, close + run)
continue
}
if (opens) failed.add(char + run)
index += run
}
if (end > from) nodes.push({ type: "text", text: text.slice(from, end) })
return nodes
}
function runLength(text: string, start: number, end: number, char: string) {
let index = start
while (index < end && text[index] === char) index++
return index - start
}
function codeClose(text: string, start: number, end: number, run: number) {
for (let index = start; index < end; index++) {
if (text[index] !== "`") continue
const length = runLength(text, index, end, "`")
if (length === run) return index
index += length - 1
}
return -1
}
function markClose(
text: string,
start: number,
end: number,
char: string,
run: number,
mentions: Map<number, UserMarkdownMention>,
) {
for (let index = start; index < end; index++) {
const mention = mentions.get(index)
if (mention) {
index = mention.end - 1
continue
}
if (text[index] !== char) continue
const length = runLength(text, index, end, char)
if (length === run && index > start && !SPACE.test(text[index - 1]!) && !WORD.test(text[index + run] ?? "")) {
return index
}
index += length - 1
}
return -1
}
function link(text: string) {
const match = URL_PATTERN.exec(text)?.[0]
if (!match) return
const url = trimLink(match)
return url.length > url.indexOf("//") + 2 ? url : undefined
}
function trimLink(url: string): string {
if (/[.,;:!?]$/.test(url)) return trimLink(url.slice(0, -1))
if (url.endsWith(")") && url.split("(").length < url.split(")").length) return trimLink(url.slice(0, -1))
return url
}