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