mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 06:26:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920e5e1f09 |
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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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*")
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user