Compare commits

...
15 changed files with 1395 additions and 301 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/session-ui/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*")
})
+1
View File
@@ -8,6 +8,7 @@
"./actions": "./src/actions.ts",
"./document": "./src/document.ts",
"./message": "./src/message/current-message.tsx",
"./user-markdown": "./src/message/user-markdown.ts",
"./timeline/projection": "./src/timeline/projection.ts",
"./timeline/detail": "./src/timeline/detail.ts",
"./timeline/row": "./src/timeline/session-timeline-row.tsx",
@@ -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 "./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()
@@ -0,0 +1,88 @@
import { expect, test } from "bun:test"
import {
parseUserMarkdown,
type UserMarkdownBlock,
type UserMarkdownInline,
type UserMarkdownMention,
} from "./user-markdown"
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)
})
@@ -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
}
@@ -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 = {