markdown: fix hyperlink rendering and detection (#1442)

Make named and bare links clickable without exposing their destinations
when the terminal supports OSC 8. Preserve visible URL fallbacks
elsewhere and keep links intact across formatting, streaming, wrapping,
and late terminal capability detection.

Fix #1439
This commit is contained in:
Simon Klee authored and GitHub committed 2026-09-04 21:30:50 +02:00
1 parent 9c55da3a63
commit 7581976f4d
16 files changed
+1941 -99

No files matched your search

+355 -10
View File
@@ -10,20 +10,22 @@ function chunk(text: string): TextChunk {
describe("detectLinks", () => {
test("should set link on markup.link.url chunks", () => {
const content = "[Click here](https://example.com)"
const destination = "custom+v1://example.test/path"
const content = `[Click here](${destination})`
const start = content.indexOf(destination)
const highlights: SimpleHighlight[] = [
[0, 1, "markup.link"],
[1, 11, "markup.link.label"],
[11, 13, "markup.link"],
[13, 32, "markup.link.url"],
[32, 33, "markup.link"],
[11, start, "markup.link"],
[start, start + destination.length, "markup.link.url"],
[start + destination.length, content.length, "markup.link"],
]
const chunks = [chunk("["), chunk("Click here"), chunk("]("), chunk("https://example.com"), chunk(")")]
const chunks = [chunk("["), chunk("Click here"), chunk("]("), chunk(destination), chunk(")")]
const result = detectLinks(chunks, { content, highlights })
expect(result.find((c) => c.text === "https://example.com")!.link).toEqual({ url: "https://example.com" })
expect(result.find((c) => c.text === "Click here")!.link).toEqual({ url: "https://example.com" })
expect(result.find((c) => c.text === destination)!.link).toEqual({ url: destination })
expect(result.find((c) => c.text === "Click here")!.link).toEqual({ url: destination })
})
test("should set link on string.special.url chunks", () => {
@@ -56,9 +58,15 @@ describe("detectLinks", () => {
})
test("should return chunks unchanged when no URL scopes exist", () => {
const content = "hello world"
const highlights: SimpleHighlight[] = [[0, 5, "keyword"]]
const chunks = [chunk("hello"), chunk(" world")]
const content = "**label** and `code`"
const highlights: SimpleHighlight[] = [
[0, 9, "markup.strong"],
[0, 2, "conceal", { conceal: "" }],
[2, 7, "markup.link.label"],
[7, 9, "conceal", { conceal: "" }],
[14, 20, "markup.raw"],
]
const chunks = [chunk("label"), chunk(" and "), chunk("code")]
const result = detectLinks(chunks, { content, highlights })
@@ -95,4 +103,341 @@ describe("detectLinks", () => {
// The label chunk should also get the link
expect(result.find((c) => c.text === "Click here")!.link).toEqual({ url: "https://example.com" })
})
test("links formatted and source-replaced label chunks across intervening conceal captures", () => {
const content = "[a **A&B** z](https://example.com)"
const labelEnd = content.indexOf("]")
const entityStart = content.indexOf("&")
const urlStart = content.indexOf("https://")
const highlights: SimpleHighlight[] = [
[1, labelEnd, "markup.link.label"],
[3, labelEnd - 2, "markup.strong"],
[3, 5, "conceal", { conceal: "" }],
[entityStart, entityStart + 5, "character.special", { conceal: "&" }],
[labelEnd - 4, labelEnd - 2, "conceal", { conceal: "" }],
[labelEnd, labelEnd + 1, "conceal", { conceal: " " }],
[urlStart, content.length - 1, "markup.link.url"],
]
const chunks = ["a ", "A", "&", "B", " z", " ", "(", "https://example.com"].map(chunk)
const sourceRanges = [
{ start: 1, end: 3 },
{ start: 5, end: entityStart },
{ start: entityStart, end: entityStart + 5 },
{ start: entityStart + 5, end: entityStart + 6 },
{ start: labelEnd - 2, end: labelEnd },
{ start: labelEnd, end: labelEnd + 1 },
{ start: labelEnd + 1, end: labelEnd + 2 },
{ start: urlStart, end: content.length - 1 },
]
const result = detectLinks(chunks, { content, highlights, sourceRanges })
for (const label of result.slice(0, 5)) {
expect(label.link).toEqual({ url: "https://example.com" })
}
expect(result[5].link).toBeUndefined()
expect(result[7].link).toEqual({ url: "https://example.com" })
})
test("normalizes escaped named destinations while preserving literal autolink backslashes", () => {
const destination = String.raw`custom://example.test/a\(b\)/c\\d\*e`
const autolink = String.raw`https://example.test/a\*b`
const content = `[label](${destination}) <${autolink}>`
const start = content.indexOf(destination)
const autoStart = content.indexOf(autolink)
const highlights: SimpleHighlight[] = [
[1, 6, "markup.link.label"],
[start, start + destination.length, "markup.link.url"],
[autoStart, autoStart + autolink.length, "markup.link.url"],
]
const result = detectLinks([chunk("label"), chunk(destination), chunk(autolink)], {
content,
highlights,
sourceRanges: [
{ start: 1, end: 6 },
{ start, end: start + destination.length },
{ start: autoStart, end: autoStart + autolink.length },
],
})
expect(result.map((item) => item.link?.url)).toEqual([
String.raw`custom://example.test/a(b)/c\d*e`,
String.raw`custom://example.test/a(b)/c\d*e`,
autolink,
])
})
test("associates each named label with its destination, never an unrelated autolink or bare URL", () => {
const content =
"[orphan] [other] <https://auto.test> https://bare.test [a](custom+v1://target.test) [b](https://second.test)"
const orphanStart = content.indexOf("orphan")
const otherStart = content.indexOf("other")
const autoStart = content.indexOf("https://auto.test")
const bareStart = content.indexOf("https://bare.test")
const labelStart = content.indexOf("a](")
const targetStart = content.indexOf("custom+v1://target.test")
const secondLabel = content.indexOf("b](")
const secondTarget = content.indexOf("https://second.test")
const highlights: SimpleHighlight[] = [
[orphanStart, orphanStart + "orphan".length, "markup.link.label"],
[otherStart, otherStart + "other".length, "markup.link.label"],
[autoStart, autoStart + "https://auto.test".length, "markup.link.url"],
[labelStart, labelStart + 1, "markup.link.label"],
[targetStart, targetStart + "custom+v1://target.test".length, "markup.link.url"],
[secondLabel, secondLabel + 1, "markup.link.label"],
[secondTarget, secondTarget + "https://second.test".length, "markup.link.url"],
]
const sourceRanges = [
{ start: orphanStart, end: orphanStart + "orphan".length },
{ start: otherStart, end: otherStart + "other".length },
{ start: autoStart, end: autoStart + "https://auto.test".length },
{ start: bareStart, end: bareStart + "https://bare.test".length },
{ start: labelStart, end: labelStart + 1 },
{ start: targetStart, end: targetStart + "custom+v1://target.test".length },
{ start: secondLabel, end: secondLabel + 1 },
{ start: secondTarget, end: secondTarget + "https://second.test".length },
]
const chunks = sourceRanges.map(({ start, end }) => chunk(content.slice(start, end)))
const result = detectLinks(chunks, { content, highlights, sourceRanges })
expect(result.map((item) => item.link?.url)).toEqual([
undefined,
undefined,
"https://auto.test",
"https://bare.test",
"custom+v1://target.test",
"custom+v1://target.test",
"https://second.test",
"https://second.test",
])
})
test("prioritizes the outer formatted label and explicit destination over visible URL text", () => {
const content = "[**before https://visible.test after**](custom+v1://target.test)"
const labelStart = 1
const labelEnd = content.indexOf("]")
const visibleStart = content.indexOf("https://visible.test")
const targetStart = content.indexOf("custom+v1://target.test")
const highlights: SimpleHighlight[] = [
[labelStart, labelEnd, "markup.link.label"],
[labelStart + 2, labelEnd, "markup.link.label"],
[visibleStart, visibleStart + "https://visible.test".length, "string.special.url"],
[targetStart, targetStart + "custom+v1://target.test".length, "markup.link.url"],
]
const chunks = [chunk("before "), chunk("https://visible.test"), chunk(" after"), chunk("custom+v1://target.test")]
const sourceRanges = [
{ start: labelStart + 2, end: visibleStart },
{ start: visibleStart, end: visibleStart + "https://visible.test".length },
{ start: visibleStart + "https://visible.test".length, end: labelEnd - 2 },
{ start: targetStart, end: targetStart + "custom+v1://target.test".length },
]
const result = detectLinks(chunks, { content, highlights, sourceRanges })
expect(result.map((item) => item.link?.url)).toEqual(Array(4).fill("custom+v1://target.test"))
})
test("prioritizes explicit destinations when a visible URL spans the entire label", () => {
const content = "[https://visible.test](custom://target.test)"
const visibleStart = 1
const visibleEnd = content.indexOf("]")
const targetStart = content.indexOf("custom://target.test")
const highlights: SimpleHighlight[] = [
[visibleStart, visibleEnd, "markup.link.label"],
[visibleStart, visibleEnd, "string.special.url"],
[targetStart, content.length - 1, "markup.link.url"],
]
const result = detectLinks([chunk("https://visible.test"), chunk("custom://target.test")], {
content,
highlights,
sourceRanges: [
{ start: visibleStart, end: visibleEnd },
{ start: targetStart, end: content.length - 1 },
],
})
expect(result.map((item) => item.link?.url)).toEqual(["custom://target.test", "custom://target.test"])
})
test("processes dense raw markup, named links, bare URLs, and chunks", () => {
const count = 8_000
const parts: string[] = []
const highlights: SimpleHighlight[] = []
const chunks: TextChunk[] = []
const sourceRanges: Array<{ start: number; end: number }> = []
let offset = 0
for (let index = 0; index < count; index++) {
const raw = `\`https://hidden${index}.test\``
const label = `label${index}`
const target = `custom://target${index}.test`
const visible = `https://visible${index}.test`
const part = `${raw} [${label}](${target}) ${visible} `
const labelStart = offset + raw.length + 2
const targetStart = labelStart + label.length + 2
const visibleStart = targetStart + target.length + 2
parts.push(part)
highlights.push([offset, offset + raw.length, "markup.raw"])
highlights.push([labelStart, labelStart + label.length, "markup.link.label"])
highlights.push([targetStart, targetStart + target.length, "markup.link.url"])
chunks.push(chunk(label), chunk(target), chunk(visible))
sourceRanges.push(
{ start: labelStart, end: labelStart + label.length },
{ start: targetStart, end: targetStart + target.length },
{ start: visibleStart, end: visibleStart + visible.length },
)
offset += part.length
}
const result = detectLinks(chunks, { content: parts.join(""), highlights, sourceRanges })
expect(result).toHaveLength(count * 3)
expect(result[0].link?.url).toBe("custom://target0.test")
expect(result[1].link?.url).toBe("custom://target0.test")
expect(result[2].link?.url).toBe("https://visible0.test")
expect(result.at(-3)?.link?.url).toBe(`custom://target${count - 1}.test`)
expect(result.at(-2)?.link?.url).toBe(`custom://target${count - 1}.test`)
expect(result.at(-1)?.link?.url).toBe(`https://visible${count - 1}.test`)
}, 10_000)
test("splits only bare HTTP URLs from surrounding prose and punctuation", () => {
const content = "ftp://ignored.test mailto:user@test See HtTpS://one.test/path, then (http://two.test/a_(b))."
const chunks = [chunk(content)]
const result = detectLinks(chunks, { content, highlights: [] })
expect(result.map((item) => [item.text, item.link?.url])).toEqual([
["ftp://ignored.test mailto:user@test See ", undefined],
["HtTpS://one.test/path", "HtTpS://one.test/path"],
[", then (", undefined],
["http://two.test/a_(b)", "http://two.test/a_(b)"],
[").", undefined],
])
})
test("preserves interior URL apostrophes while excluding surrounding quotes", () => {
const content = "Visit https://example.test/it's-valid or 'https://example.test/quoted'."
const result = detectLinks([chunk(content)], { content, highlights: [] })
expect(result.filter((item) => item.link).map((item) => item.link?.url)).toEqual([
"https://example.test/it's-valid",
"https://example.test/quoted",
])
})
test("excludes concealed formatting markers without stripping literal URL characters", () => {
for (const marker of ["*", "**", "_", "__", "~~"]) {
const url = "https://example.test/path_*~"
const content = `${marker}${url}${marker},`
const end = marker.length + url.length
const highlights: SimpleHighlight[] = []
for (let index = 0; index < marker.length; index++) {
highlights.push([end + index, end + index + 1, "conceal", { conceal: "" }])
}
const result = detectLinks([chunk(url)], {
content,
highlights,
sourceRanges: [{ start: marker.length, end }],
})
expect(result[0].link?.url).toBe(url)
}
})
test("does not link bare URLs inside inline or block raw markup", () => {
const content = "`https://inline.test` https://visible.test ```https://block.test```"
const inlineStart = content.indexOf("https://inline.test")
const blockStart = content.indexOf("https://block.test")
const highlights: SimpleHighlight[] = [
[inlineStart - 1, inlineStart + "https://inline.test".length + 1, "markup.raw"],
[blockStart - 3, blockStart + "https://block.test".length + 3, "markup.raw.block"],
]
const result = detectLinks([chunk(content)], { content, highlights })
expect(result.map((item) => [item.text, item.link?.url])).toEqual([
["`https://inline.test` ", undefined],
["https://visible.test", "https://visible.test"],
[" ```https://block.test```", undefined],
])
})
test("preserves styles and existing links when splitting URL chunks", () => {
const content = "prefix https://new.test suffix"
const fg = RGBA.fromInts(10, 20, 30, 255)
const bg = RGBA.fromInts(40, 50, 60, 255)
const styled = { ...chunk(content), fg, bg, attributes: 7 }
const existing = { ...chunk("existing"), link: { url: "https://existing.test" } }
const result = detectLinks([styled, existing], {
content: `${content}existing`,
highlights: [],
sourceRanges: [
{ start: 0, end: content.length },
{ start: content.length, end: content.length + existing.text.length },
],
})
expect(result.map((item) => item.text)).toEqual(["prefix ", "https://new.test", " suffix", "existing"])
expect(result[1].link).toEqual({ url: "https://new.test" })
expect(result[3].link).toEqual({ url: "https://existing.test" })
for (const item of result.slice(0, 3)) {
expect(item.fg).toBe(fg)
expect(item.bg).toBe(bg)
expect(item.attributes).toBe(7)
}
})
test("uses exact source ranges for conceal replacements and repeated visible text", () => {
const content = "xx [xx](https://example.com) xx"
const labelStart = content.indexOf("xx", 3)
const urlStart = content.indexOf("https://")
const highlights: SimpleHighlight[] = [
[labelStart, labelStart + 2, "markup.link.label"],
[labelStart + 2, labelStart + 3, "conceal", { conceal: "xx" }],
[urlStart, urlStart + "https://example.com".length, "markup.link.url"],
]
const chunks = [chunk("xx "), chunk("xx"), chunk("xx"), chunk("("), chunk("https://example.com"), chunk(" xx")]
const sourceRanges = [
{ start: 0, end: 3 },
{ start: labelStart, end: labelStart + 2 },
{ start: labelStart + 2, end: labelStart + 3 },
{ start: labelStart + 3, end: labelStart + 4 },
{ start: urlStart, end: urlStart + "https://example.com".length },
{ start: content.length - 3, end: content.length },
]
const result = detectLinks(chunks, { content, highlights, sourceRanges })
expect(result[0].link).toBeUndefined()
expect(result[1].link).toEqual({ url: "https://example.com" })
expect(result[2].link).toBeUndefined()
expect(result[4].link).toEqual({ url: "https://example.com" })
expect(result[5].link).toBeUndefined()
})
test("does not split replacement text using unrelated source URL offsets", () => {
const content = "https://hidden.test https://visible.test"
const firstEnd = content.indexOf(" ")
const chunks = [chunk("replacement"), chunk(" "), chunk("https://visible.test")]
const sourceRanges = [
{ start: 0, end: firstEnd },
{ start: firstEnd, end: firstEnd + 1 },
{ start: firstEnd + 1, end: content.length },
]
const result = detectLinks(chunks, { content, highlights: [], sourceRanges })
expect(result.map((item) => [item.text, item.link?.url])).toEqual([
["replacement", undefined],
[" ", undefined],
["https://visible.test", "https://visible.test"],
])
})
})
+137 -26
View File
@@ -2,55 +2,166 @@ import type { TextChunk } from "../text-buffer.js"
import type { SimpleHighlight } from "./tree-sitter/types.js"
const URL_SCOPES = ["markup.link.url", "string.special.url"]
const HTTP_URL_PREFIX = /https?:\/\//i
const BARE_URL = /https?:\/\/[^\s<>"`]+/gi
export function normalizeMarkdownLinkTarget(destination: string): string {
return destination.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, "$1")
}
export function detectLinks(
chunks: TextChunk[],
context: { content: string; highlights: SimpleHighlight[] },
context: { content: string; highlights: SimpleHighlight[]; sourceRanges?: Array<{ start: number; end: number }> },
): TextChunk[] {
const content = context.content
const highlights = context.highlights
const hasBareUrl = HTTP_URL_PREFIX.test(content)
if (!hasBareUrl && !highlights.some(([, , group]) => URL_SCOPES.includes(group))) return chunks
const ranges: Array<{ start: number; end: number; url: string }> = []
const ranges: Array<{ start: number; end: number; url: string; label?: boolean }> = []
const rawRanges: Array<{ start: number; end: number }> = []
const labels = new Map<number, { start: number; end: number }>()
const concealedDelimiters = new Map<number, number>()
for (let i = 0; i < highlights.length; i++) {
const [start, end, group] = highlights[i]
const [start, end, group, meta] = highlights[i]
if (group === "conceal" && meta?.conceal === "" && /^[*_~]+$/.test(content.slice(start, end))) {
concealedDelimiters.set(end, start)
}
if (group === "markup.raw" || group.startsWith("markup.raw.")) {
rawRanges.push({ start, end })
}
if (group === "markup.link.label") {
const existing = labels.get(end)
if (!existing || start < existing.start) labels.set(end, { start, end })
}
if (!URL_SCOPES.includes(group)) continue
const url = content.slice(start, end)
if (!url) continue
ranges.push({ start, end, url })
}
for (let j = i - 1; j >= 0; j--) {
const [labelStart, labelEnd, prev] = highlights[j]
if (prev === "markup.link.label") {
ranges.push({ start: labelStart, end: labelEnd, url })
break
const highlightedCount = ranges.length
for (let index = 0; index < highlightedCount; index++) {
const range = ranges[index]
let labelEnd = range.start
if (content[labelEnd - 1] === "<") labelEnd--
while (labelEnd > 0 && /\s/.test(content[labelEnd - 1])) labelEnd--
if (content[labelEnd - 1] !== "(" || content[labelEnd - 2] !== "]") continue
const label = labels.get(labelEnd - 2)
if (!label) continue
range.url = normalizeMarkdownLinkTarget(range.url)
ranges.push({ ...label, url: range.url, label: true })
}
if (hasBareUrl) {
rawRanges.sort((a, b) => a.start - b.start || b.end - a.end)
ranges.sort((a, b) => a.start - b.start || b.end - a.end)
let rawIndex = 0
let highlightedIndex = 0
for (const match of content.matchAll(BARE_URL)) {
const start = match.index
let url = match[0]
let openParens = 0
let closeParens = 0
for (const character of url) {
if (character === "(") openParens++
if (character === ")") closeParens++
}
if (!prev.startsWith("markup.link")) break
while (url.length > 0) {
const delimiterStart = concealedDelimiters.get(start + url.length)
if (delimiterStart !== undefined && delimiterStart >= start) {
url = url.slice(0, delimiterStart - start)
continue
}
const last = url[url.length - 1]
if (last === ")" && closeParens > openParens) {
closeParens--
} else if (!".,!?;:]}'".includes(last)) {
break
}
url = url.slice(0, -1)
}
const end = start + url.length
if (!url) continue
while (rawIndex < rawRanges.length && rawRanges[rawIndex].end <= start) rawIndex++
if (rawIndex < rawRanges.length && rawRanges[rawIndex].start < end) continue
while (highlightedIndex < ranges.length && ranges[highlightedIndex].end <= start) highlightedIndex++
if (highlightedIndex < ranges.length && ranges[highlightedIndex].start < end) continue
ranges.push({ start, end, url })
}
}
if (ranges.length === 0) return chunks
ranges.sort((a, b) => a.start - b.start || b.end - a.end || Number(b.label ?? false) - Number(a.label ?? false))
// Use content.indexOf to find each chunk's position in the original content.
// This handles concealed text correctly because concealed chunks are either
// empty (length 0, skipped) or single-char replacements (length 1, skipped).
// Non-concealed chunks with length > 1 are exact substrings of content in order.
const linkedChunks: TextChunk[] = []
let contentPos = 0
for (const chunk of chunks) {
if (chunk.text.length <= 1) continue
const idx = content.indexOf(chunk.text, contentPos)
if (idx < 0) continue
for (const range of ranges) {
if (idx < range.end && idx + chunk.text.length > range.start) {
chunk.link = { url: range.url }
break
}
let rangeIndex = 0
for (let index = 0; index < chunks.length; index++) {
const chunk = chunks[index]
if (!chunk.text || chunk.link) {
linkedChunks.push(chunk)
continue
}
contentPos = idx + chunk.text.length
const providedRange = context.sourceRanges?.[index]
const start = providedRange?.start ?? content.indexOf(chunk.text, contentPos)
if (start < 0) {
linkedChunks.push(chunk)
continue
}
const end = providedRange?.end ?? start + chunk.text.length
contentPos = end
while (rangeIndex < ranges.length && ranges[rangeIndex].end <= start) rangeIndex++
if (content.slice(start, end) !== chunk.text) {
const range = ranges[rangeIndex]
if (range?.label && range.start < end) chunk.link = { url: range.url }
linkedChunks.push(chunk)
continue
}
let position = start
for (; rangeIndex < ranges.length; rangeIndex++) {
const range = ranges[rangeIndex]
if (range.end <= position) continue
if (range.start >= end) break
const linkStart = Math.max(position, range.start)
const linkEnd = Math.min(end, range.end)
if (linkStart > position) {
linkedChunks.push({ ...chunk, text: chunk.text.slice(position - start, linkStart - start) })
}
if (linkStart === start && linkEnd === end) {
chunk.link = { url: range.url }
linkedChunks.push(chunk)
} else {
linkedChunks.push({
...chunk,
text: chunk.text.slice(linkStart - start, linkEnd - start),
link: { url: range.url },
})
}
position = linkEnd
if (position === end) break
}
if (position === start) {
linkedChunks.push(chunk)
} else if (position < end) {
linkedChunks.push({ ...chunk, text: chunk.text.slice(position - start) })
}
}
return chunks
return linkedChunks
}
@@ -124,6 +124,31 @@ function add(a, b) {
expect(reconstructed).toBe(originalCode)
})
test("records exactly one original source range per emitted chunk, including conceal replacements", () => {
const content = "xx [xx](https://example.com) xx"
const labelStart = content.indexOf("xx", 3)
const urlStart = content.indexOf("https://")
const highlights: SimpleHighlight[] = [
[3, 4, "conceal", { conceal: "" }],
[labelStart, labelStart + 2, "markup.link.label"],
[labelStart + 2, labelStart + 3, "conceal", { conceal: "xx" }],
[urlStart, urlStart + "https://example.com".length, "markup.link.url"],
]
const ranges: Array<{ start: number; end: number }> = []
const chunks = treeSitterToTextChunks(content, highlights, syntaxStyle, { ranges })
expect(chunks.map((item) => item.text)).toEqual(["xx ", "xx", "xx", "(", "https://example.com", ") xx"])
expect(ranges).toEqual([
{ start: 0, end: 3 },
{ start: 4, end: 6 },
{ start: 6, end: 7 },
{ start: 7, end: 8 },
{ start: 8, end: 27 },
{ start: 27, end: 31 },
])
})
test("should apply different styles to different syntax elements", async () => {
const jsCode = "const number = 42; // comment"
@@ -11,6 +11,7 @@ registerEnvVar({ name: "OTUI_TS_STYLE_WARN", default: false, description: "Enabl
interface TextChunkOptions {
enabled?: boolean
baseHighlight?: string
ranges?: Array<{ start: number; end: number }>
}
interface Boundary {
@@ -43,6 +44,7 @@ export function treeSitterToTextChunks(
options?: TextChunkOptions,
): TextChunk[] {
const chunks: TextChunk[] = []
const ranges = options?.ranges
const defaultStyle = syntaxStyle.getStyle("default")
const concealEnabled = options?.enabled ?? true
const baseStyle = options?.baseHighlight ? syntaxStyle.getStyle(options.baseHighlight) : undefined
@@ -118,6 +120,7 @@ export function treeSitterToTextChunks(
})
: 0,
})
ranges?.push({ start: currentOffset, end: boundary.offset })
}
} else {
const insideInjectionContainer = injectionContainerRanges.some(
@@ -198,6 +201,7 @@ export function treeSitterToTextChunks(
})
: 0,
})
ranges?.push({ start: currentOffset, end: boundary.offset })
}
} else if (currentOffset < boundary.offset) {
const text = content.slice(currentOffset, boundary.offset)
@@ -216,6 +220,7 @@ export function treeSitterToTextChunks(
})
: 0,
})
ranges?.push({ start: currentOffset, end: boundary.offset })
}
if (boundary.type === "start") {
@@ -274,6 +279,7 @@ export function treeSitterToTextChunks(
})
: 0,
})
ranges?.push({ start: currentOffset, end: content.length })
}
return chunks
+107
View File
@@ -7,6 +7,7 @@ import { ManualClock } from "../testing/manual-clock.js"
import type { SimpleHighlight } from "../lib/tree-sitter/types.js"
import { BoxRenderable } from "./Box.js"
import { TextAttributes, type CapturedFrame } from "../types.js"
import { StyledText } from "../lib/styled-text.js"
let currentRenderer: TestRenderer
let renderOnce: () => Promise<void>
@@ -517,6 +518,34 @@ test("CodeRenderable - empty content does not trigger highlighting", async () =>
expect(mockClient.isHighlighting()).toBe(false)
expect(codeRenderable.content).toBe("")
codeRenderable.initialStyledText = new StyledText([{ __isChunk: true, text: "first\nsecond" }])
codeRenderable.streaming = true
codeRenderable.content = "first\nsecond"
await renderOnce()
expect(codeRenderable.isHighlighting).toBe(true)
expect(codeRenderable.lineCount).toBe(2)
expect(captureFrame()).toContain("first")
const changes: Array<{ source: string; visible: string }> = []
codeRenderable.on("line-info-change", () => {
changes.push({ source: codeRenderable.content, visible: codeRenderable.plainText })
})
codeRenderable.content = ""
expect(changes).toEqual([{ source: "", visible: "" }])
expect(codeRenderable.plainText).toBe("")
expect(codeRenderable.lineCount).toBe(1)
expect(codeRenderable.lineInfo.lineSources).toEqual([0])
expect(codeRenderable.isDirty).toBe(true)
await renderOnce()
expect(codeRenderable.isHighlighting).toBe(false)
expect(mockClient.isHighlighting()).toBe(true)
expect(captureFrame()).not.toContain("first")
})
test("CodeRenderable - text renders immediately before highlighting completes", async () => {
@@ -1304,6 +1333,45 @@ test("CodeRenderable - streaming mode respects drawUnstyledText only for initial
expect(codeRenderable.content).toBe("const updated = 'world';")
})
test("CodeRenderable - updating initial styled text refreshes an unresolved streaming preview", async () => {
const mockClient = new MockTreeSitterClient()
const codeRenderable = new CodeRenderable(currentRenderer, {
id: "test-code-streaming-styled-preview",
content: "[Label](https://example.com)",
filetype: "markdown",
syntaxStyle: SyntaxStyle.create(),
treeSitterClient: mockClient,
streaming: true,
drawUnstyledText: true,
initialStyledText: new StyledText([
{ __isChunk: true, text: "Label (https://example.com)", link: { url: "https://example.com/old" } },
]),
})
currentRenderer.root.add(codeRenderable)
await renderOnce()
expect(mockClient.isHighlighting()).toBe(true)
expect(codeRenderable.plainText).toBe("Label (https://example.com)")
const preview = new StyledText([{ __isChunk: true, text: "Label", link: { url: "https://example.com/new" } }])
codeRenderable.initialStyledText = preview
expect(codeRenderable.plainText).toBe("Label")
await renderOnce()
expect(mockClient.isHighlighting()).toBe(true)
expect(currentRenderer.getLinkAt(codeRenderable.x, codeRenderable.y)).toBe("https://example.com/new")
preview.chunks[0]!.text = "Changed"
preview.chunks[0]!.link = { url: "https://example.com/changed" }
codeRenderable.content = "[Changed](https://example.com/changed)"
expect(codeRenderable.plainText).toBe("Changed")
await renderOnce()
expect(mockClient.isHighlighting()).toBe(true)
expect(currentRenderer.getLinkAt(codeRenderable.x, codeRenderable.y)).toBe("https://example.com/changed")
})
test("CodeRenderable - streaming mode with drawUnstyledText=false waits for new highlights", async () => {
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
@@ -1380,6 +1448,45 @@ test("CodeRenderable - onChunks callback can transform chunks when highlights ar
expect(codeRenderable.plainText).toBe("HELLO")
})
test("CodeRenderable - onChunks receives exact source ranges for concealed replacement chunks", async () => {
const content = "[x](https://example.com)"
const highlights: SimpleHighlight[] = [
[0, 1, "conceal", { conceal: "" }],
[1, 2, "markup.link.label"],
[2, 3, "conceal", { conceal: "replacement" }],
[4, 23, "markup.link.url"],
]
const mockClient = new MockTreeSitterClient()
mockClient.setMockResult({ highlights })
let observedRanges: Array<{ start: number; end: number }> | undefined
let observedChunks: string[] | undefined
const codeRenderable = new CodeRenderable(currentRenderer, {
id: "test-code-source-ranges",
content,
filetype: "markdown",
syntaxStyle: SyntaxStyle.create(),
treeSitterClient: mockClient,
onChunks: (chunks, context) => {
observedChunks = chunks.map((item) => item.text)
observedRanges = context.sourceRanges
return chunks
},
})
currentRenderer.root.add(codeRenderable)
await resolveMockHighlights(codeRenderable, mockClient)
expect(observedChunks).toEqual(["x", "replacement", "(", "https://example.com", ")"])
expect(observedRanges).toEqual([
{ start: 1, end: 2 },
{ start: 2, end: 3 },
{ start: 3, end: 4 },
{ start: 4, end: 23 },
{ start: 23, end: 24 },
])
})
test("CodeRenderable - baseHighlight applies a style when parser highlights are empty", async () => {
const quoteColor = RGBA.fromValues(0.25, 0.5, 0.75, 1)
const syntaxStyle = SyntaxStyle.fromStyles({
+19 -1
View File
@@ -21,6 +21,7 @@ export type OnHighlightCallback = (
export interface ChunkRenderContext extends HighlightContext {
highlights: SimpleHighlight[]
sourceRanges?: Array<{ start: number; end: number }>
}
export type OnChunksCallback = (
@@ -124,7 +125,7 @@ export class CodeRenderable extends TextBufferRenderable {
return
}
if (this._initialStyledText && this._drawUnstyledText) {
if (value && this._initialStyledText && this._drawUnstyledText) {
this.textBuffer.setStyledText(this._initialStyledText)
} else {
this.textBuffer.setText(value)
@@ -134,6 +135,15 @@ export class CodeRenderable extends TextBufferRenderable {
}
}
public updateStreamingPreview(content: string, initialStyledText: StyledText): void {
this._content = content
this._initialStyledText = initialStyledText
this.invalidateHighlights()
this.textBuffer.setStyledText(initialStyledText)
this.setRenderedLineSources(undefined)
this.updateTextInfo()
}
public override get lineInfo(): LineInfo {
if (!this._renderedLineSources) return super.lineInfo
if (this._mappedLineInfo) return this._mappedLineInfo
@@ -229,6 +239,11 @@ export class CodeRenderable extends TextBufferRenderable {
set initialStyledText(value: StyledText | undefined) {
if (this._initialStyledText !== value) {
if (value && this._streaming && this._drawUnstyledText && this._isHighlighting) {
this.updateStreamingPreview(this._content, value)
return
}
this._initialStyledText = value
this.invalidateHighlights()
}
@@ -382,16 +397,19 @@ export class CodeRenderable extends TextBufferRenderable {
}
if (highlights.length > 0 || this._onChunks || this._baseHighlight) {
const sourceRanges: Array<{ start: number; end: number }> | undefined = this._onChunks ? [] : undefined
const context: ChunkRenderContext = {
content,
filetype,
syntaxStyle: this._syntaxStyle,
highlights,
sourceRanges,
}
let chunks = treeSitterToTextChunks(content, highlights, this._syntaxStyle, {
enabled: this._conceal,
baseHighlight: this._baseHighlight,
ranges: sourceRanges,
})
// onChunks may rewrite text arbitrarily, so the conceal-only source map would be invalid.
const renderedLineSources = this._onChunks ? undefined : this.getConcealLinesSourceMap(content, highlights)
+221 -34
View File
@@ -1,12 +1,12 @@
import { Renderable, type RenderableOptions } from "../Renderable.js"
import { type RenderContext } from "../types.js"
import { type RenderContext, type TerminalCapabilities } from "../types.js"
import { SyntaxStyle, type StyleDefinition } from "../syntax-style.js"
import type { TextChunk } from "../text-buffer.js"
import { createTextAttributes } from "../utils.js"
import type { BorderStyle } from "../lib/border.js"
import { RGBA, parseColor, type ColorInput } from "../lib/RGBA.js"
import { Lexer, type MarkedToken, type Token, type Tokens } from "marked"
import { CodeRenderable, type OnChunksCallback } from "./Code.js"
import { CodeRenderable, type OnChunksCallback, type OnHighlightCallback } from "./Code.js"
import { BoxRenderable } from "./Box.js"
import { StyledText } from "../lib/styled-text.js"
import { TextRenderable } from "./Text.js"
@@ -21,7 +21,9 @@ import type { TreeSitterClient } from "../lib/tree-sitter/index.js"
import { infoStringToFiletype } from "../lib/tree-sitter/resolve-ft.js"
import { parseMarkdownIncremental, type ParseState } from "./markdown-parser.js"
import type { OptimizedBuffer } from "../buffer.js"
import { detectLinks } from "../lib/detect-links.js"
import { detectLinks, normalizeMarkdownLinkTarget } from "../lib/detect-links.js"
import type { SimpleHighlight } from "../lib/tree-sitter/types.js"
import { MAX_LINK_URL_BYTES } from "../zig.js"
export type MarkdownTableStyle = "grid" | "columns"
@@ -226,6 +228,12 @@ interface ResolvedTableRenderableOptions {
const TRAILING_MARKDOWN_BLOCK_BREAKS_RE = /(?:\r?\n){2,}$/
const TRAILING_MARKDOWN_BLOCK_NEWLINES_RE = /(?:\r?\n)+$/
const markdownLinkEncoder = new TextEncoder()
function isSupportedLinkTarget(url: string): boolean {
if (url.length > MAX_LINK_URL_BYTES) return false
return markdownLinkEncoder.encode(url).byteLength <= MAX_LINK_URL_BYTES
}
function colorsEqual(left?: RGBA, right?: RGBA): boolean {
if (!left || !right) return left === right
@@ -259,6 +267,10 @@ interface ListItemRenderInput {
}
export class MarkdownRenderable extends Renderable {
private static _capabilitySubscriptions = new WeakMap<
RenderContext,
{ renderables: Set<MarkdownRenderable>; listener: (capabilities: TerminalCapabilities) => void }
>()
private _content: string = ""
private _syntaxStyle: SyntaxStyle
private _fg?: RGBA
@@ -275,11 +287,67 @@ export class MarkdownRenderable extends Renderable {
_blockStates: BlockState[] = []
_stableBlockCount = 0
private _styleDirty: boolean = false
private _linkifyMarkdownChunks: OnChunksCallback = (chunks, context) =>
detectLinks(chunks, {
content: context.content,
highlights: context.highlights,
})
private _highlightMarkdownLinks: OnHighlightCallback = (highlights, context) =>
this.addMarkdownLinkHighlights(highlights, context.content)
private _linkifyMarkdownChunks: OnChunksCallback = detectLinks
private _ownedStructuredRenderables = new WeakSet<Renderable>()
private handleCapabilities(): void {
if (!this._renderNode && !this._content.includes("]")) return
const previousHighlight = this._highlightMarkdownLinks
this._highlightMarkdownLinks = (highlights, context) => this.addMarkdownLinkHighlights(highlights, context.content)
for (const state of this._blockStates) {
const renderables: Array<[Renderable, MarkedToken?]> = [[state.renderable, state.token]]
while (renderables.length > 0) {
const [renderable, token] = renderables.pop()!
if (
renderable instanceof TextTableRenderable &&
token?.type === "table" &&
this._ownedStructuredRenderables.has(renderable)
) {
const { cache } = this.buildTableContentCache(
token as Tokens.Table,
renderable === state.renderable ? state.tableContentCache : undefined,
true,
)
if (cache) {
renderable.content = cache.content
if (renderable === state.renderable) state.tableContentCache = cache
}
continue
}
if (
renderable instanceof CodeRenderable &&
renderable.filetype === "markdown" &&
renderable.onChunks === this._linkifyMarkdownChunks &&
renderable.onHighlight === previousHighlight
) {
if (this._streaming && token) renderable.initialStyledText = this.createInitialStyledText(token)
renderable.onHighlight = this._highlightMarkdownLinks
}
if (token?.type === "list" && this._ownedStructuredRenderables.has(renderable)) {
const items = (token as Tokens.List).items
const rows = renderable.getChildren()
for (let index = 0; index < items.length; index++) {
const children = rows[index]?.getChildren()[1]?.getChildren() ?? []
const tokens = this.getRenderableListItemTokens(items[index]!)
for (let childIndex = 0; childIndex < children.length; childIndex++) {
renderables.push([children[childIndex]!, tokens[childIndex]])
}
}
continue
}
for (const child of renderable.getChildren()) renderables.push([child, token])
}
}
this.requestRender()
}
protected _contentDefaultOptions = {
content: "",
@@ -309,6 +377,23 @@ export class MarkdownRenderable extends Renderable {
this._internalBlockMode = options.internalBlockMode ?? this._contentDefaultOptions.internalBlockMode
this.updateBlocks()
let subscription = MarkdownRenderable._capabilitySubscriptions.get(ctx)
if (!subscription) {
const renderables = new Set<MarkdownRenderable>()
let hyperlinksSupported = ctx.capabilities?.hyperlinks === true
subscription = {
renderables,
listener: (capabilities) => {
if (hyperlinksSupported === (capabilities.hyperlinks === true)) return
hyperlinksSupported = capabilities.hyperlinks === true
for (const renderable of renderables) renderable.handleCapabilities()
},
}
ctx.on("capabilities", subscription.listener)
MarkdownRenderable._capabilitySubscriptions.set(ctx, subscription)
}
subscription.renderables.add(this)
}
get content(): string {
@@ -461,8 +546,8 @@ export class MarkdownRenderable extends Renderable {
}
}
private createDefaultChunk(text: string): TextChunk {
return this.createChunk(text, "default")
private createDefaultChunk(text: string, link?: { url: string }): TextChunk {
return this.createChunk(text, "default", link)
}
private createInitialStyledText(token: MarkedToken): StyledText | undefined {
@@ -480,20 +565,20 @@ export class MarkdownRenderable extends Renderable {
return chunks.length > 0 ? new StyledText(chunks) : undefined
}
private renderInlineContent(tokens: Token[], chunks: TextChunk[]): void {
private renderInlineContent(tokens: Token[], chunks: TextChunk[], link?: { url: string }): void {
for (const token of tokens) {
this.renderInlineToken(token as MarkedToken, chunks)
this.renderInlineToken(token as MarkedToken, chunks, link)
}
}
private renderInlineToken(token: MarkedToken, chunks: TextChunk[]): void {
private renderInlineToken(token: MarkedToken, chunks: TextChunk[], link?: { url: string }): void {
switch (token.type) {
case "text":
chunks.push(this.createDefaultChunk(token.text))
chunks.push(this.createDefaultChunk(token.text, link))
break
case "escape":
chunks.push(this.createDefaultChunk(token.text))
chunks.push(this.createDefaultChunk(token.text, link))
break
case "codespan":
@@ -508,37 +593,37 @@ export class MarkdownRenderable extends Renderable {
case "strong":
if (!this._conceal) {
chunks.push(this.createChunk("**", "markup.strong"))
chunks.push(this.createChunk("**", "markup.strong", link))
}
for (const child of token.tokens) {
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strong")
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strong", link)
}
if (!this._conceal) {
chunks.push(this.createChunk("**", "markup.strong"))
chunks.push(this.createChunk("**", "markup.strong", link))
}
break
case "em":
if (!this._conceal) {
chunks.push(this.createChunk("*", "markup.italic"))
chunks.push(this.createChunk("*", "markup.italic", link))
}
for (const child of token.tokens) {
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.italic")
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.italic", link)
}
if (!this._conceal) {
chunks.push(this.createChunk("*", "markup.italic"))
chunks.push(this.createChunk("*", "markup.italic", link))
}
break
case "del":
if (!this._conceal) {
chunks.push(this.createChunk("~~", "markup.strikethrough"))
chunks.push(this.createChunk("~~", "markup.strikethrough", link))
}
for (const child of token.tokens) {
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strikethrough")
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strikethrough", link)
}
if (!this._conceal) {
chunks.push(this.createChunk("~~", "markup.strikethrough"))
chunks.push(this.createChunk("~~", "markup.strikethrough", link))
}
break
@@ -548,9 +633,22 @@ export class MarkdownRenderable extends Renderable {
for (const child of token.tokens) {
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.link.label", linkHref)
}
chunks.push(this.createChunk(" (", "markup.link", linkHref))
chunks.push(this.createChunk(token.href, "markup.link.url", linkHref))
chunks.push(this.createChunk(")", "markup.link", linkHref))
if (
(this.ctx.capabilities?.hyperlinks !== true || !isSupportedLinkTarget(token.href)) &&
token.text !== token.href &&
token.raw !== token.text &&
token.raw !== `<${token.text}>`
) {
chunks.push(this.createChunk(" (", "markup.link", linkHref))
chunks.push(this.createChunk(token.href, "markup.link.url", linkHref))
chunks.push(this.createChunk(")", "markup.link", linkHref))
}
} else if (token.raw === token.text || token.raw === `<${token.text}>`) {
if (token.raw.startsWith("<")) chunks.push(this.createChunk("<", "markup.link", linkHref))
for (const child of token.tokens) {
this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.link.label", linkHref)
}
if (token.raw.startsWith("<")) chunks.push(this.createChunk(">", "markup.link", linkHref))
} else {
chunks.push(this.createChunk("[", "markup.link", linkHref))
for (const child of token.tokens) {
@@ -564,7 +662,7 @@ export class MarkdownRenderable extends Renderable {
}
case "image": {
const imageHref = { url: token.href }
const imageHref = link ?? { url: token.href }
if (this._conceal) {
chunks.push(this.createChunk(token.text || "image", "markup.link.label", imageHref))
} else {
@@ -578,14 +676,14 @@ export class MarkdownRenderable extends Renderable {
}
case "br":
chunks.push(this.createDefaultChunk("\n"))
chunks.push(this.createDefaultChunk("\n", link))
break
default:
if ("tokens" in token && Array.isArray(token.tokens)) {
this.renderInlineContent(token.tokens, chunks)
this.renderInlineContent(token.tokens, chunks, link)
} else if ("text" in token && typeof token.text === "string") {
chunks.push(this.createDefaultChunk(token.text))
chunks.push(this.createDefaultChunk(token.text, link))
}
break
}
@@ -617,11 +715,79 @@ export class MarkdownRenderable extends Renderable {
break
default:
this.renderInlineToken(token, chunks)
this.renderInlineToken(token, chunks, link)
break
}
}
private addMarkdownLinkHighlights(highlights: SimpleHighlight[], content: string): SimpleHighlight[] {
if (!highlights.some(([, , group]) => group === "markup.link.url" || group === "string.special.url")) {
return highlights
}
const modified = [...highlights]
const labels = new Map<number, SimpleHighlight>()
const bracketConceals = new Map<number, number>()
const closings: SimpleHighlight[] = []
for (let index = 0; index < highlights.length; index++) {
const highlight = highlights[index]!
const [start, end, group, meta] = highlight
if (group === "markup.link.label" && start < end && !labels.has(end)) labels.set(end, highlight)
if (end === start + 1 && meta?.conceal === " " && !bracketConceals.has(start)) {
bracketConceals.set(start, index)
}
if (group === "markup.link" && content.slice(start, end) === ")") closings.push(highlight)
}
let closingIndex = 0
for (let index = 0; index < highlights.length; index++) {
const [start, end, group, meta] = highlights[index]!
if (group !== "markup.link.url" && group !== "string.special.url") continue
let urlStart = start
let urlEnd = end
if (content[start] === "<" && content[end - 1] === ">") {
urlStart++
urlEnd--
modified[index] = [urlStart, urlEnd, group, meta]
if (this._conceal) {
modified.push([start, start + 1, "conceal", { conceal: "", isInjection: true }])
modified.push([end - 1, end, "conceal", { conceal: "", isInjection: true }])
}
}
let destinationStart = start
while (destinationStart > 0 && /\s/.test(content[destinationStart - 1]!)) destinationStart--
if (!this._conceal || content[destinationStart - 1] !== "(" || content[destinationStart - 2] !== "]") continue
const label = labels.get(destinationStart - 2)
if (!label) continue
const destination = content.slice(urlStart, urlEnd)
const url = group === "markup.link.url" ? normalizeMarkdownLinkTarget(destination) : destination
if (
(this.ctx.capabilities?.hyperlinks !== true || !isSupportedLinkTarget(url)) &&
content.slice(label[0], label[1]) !== url
) {
continue
}
while (closingIndex < closings.length && closings[closingIndex]![0] < end) closingIndex++
const close = closings[closingIndex]
if (!close) continue
const bracketConcealIndex = bracketConceals.get(destinationStart - 2)
if (bracketConcealIndex !== undefined) {
const [bracketStart, bracketEnd, bracketGroup, bracketMeta] = modified[bracketConcealIndex]!
modified[bracketConcealIndex] = [bracketStart, bracketEnd, bracketGroup, { ...bracketMeta, conceal: "" }]
}
modified.push([destinationStart - 1, close[1], "conceal", { conceal: "", isInjection: true }])
}
return modified
}
private applyMargins(renderable: Renderable, marginTop: number, marginBottom: number): void {
renderable.marginTop = marginTop
renderable.marginBottom = marginBottom
@@ -647,6 +813,7 @@ export class MarkdownRenderable extends Renderable {
streaming: true,
initialStyledText,
baseHighlight,
onHighlight: this._highlightMarkdownLinks,
onChunks,
treeSitterClient: this._treeSitterClient,
width: "100%",
@@ -694,6 +861,7 @@ export class MarkdownRenderable extends Renderable {
flexShrink: 0,
marginBottom,
})
this._ownedStructuredRenderables.add(list)
for (const item of this.getListItemInputs(token, id)) {
list.add(this.createListItemRenderable(item))
@@ -975,7 +1143,11 @@ export class MarkdownRenderable extends Renderable {
baseHighlight?: string,
initialStyledText?: StyledText,
): void {
renderable.initialStyledText = initialStyledText
if (initialStyledText && renderable.streaming && renderable.drawUnstyledText && renderable.isHighlighting) {
renderable.updateStreamingPreview(content, initialStyledText)
} else {
renderable.initialStyledText = initialStyledText
}
renderable.filetype = "markdown"
renderable.syntaxStyle = this._syntaxStyle
renderable.fg = this._fg
@@ -984,6 +1156,7 @@ export class MarkdownRenderable extends Renderable {
renderable.drawUnstyledText = initialStyledText !== undefined
renderable.streaming = true
renderable.baseHighlight = baseHighlight
renderable.onHighlight = this._highlightMarkdownLinks
renderable.content = content
renderable.marginBottom = marginBottom
}
@@ -1385,7 +1558,7 @@ export class MarkdownRenderable extends Renderable {
marginBottom: number = 0,
): TextTableRenderable {
const options = this.resolveTableRenderableOptions()
return new TextTableRenderable(this.ctx, {
const table = new TextTableRenderable(this.ctx, {
id,
content,
width: "100%",
@@ -1404,6 +1577,8 @@ export class MarkdownRenderable extends Renderable {
borderColor: options.borderColor,
selectable: options.selectable,
})
this._ownedStructuredRenderables.add(table)
return table
}
private createTableBlock(
@@ -2140,4 +2315,16 @@ export class MarkdownRenderable extends Renderable {
}
super.renderSelf(buffer, deltaTime)
}
protected destroySelf(): void {
const subscription = MarkdownRenderable._capabilitySubscriptions.get(this.ctx)
if (subscription) {
subscription.renderables.delete(this)
if (subscription.renderables.size === 0) {
this.ctx.off("capabilities", subscription.listener)
MarkdownRenderable._capabilitySubscriptions.delete(this.ctx)
}
}
super.destroySelf()
}
}
@@ -17,10 +17,13 @@ import {
type MockMouse,
type TestRenderer,
MockTreeSitterClient,
setRendererCapabilities,
TestRecorder,
} from "../../testing.js"
import { ManualClock } from "../../testing/manual-clock.js"
import { TextAttributes, type CapturedFrame } from "../../types.js"
import type { SimpleHighlight } from "../../lib/tree-sitter/types.js"
import { stringWidth } from "../../platform/runtime.js"
let renderer: TestRenderer
let mockMouse: MockMouse
@@ -172,6 +175,13 @@ function findSpanContaining(frame: CapturedFrame, text: string) {
return undefined
}
function findRenderedText(text: string): { x: number; y: number } {
const lines = captureFrame().split("\n")
const y = lines.findIndex((line) => line.includes(text))
expect(y).toBeGreaterThanOrEqual(0)
return { x: stringWidth(lines[y]!.slice(0, lines[y]!.indexOf(text))), y }
}
function getMarginBottom(renderable: { getLayoutNode(): { getMargin(edge: Edge): unknown } }): number {
const margin = renderable.getLayoutNode().getMargin(Edge.Bottom) as unknown
if (typeof margin === "number") return margin
@@ -1217,6 +1227,7 @@ test("code block concealment is disabled by default", async () => {
})
test("code block concealment can be enabled with concealCode", async () => {
setRendererCapabilities(renderer, { hyperlinks: false })
const mockTreeSitterClient = createMockTreeSitterClient()
mockTreeSitterClient.setMockResult({
highlights: [[0, 1, "conceal", { conceal: "" }]],
@@ -1224,11 +1235,12 @@ test("code block concealment can be enabled with concealCode", async () => {
const md = createMarkdownRenderable({
id: "markdown-code-conceal-enabled",
content: "```markdown\n# Hidden heading\n```",
content: "```markdown\n# Hidden heading\n```\n\nPlain **prose**\n\n| Name |\n|---|\n| Value |",
syntaxStyle,
conceal: true,
concealCode: true,
treeSitterClient: mockTreeSitterClient,
tableOptions: { widthMode: "content" },
})
renderer.root.add(md)
@@ -1236,13 +1248,29 @@ test("code block concealment can be enabled with concealCode", async () => {
expect(mockTreeSitterClient.isHighlighting()).toBe(true)
const codeBlock = md._blockStates[0]?.renderable as CodeRenderable
const prose = md._blockStates[1]!.renderable as CodeRenderable
const table = md._blockStates[2]!.renderable as TextTableRenderable
mockTreeSitterClient.resolveAllHighlightOnce()
await waitForHighlight(codeBlock)
await Promise.all([waitForHighlight(codeBlock), waitForHighlight(prose)])
await renderer.idle()
const onHighlight = prose.onHighlight
const tableContent = table.content
const frame = captureFrame()
expect(frame).not.toContain("# Hidden heading")
expect(frame).toContain("Hidden heading")
for (const hyperlinks of [true, false]) {
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks }))
await renderOnce()
expect(codeBlock.onHighlight).toBeUndefined()
expect(prose.onHighlight).toBe(onHighlight)
expect(md._blockStates[2]!.renderable).toBe(table)
expect(table.content).toBe(tableContent)
expect(mockTreeSitterClient.isHighlighting()).toBe(false)
expect(captureFrame()).toBe(frame)
}
})
test("toggling concealCode updates existing code block renderables", async () => {
@@ -1960,6 +1988,553 @@ test("inline formatting with conceal=false", async () => {
// Link tests
for (const hyperlinks of [false, true]) {
for (const named of [true, false]) {
test(`${named ? "named" : "bare"} Unicode links after multibyte text preserve hyperlink metadata with hyperlinks=${hyperlinks}`, async () => {
setRendererCapabilities(renderer, { hyperlinks })
resizeRenderer(120, 10)
const prefix = "caf\u00e9 \u4e2d\u6587 \u{1f680} e\u0301 "
const url = "https://example.com/caf\u00e9"
const label = named ? "R\u00e9f\u{1f680}" : url
const md = createMarkdownRenderable({
id: "markdown-unicode-links",
content: `${prefix}${named ? `[${label}](${url})` : url} after`,
syntaxStyle,
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const visibleLink = named && !hyperlinks ? `${label} (${url})` : label
expect(captureFrame()).toContain(`${prefix}${visibleLink} after`)
const position = findRenderedText(label)
expect(position.x).toBe(stringWidth(prefix))
for (let offset = 0; offset < stringWidth(label); offset++) {
expect(renderer.getLinkAt(position.x + offset, position.y)).toBe(url)
}
expect(renderer.getLinkAt(position.x - 1, position.y)).toBeNull()
expect(renderer.getLinkAt(position.x + stringWidth(visibleLink), position.y)).toBeNull()
})
}
test(`named and bare prose and table links preserve hyperlink metadata with hyperlinks=${hyperlinks}`, async () => {
setRendererCapabilities(renderer, { hyperlinks })
resizeRenderer(260, 40)
const md = createMarkdownRenderable({
id: "markdown-link-capabilities",
content:
"Before [Prose](https://example.com/named-prose) after https://example.com/prose " +
"and <https://example.com/auto-prose> **https://example.com/bold-prose** " +
"https://example.com/it's-prose " +
"[https://example.com/identical-prose](https://example.com/identical-prose)\n\n" +
"| Named |\n|---|\n| [Table](https://example.com/named-table) |\n\n" +
"| Link |\n|---|\n| https://example.com/table |\n| <https://example.com/auto-table> |\n" +
"| https://example.com/it's-table |\n" +
"| [https://example.com/identical-table](https://example.com/identical-table) |",
syntaxStyle,
tableOptions: { widthMode: "content" },
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const frame = captureFrame()
expect(frame).toContain(hyperlinks ? "Before Prose after" : "Before Prose (https://example.com/named-prose) after")
expect(frame).toContain(hyperlinks ? "│Table│" : "│Table (https://example.com/named-table)│")
expect(frame).not.toContain("<https://")
for (const [text, url] of [
["Prose", "https://example.com/named-prose"],
["Table", "https://example.com/named-table"],
] as const) {
if (hyperlinks) expect(frame).not.toContain(url)
const position = findRenderedText(text)
for (let offset = 0; offset < text.length; offset++) {
expect(renderer.getLinkAt(position.x + offset, position.y)).toBe(url)
}
}
for (const url of [
"https://example.com/prose",
"https://example.com/table",
"https://example.com/auto-prose",
"https://example.com/auto-table",
"https://example.com/bold-prose",
"https://example.com/it's-prose",
"https://example.com/it's-table",
"https://example.com/identical-prose",
"https://example.com/identical-table",
]) {
expect(frame.split(url)).toHaveLength(2)
const position = findRenderedText(url)
expect(renderer.getLinkAt(position.x, position.y)).toBe(url)
expect(renderer.getLinkAt(position.x + url.length - 1, position.y)).toBe(url)
}
})
test(`streaming Markdown links are clickable before highlighting with hyperlinks=${hyperlinks}`, async () => {
setRendererCapabilities(renderer, { hyperlinks })
resizeRenderer(160, 10)
const client = createMockTreeSitterClient()
const md = createMarkdownRenderable({
id: "markdown-streaming-link-preview",
content:
"Read [**OpenTUI**](https://example.com/docs) and https://example.com/bare " +
"with [![Logo](https://example.com/image)](https://example.com/outer)",
syntaxStyle,
streaming: true,
treeSitterClient: client,
})
renderer.root.add(md)
await renderOnce()
expect(client.isHighlighting()).toBe(true)
const frame = captureFrame()
expect(frame).toContain(hyperlinks ? "Read OpenTUI and" : "Read OpenTUI (https://example.com/docs) and")
expect(frame.match(/https:\/\/example\.com\/bare/g)).toHaveLength(1)
const label = findRenderedText("OpenTUI")
const bare = findRenderedText("https://example.com/bare")
const image = findRenderedText("Logo")
expect(renderer.getLinkAt(label.x, label.y)).toBe("https://example.com/docs")
expect(renderer.getLinkAt(bare.x, bare.y)).toBe("https://example.com/bare")
expect(renderer.getLinkAt(image.x, image.y)).toBe("https://example.com/outer")
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: !hyperlinks }))
await renderOnce()
expect(client.isHighlighting()).toBe(true)
expect(captureFrame()).toContain(hyperlinks ? "Read OpenTUI (https://example.com/docs) and" : "Read OpenTUI and")
const refreshedLabel = findRenderedText("OpenTUI")
const refreshedImage = findRenderedText("Logo")
expect(renderer.getLinkAt(refreshedLabel.x, refreshedLabel.y)).toBe("https://example.com/docs")
expect(renderer.getLinkAt(refreshedImage.x, refreshedImage.y)).toBe("https://example.com/outer")
const code = md._blockStates[0]!.renderable as CodeRenderable
const changes: Array<{ source: string; visible: string }> = []
code.on("line-info-change", () => changes.push({ source: code.content, visible: code.plainText }))
md.content = "[Updated](custom://target)"
expect(changes).toEqual([
{ source: "[Updated](custom://target)", visible: hyperlinks ? "Updated (custom://target)" : "Updated" },
])
await renderOnce()
const updated = findRenderedText("Updated")
expect(renderer.getLinkAt(updated.x, updated.y)).toBe("custom://target")
})
}
test("one-character and formatted prose and table labels preserve hyperlinks", async () => {
setRendererCapabilities(renderer, { hyperlinks: true })
const md = createMarkdownRenderable({
id: "markdown-formatted-links",
content:
"[x](https://example.com/one) and [**bold *nested*** *em* `code`](https://example.com/prose)\n\n" +
"| Link |\n|---|\n| [y](https://example.com/two) |\n" +
"| [**strong *inner*** *italic* `cell`](https://example.com/table) |\n" +
"| [![Logo](https://example.com/image)](https://example.com/outer) |",
syntaxStyle,
tableOptions: { widthMode: "content" },
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
for (const [text, url] of [
["x", "https://example.com/one"],
["bold", "https://example.com/prose"],
["nested", "https://example.com/prose"],
["em", "https://example.com/prose"],
["code", "https://example.com/prose"],
["y", "https://example.com/two"],
["strong", "https://example.com/table"],
["inner", "https://example.com/table"],
["italic", "https://example.com/table"],
["cell", "https://example.com/table"],
["Logo", "https://example.com/outer"],
] as const) {
const position = findRenderedText(text)
for (let offset = 0; offset < text.length; offset++) {
expect(renderer.getLinkAt(position.x + offset, position.y)).toBe(url)
}
}
expect(captureFrame()).not.toContain("https://example.com/")
})
test("compact prose links conceal titled, spaced, angle-bracketed, nested, and escaped destinations", async () => {
setRendererCapabilities(renderer, { hyperlinks: true })
const md = createMarkdownRenderable({
id: "markdown-complex-link-destinations",
content:
'[Titled](https://example.com/title "tip")\n\n' +
"[Angle](<https://example.com/angle>)\n\n" +
'[Spaced]( https://example.com/spaced "spaced tip")\n\n' +
'[Spaced angle]( <https://example.com/spaced-angle> "angle tip")\n\n' +
"[Nested](https://example.com/a_(b))\n\n" +
"[Escaped](https://example.com/a\\_b\\(c\\))",
syntaxStyle,
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
for (const [text, url] of [
["Titled", "https://example.com/title"],
["Angle", "https://example.com/angle"],
["Spaced", "https://example.com/spaced"],
["Spaced angle", "https://example.com/spaced-angle"],
["Nested", "https://example.com/a_(b)"],
["Escaped", "https://example.com/a_b(c)"],
] as const) {
const position = findRenderedText(text)
expect(renderer.getLinkAt(position.x, position.y)).toBe(url)
}
expect(captureFrame()).not.toContain("https://example.com/")
expect(captureFrame()).not.toContain("tip")
})
for (const [label, ending, supported] of [
["maximum ASCII", "a", true],
["oversized ASCII", "aa", false],
["maximum UTF-8", "\u00e9", true],
["oversized UTF-8", "\u00e9a", false],
["maximum escaped ASCII", "\\_", true],
["maximum custom scheme", "a", true],
] as const) {
test(`${label} Markdown destinations respect the native link-pool UTF-8 capacity`, async () => {
setRendererCapabilities(renderer, { hyperlinks: true })
resizeRenderer(560, 12)
const custom = label === "maximum custom scheme"
const prefix = custom ? "custom+v1://example.test/" : "https://example.com/"
const destination = prefix + "a".repeat(511 - prefix.length - (ending.startsWith("\u00e9") ? 1 : 0)) + ending
const target = destination.replace("\\_", "_")
const md = createMarkdownRenderable({
id: "markdown-link-native-capacity",
content: `[Prose](${destination})\n\n| Link |\n|---|\n| [Table](${destination}) |`,
syntaxStyle,
tableOptions: { widthMode: "content" },
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const frame = captureFrame()
const prose = findRenderedText("Prose")
const table = findRenderedText("Table")
expect(new TextEncoder().encode(target)).toHaveLength(supported ? 512 : 513)
if (ending === "\\_") expect(new TextEncoder().encode(destination)).toHaveLength(513)
if (custom) expect(md.content).not.toContain("http")
if (supported) {
expect(frame).not.toContain(target)
expect(renderer.getLinkAt(prose.x, prose.y)).toBe(target)
expect(renderer.getLinkAt(table.x, table.y)).toBe(target)
} else {
expect(frame).toContain(`Prose (${destination})`)
expect(frame).toContain(`Table (${destination})`)
expect(renderer.getLinkAt(prose.x, prose.y)).toBeNull()
expect(renderer.getLinkAt(table.x, table.y)).toBeNull()
}
})
}
test("bare URLs inside inline and fenced code are not hyperlinks", async () => {
setRendererCapabilities(renderer, { hyperlinks: true })
const md = createMarkdownRenderable({
id: "markdown-code-links",
content:
"Inline `https://example.com/inline`\n\n```text\nhttps://example.com/fenced\n```\n\n" +
"| Link |\n|---|\n| `https://example.com/cell` |",
syntaxStyle,
tableOptions: { widthMode: "content" },
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
for (const url of ["https://example.com/inline", "https://example.com/fenced", "https://example.com/cell"]) {
const position = findRenderedText(url)
expect(renderer.getLinkAt(position.x, position.y)).toBeNull()
}
})
test("wrapped markdown link labels preserve hyperlink metadata on every row", async () => {
setRendererCapabilities(renderer, { hyperlinks: true })
resizeRenderer(18, 20)
const md = createMarkdownRenderable({
id: "markdown-wrapped-link",
content: "Visit [first second third](https://example.com/wrapped)",
syntaxStyle,
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
for (const text of ["first", "second", "third"]) {
const position = findRenderedText(text)
expect(renderer.getLinkAt(position.x, position.y)).toBe("https://example.com/wrapped")
}
expect(captureFrame()).not.toContain("https://")
})
for (const [internalBlockMode, wrapped] of [
["coalesced", false],
["top-level", false],
["coalesced", true],
] as const) {
test(`hyperlink capability changes refresh existing ${internalBlockMode}${wrapped ? " wrapped" : ""} prose and table renderables`, async () => {
setRendererCapabilities(renderer, { hyperlinks: false })
const md = createMarkdownRenderable({
id: "markdown-capability-transition",
content: "[Prose](https://example.com/prose)\n\n| Link |\n|---|\n| [Table](https://example.com/table) |",
syntaxStyle,
internalBlockMode,
tableOptions: { widthMode: "content" },
renderNode: wrapped
? (_token, context) => {
const child = context.defaultRender()
if (!child) return undefined
const wrapper = new BoxRenderable(renderer, { width: "100%", flexDirection: "column" })
wrapper.add(child)
return wrapper
}
: undefined,
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const prose = md._blockStates[0]!.renderable
const table = md._blockStates[1]!.renderable
expect(captureFrame()).toContain("Prose (https://example.com/prose)")
expect(captureFrame()).toContain("Table (https://example.com/table)")
const recorder = new TestRecorder(renderer)
recorder.rec()
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: true }))
await renderMarkdownRenderable(md)
recorder.stop()
expect(md._blockStates[0]!.renderable).toBe(prose)
expect(md._blockStates[1]!.renderable).toBe(table)
expect(captureFrame()).toContain("Prose")
expect(captureFrame()).toContain("Table")
expect(captureFrame()).not.toContain("https://example.com/")
for (const recorded of recorder.recordedFrames) {
expect(recorded.frame).toContain("Prose")
expect(recorded.frame).toContain("Table")
}
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: false }))
await renderMarkdownRenderable(md)
expect(md._blockStates[0]!.renderable).toBe(prose)
expect(md._blockStates[1]!.renderable).toBe(table)
expect(captureFrame()).toContain("Prose (https://example.com/prose)")
expect(captureFrame()).toContain("Table (https://example.com/table)")
})
}
test("hyperlink capability changes preserve custom Markdown code callbacks", async () => {
setRendererCapabilities(renderer, { hyperlinks: false })
const onHighlight = (highlights: SimpleHighlight[]) => highlights
const md = createMarkdownRenderable({
id: "markdown-capability-custom-code",
content: "Custom\n\nDelegated\n\nplain",
syntaxStyle,
renderNode: (token, context) => {
if (token.raw.startsWith("Custom")) {
return new CodeRenderable(renderer, {
id: "custom-markdown-code",
content: token.raw,
filetype: "markdown",
syntaxStyle,
treeSitterClient: markdownTreeSitterClient,
onHighlight,
})
}
const renderable = context.defaultRender() as CodeRenderable
if (token.raw.startsWith("Delegated")) renderable.onHighlight = onHighlight
else renderable.content = "[Injected](custom://target)"
return renderable
},
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const custom = md._blockStates[0]!.renderable as CodeRenderable
const delegated = md._blockStates[1]!.renderable as CodeRenderable
const injected = md._blockStates[2]!.renderable as CodeRenderable
expect(captureFrame()).toContain("Injected (custom://target)")
let label = findRenderedText("Injected")
expect(renderer.getLinkAt(label.x, label.y)).toBe("custom://target")
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: true }))
await renderMarkdownRenderable(md)
expect(custom.onHighlight).toBe(onHighlight)
expect(delegated.onHighlight).toBe(onHighlight)
expect(md._blockStates[2]!.renderable).toBe(injected)
expect(captureFrame()).toContain("Injected")
expect(captureFrame()).not.toContain("custom://target")
label = findRenderedText("Injected")
expect(renderer.getLinkAt(label.x, label.y)).toBe("custom://target")
})
test("hyperlink capability changes preserve custom table content", async () => {
setRendererCapabilities(renderer, { hyperlinks: false })
const md = createMarkdownRenderable({
id: "markdown-capability-custom-table",
content: "| Link |\n|---|\n| [Markdown](https://example.com/markdown) |",
syntaxStyle,
renderNode: (token) =>
token.type === "table"
? new TextTableRenderable(renderer, {
content: [[[{ __isChunk: true, text: "Application-owned content", attributes: 0 }]]],
columnWidthMode: "content",
})
: undefined,
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const table = md._blockStates[0]!.renderable as TextTableRenderable
const content = table.content
expect(captureFrame()).toContain("Application-owned content")
for (const hyperlinks of [true, false]) {
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks }))
await renderMarkdownRenderable(md)
expect(md._blockStates[0]!.renderable).toBe(table)
expect(table.content).toBe(content)
expect(captureFrame()).toContain("Application-owned content")
expect(captureFrame()).not.toContain("Markdown")
}
})
for (const wrapped of [false, true]) {
test(`hyperlink capability changes refresh nested prose and tables in ${wrapped ? "wrapped " : ""}top-level lists`, async () => {
setRendererCapabilities(renderer, { hyperlinks: false })
const md = createMarkdownRenderable({
id: "markdown-capability-nested-table",
content:
"- [Before](https://example.com/prose)\n\n | Link |\n |---|\n | [Nested](https://example.com/nested) |",
syntaxStyle,
internalBlockMode: "top-level",
tableOptions: { widthMode: "content" },
renderNode: wrapped
? (token, context) => {
if (token.type !== "list") return undefined
const wrapper = new BoxRenderable(renderer, { width: "100%", flexDirection: "column" })
wrapper.add(context.defaultRender()!)
return wrapper
}
: undefined,
})
renderer.root.add(md)
await renderMarkdownRenderable(md)
const block = md._blockStates[0]!.renderable
const list = wrapped ? block.getChildren()[0]! : block
const content = list.getChildren()[0]!.getChildren()[1]!
const prose = content.getChildren()[0]!
const table = content.getChildren()[1]!
expect(prose).toBeInstanceOf(CodeRenderable)
expect(table).toBeInstanceOf(TextTableRenderable)
expect(captureFrame()).toContain("Before (https://example.com/prose)")
expect(captureFrame()).toContain("Nested (https://example.com/nested)")
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: true }))
await renderMarkdownRenderable(md)
expect(md._blockStates[0]!.renderable).toBe(block)
expect(wrapped ? block.getChildren()[0] : block).toBe(list)
expect(list.getChildren()[0]!.getChildren()[1]).toBe(content)
expect(content.getChildren()[0]).toBe(prose)
expect(content.getChildren()[1]).toBe(table)
expect(captureFrame()).not.toContain("https://example.com/")
for (const [text, url] of [
["Before", "https://example.com/prose"],
["Nested", "https://example.com/nested"],
] as const) {
const compact = findRenderedText(text)
expect(renderer.getLinkAt(compact.x, compact.y)).toBe(url)
}
renderer.emit("capabilities", setRendererCapabilities(renderer, { hyperlinks: false }))
await renderMarkdownRenderable(md)
expect(md._blockStates[0]!.renderable).toBe(block)
expect(wrapped ? block.getChildren()[0] : block).toBe(list)
expect(list.getChildren()[0]!.getChildren()[1]).toBe(content)
expect(content.getChildren()[0]).toBe(prose)
expect(content.getChildren()[1]).toBe(table)
expect(captureFrame()).toContain("Before (https://example.com/prose)")
expect(captureFrame()).toContain("Nested (https://example.com/nested)")
})
}
test("dense Markdown link highlights conceal every destination", () => {
setRendererCapabilities(renderer, { hyperlinks: true })
const md = createMarkdownRenderable({ id: "markdown-dense-link-highlights", syntaxStyle })
const addMarkdownLinkHighlights = (
md as unknown as { addMarkdownLinkHighlights: (items: SimpleHighlight[], source: string) => SimpleHighlight[] }
).addMarkdownLinkHighlights.bind(md)
const highlights: SimpleHighlight[] = [
[0, 9, "markup.strong"],
[0, 2, "conceal", { conceal: "" }],
[2, 7, "markup.link.label"],
[7, 9, "conceal", { conceal: "" }],
[14, 20, "markup.raw"],
]
expect(addMarkdownLinkHighlights(highlights, "**label** and `code`")).toBe(highlights)
highlights.length = 0
let content = ""
for (let index = 0; index < 1024; index++) {
const start = content.length
const destination = `https://example.com/${index}`
content += `[x](${destination}) `
highlights.push(
[start + 1, start + 2, "markup.link.label"],
[start + 2, start + 3, "conceal", { conceal: " " }],
[start + 4, start + 4 + destination.length, "markup.link.url"],
[start + 4 + destination.length, start + 5 + destination.length, "markup.link"],
)
}
const modified = addMarkdownLinkHighlights(highlights, content)
expect(modified.filter(([, , group, meta]) => group === "conceal" && meta?.isInjection)).toHaveLength(1024)
expect(modified.filter(([, , group, meta]) => group === "conceal" && meta?.conceal === " ")).toHaveLength(0)
})
test("multiple markdown renderables share and clean up one renderer capability listener", () => {
const listenersBefore = renderer.listenerCount("capabilities")
const renderables = Array.from({ length: 12 }, (_, index) =>
createMarkdownRenderable({
id: `markdown-shared-capability-listener-${index}`,
syntaxStyle,
}),
)
expect(renderer.listenerCount("capabilities")).toBe(listenersBefore + 1)
for (const renderable of renderables) renderable.destroyRecursively()
expect(renderer.listenerCount("capabilities")).toBe(listenersBefore)
})
test("links with conceal mode", async () => {
const markdown = `Check out [OpenTUI](https://github.com/sst/opentui) for more.`
+21
View File
@@ -55,6 +55,7 @@ import { type Clock, type TimerHandle, SystemClock } from "./lib/clock.js"
import { StdinParser, type StdinEvent, type StdinParserProtocolContext } from "./lib/stdin-parser.js"
import { matchesKeyBinding } from "./lib/keybinding.internal.js"
import { RendererThemeMode } from "./renderer-theme-mode.js"
import { getLinkId } from "./utils.js"
registerEnvVar({
name: "OTUI_DUMP_CAPTURES",
@@ -2052,6 +2053,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
const destroyListener = (): void => {
destroySurface()
}
const capabilitiesListener = (capabilities: TerminalCapabilities): void => {
snapshotContext.capabilities = capabilities
renderContext.emit(CliRenderEvents.CAPABILITIES, capabilities)
}
const assertNotDestroyed = (): void => {
if (surfaceDestroyed) {
@@ -2278,6 +2283,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
surfaceDestroyed = true
renderer.off(CliRenderEvents.DESTROY, destroyListener)
renderer.off(CliRenderEvents.CAPABILITIES, capabilitiesListener)
let destroyError: unknown = null
@@ -2304,6 +2310,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
}
renderer.on(CliRenderEvents.DESTROY, destroyListener)
renderer.on(CliRenderEvents.CAPABILITIES, capabilitiesListener)
return {
get renderContext(): RenderContext {
@@ -3862,6 +3869,20 @@ export class CliRenderer extends EventEmitter implements RenderContext {
return this.lib.checkHit(this.rendererPtr, x, y)
}
public getLinkIdAt(x: number, y: number): number {
if (this._isDestroyed || !Number.isInteger(x) || !Number.isInteger(y)) return 0
const buffer = this.currentRenderBuffer
if (x < 0 || y < 0 || x >= buffer.width || y >= buffer.height) return 0
return getLinkId(buffer.buffers.attributes[y * buffer.width + x])
}
public getLinkAt(x: number, y: number): string | null {
const linkId = this.getLinkIdAt(x, y)
return linkId === 0 ? null : this.currentRenderBuffer.lib.linkGetUrl(linkId) || null
}
private takeMemorySnapshot(): void {
if (this._isDestroyed) return
@@ -3,6 +3,8 @@ import { Writable } from "stream"
import { createCliRenderer, CliRenderer, CliRenderEvents } from "../renderer.js"
import { BoxRenderable } from "../renderables/Box.js"
import { ImageRenderable } from "../renderables/Image.js"
import { MarkdownRenderable } from "../renderables/Markdown.js"
import { SyntaxStyle } from "../syntax-style.js"
import { ManualClock } from "../testing/manual-clock.js"
import { createTestStdin, TestWriteStream } from "../testing/test-streams.js"
@@ -169,6 +171,89 @@ test("non-process stdout: rendered bytes flow to the custom Writable", async ()
expect(received.includes(0x1b)).toBe(true)
})
test("late Ghostty capability detection emits OSC 8 for compact Markdown links", async () => {
const stdin = createTestStdin()
const stdout = createCollectingStdout(100, 8)
const renderer = await createCliRenderer({ stdin, stdout, useMouse: true })
destroyFns.push(() => renderer.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
destroyFns.push(() => syntaxStyle.destroy())
renderer.root.add(
new MarkdownRenderable(renderer, {
content: "| Link |\n| --- |\n| [OpenTUI](https://github.com/anomalyco/opentui) |\n| https://example.com/path |",
syntaxStyle,
tableOptions: { style: "columns", widthMode: "content" },
}),
)
await renderer.idle()
await flushWritable(stdout)
const initialFrame = new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))
expect(initialFrame).toContain("OpenTUI (https://github.com/anomalyco/opentui)")
expect(initialFrame.match(/https:\/\/example\.com\/path/g)).toHaveLength(1)
expect(stdout.getWrittenBytes().toString("binary")).not.toContain("\x1b]8;id=")
stdout.clearWrites()
stdin.emit("data", Buffer.from("\x1bP>|ghostty 1.1.3\x1b\\"))
await renderer.idle()
await flushWritable(stdout)
const finalFrame = new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))
const output = stdout.getWrittenBytes().toString("binary")
expect(renderer.capabilities?.hyperlinks).toBe(true)
expect(finalFrame).toContain("OpenTUI")
expect(finalFrame).not.toContain("github.com/anomalyco/opentui")
expect(finalFrame.match(/https:\/\/example\.com\/path/g)).toHaveLength(1)
expect(output).toContain(";https://github.com/anomalyco/opentui\x1b\\")
expect(output).toContain(";https://example.com/path\x1b\\")
expect(output).toContain("\x1b]8;;\x1b\\")
})
test("unidentified truecolor terminals preserve the visible Markdown link fallback", async () => {
const previousTerm = process.env.TERM
const previousColorTerm = process.env.COLORTERM
process.env.TERM = "xterm-256color"
process.env.COLORTERM = "truecolor"
try {
const stdout = createCollectingStdout(80, 6)
const renderer = await createCliRenderer({
stdin: createTestStdin(),
stdout,
remote: false,
forwardEnvKeys: ["TERM", "COLORTERM"],
})
destroyFns.push(() => renderer.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
destroyFns.push(() => syntaxStyle.destroy())
renderer.root.add(
new MarkdownRenderable(renderer, {
content: "| Link |\n|---|\n| [OpenTUI](https://example.com/docs) |",
syntaxStyle,
tableOptions: { style: "columns", widthMode: "content" },
}),
)
await renderer.idle()
await flushWritable(stdout)
expect(renderer.capabilities?.rgb).toBe(true)
expect(renderer.capabilities?.hyperlinks).toBe(false)
expect(new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))).toContain(
"OpenTUI (https://example.com/docs)",
)
expect(stdout.getWrittenBytes().toString("binary")).not.toContain("\x1b]8;id=")
} finally {
if (previousTerm === undefined) delete process.env.TERM
else process.env.TERM = previousTerm
if (previousColorTerm === undefined) delete process.env.COLORTERM
else process.env.COLORTERM = previousColorTerm
}
})
test("auto images use detected Kitty graphics and delete cleared placements", async () => {
const stdin = createTestStdin()
const stdout = createCollectingStdout(8, 4)
+177 -2
View File
@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { createTestRenderer, MouseButtons, type MockMouse, type TestRenderer } from "../testing.js"
import { Renderable, type RenderableOptions } from "../Renderable.js"
import { link, t } from "../lib/styled-text.js"
import { TextRenderable } from "../renderables/Text.js"
import type { MouseEvent } from "../renderer.js"
import type { RenderContext } from "../types.js"
import { TextAttributes, type RenderContext } from "../types.js"
import type { Selection } from "../lib/selection.js"
import { getLinkId } from "../utils.js"
class TestRenderable extends Renderable {
public selectionActive = false
@@ -22,6 +25,149 @@ class TestRenderable extends Renderable {
}
}
describe("renderer getLinkAt", () => {
let renderer: TestRenderer
let mockMouse: MockMouse
let renderOnce: () => Promise<void>
beforeEach(async () => {
;({ renderer, mockMouse, renderOnce } = await createTestRenderer({ width: 12, height: 5, useMouse: true }))
})
afterEach(() => renderer.destroy())
test("resolves rendered and clicked links while rejecting invalid coordinates and destroyed renderers", async () => {
const url = "https://example.com/rendered"
const content = t`A ${link(url)("link")} ${link("https://example.com/second")("two")}`
const text = new TextRenderable(renderer, {
id: "linked-text",
position: "absolute",
left: 2,
top: 1,
width: 10,
height: 1,
content,
})
const clicked: { url: string | null } = { url: null }
text.onMouseDown = (event) => {
clicked.url = renderer.getLinkAt(event.x, event.y)
}
renderer.root.add(text)
await renderOnce()
const linkX = text.x + 2
const linkId = renderer.getLinkIdAt(linkX, text.y)
expect(linkId).toBeGreaterThan(0)
expect(renderer.getLinkIdAt(linkX + 3, text.y)).toBe(linkId)
expect(renderer.getLinkAt(linkX, text.y)).toBe(url)
expect(renderer.getLinkAt(linkX + 3, text.y)).toBe(url)
expect(renderer.getLinkIdAt(text.x, text.y)).toBe(0)
expect(renderer.getLinkAt(text.x, text.y)).toBeNull()
expect(renderer.getLinkIdAt(renderer.width - 1, renderer.height - 1)).toBe(0)
expect(renderer.getLinkAt(renderer.width - 1, renderer.height - 1)).toBeNull()
const lib = renderer.currentRenderBuffer.lib
const urlLookups = spyOn(lib, "linkGetUrl")
const styledTextUpdates = spyOn(lib, "textBufferSetStyledText")
try {
let hoveredLinkId = 0
text.onMouseMove = (event) => {
hoveredLinkId = renderer.getLinkIdAt(event.x, event.y)
renderer.setMousePointer(hoveredLinkId ? "pointer" : "default")
renderer.requestRender()
}
renderer.addPostProcessFn((buffer) => {
if (!hoveredLinkId) return
const attributes = buffer.buffers.attributes
for (let index = 0; index < attributes.length; index++) {
if (getLinkId(attributes[index]!) === hoveredLinkId) attributes[index] |= TextAttributes.UNDERLINE
}
})
for (const [x, first, second] of [
[linkX, true, false],
[linkX + 5, false, true],
[linkX + 4, false, false],
] as const) {
await mockMouse.moveTo(x, text.y)
await renderOnce()
const start = text.y * renderer.width + text.x
expect(
Array.from(
renderer.currentRenderBuffer.buffers.attributes.subarray(start, start + text.width),
(attributes) => Boolean(attributes & TextAttributes.UNDERLINE),
),
).toEqual([false, false, first, first, first, first, false, second, second, second])
}
expect(text.content).toBe(content)
expect(styledTextUpdates).toHaveBeenCalledTimes(0)
expect(urlLookups).toHaveBeenCalledTimes(0)
await mockMouse.pressDown(linkX, text.y)
expect(clicked.url).toBe(url)
expect(urlLookups).toHaveBeenCalledTimes(1)
} finally {
styledTextUpdates.mockRestore()
urlLookups.mockRestore()
}
renderer.useMouse = false
expect(renderer.getLinkIdAt(linkX, text.y)).toBe(linkId)
expect(renderer.getLinkAt(linkX, text.y)).toBe(url)
for (const [x, y] of [
[-1, 0],
[0, -1],
[renderer.width, 0],
[0, renderer.height],
[Number.NaN, 0],
[0, Number.NaN],
[Number.POSITIVE_INFINITY, 0],
[0, Number.NEGATIVE_INFINITY],
[0.5, 0],
[0, 0.5],
]) {
expect(renderer.getLinkIdAt(x, y)).toBe(0)
expect(renderer.getLinkAt(x, y)).toBeNull()
}
renderer.destroy()
expect(renderer.getLinkIdAt(0, 0)).toBe(0)
expect(renderer.getLinkAt(0, 0)).toBeNull()
})
test("resolves linked cells after wrapping and across wide-character cells", async () => {
const url = "https://example.com/wrapped"
const text = new TextRenderable(renderer, {
id: "wrapped-link",
position: "absolute",
left: 1,
top: 1,
width: 4,
height: 2,
wrapMode: "char",
content: t`${link(url)("ab\u754cde")}`,
})
renderer.root.add(text)
await renderOnce()
const linkId = renderer.getLinkIdAt(text.x, text.y)
expect(linkId).toBeGreaterThan(0)
for (const [x, y] of [
[text.x, text.y],
[text.x + 2, text.y],
[text.x + 3, text.y],
[text.x, text.y + 1],
[text.x + 1, text.y + 1],
]) {
expect(renderer.getLinkIdAt(x, y)).toBe(linkId)
expect(renderer.getLinkAt(x, y)).toBe(url)
}
})
})
describe("renderer handleMouseData", () => {
let renderer: TestRenderer
let mockMouse: MockMouse
@@ -1255,6 +1401,35 @@ describe("renderer handleMouseData split height", () => {
}
})
test("split-footer mouse handlers resolve links using renderer-relative coordinates", async () => {
try {
const url = "https://example.com/split-footer"
const text = new TextRenderable(renderer, {
id: "split-footer-link",
position: "absolute",
left: 2,
top: 1,
width: 6,
height: 1,
content: t`${link(url)("click")}`,
})
const clicked: { url: string | null } = { url: null }
text.onMouseDown = (event) => {
clicked.url = renderer.getLinkAt(event.x, event.y)
}
renderer.root.add(text)
await renderOnce()
const screenY = baseHeight - splitHeight + text.y
await mockMouse.pressDown(text.x + 1, screenY)
expect(clicked.url).toBe(url)
expect(renderer.getLinkAt(text.x + 1, screenY)).toBeNull()
} finally {
renderer.destroy()
}
})
test("split height clamps to terminal height before applying mouse offsets", async () => {
const requestedFooterHeight = 8
const terminalHeight = 5
@@ -1,6 +1,7 @@
import { afterEach, expect, test } from "bun:test"
import { readFile } from "node:fs/promises"
import type { OptimizedBuffer } from "../buffer.js"
import { RGBA } from "../lib/RGBA.js"
import { Renderable, type RenderableOptions } from "../Renderable.js"
import { BoxRenderable } from "../renderables/Box.js"
@@ -9,16 +10,12 @@ import { ImageRenderable } from "../renderables/Image.js"
import { MarkdownRenderable } from "../renderables/Markdown.js"
import { TextRenderable } from "../renderables/Text.js"
import { SyntaxStyle } from "../syntax-style.js"
import { createTestRenderer, MockTreeSitterClient, type TestRenderer } from "../testing.js"
import { createTestRenderer, MockTreeSitterClient, setRendererCapabilities, type TestRenderer } from "../testing.js"
import type { RenderContext } from "../types.js"
import { getLinkId } from "../utils.js"
type ClaimedCommit = {
snapshot: {
height: number
buffers: { char: Uint32Array }
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
destroy(): void
}
snapshot: OptimizedBuffer
rowColumns: number
startOnNewLine: boolean
trailingNewline: boolean
@@ -297,6 +294,63 @@ test("ScrollbackSurface works with MarkdownRenderable top-level blocks", async (
}
})
test("ScrollbackSurface forwards hyperlink capability changes to existing Markdown tables", async () => {
const { renderer } = await createSplitFooterRenderer()
setRendererCapabilities(renderer, { hyperlinks: false })
const capabilityListenerCount = renderer.listenerCount("capabilities")
const surface = renderer.createScrollbackSurface({ startOnNewLine: true })
const mockTreeSitterClient = createMockTreeSitterClient({ autoResolveTimeout: 0 })
mockTreeSitterClient.setMockResult({ highlights: [] })
const url = "https://example.com/table"
const markdown = new MarkdownRenderable(surface.renderContext, {
id: "surface-markdown-capability-transition",
content: `| Link |\n|---|\n| [OpenTUI](${url}) |`,
syntaxStyle,
tableOptions: { widthMode: "content" },
treeSitterClient: mockTreeSitterClient,
})
surface.root.add(markdown)
await surface.settle()
const table = markdown._blockStates[0]!.renderable
surface.commitRows(0, surface.height)
for (const hyperlinks of [true, false]) {
const capabilities = setRendererCapabilities(renderer, { hyperlinks })
renderer.emit("capabilities", capabilities)
expect(surface.renderContext.capabilities).toBe(capabilities)
await surface.settle()
expect(markdown._blockStates[0]!.renderable).toBe(table)
surface.commitRows(0, surface.height)
}
const commits = claimCommits(renderer)
try {
expect(commits).toHaveLength(3)
const frames = commits.map((commit) => decoder.decode(commit.snapshot.getRealCharBytes(true)))
expect(frames[0]).toContain(`OpenTUI (${url})`)
expect(frames[1]).toContain("\u2502OpenTUI\u2502")
expect(frames[1]).not.toContain(url)
expect(frames[2]).toContain(`OpenTUI (${url})`)
for (const [index, commit] of commits.entries()) {
const lines = frames[index]!.split("\n")
const y = lines.findIndex((line) => line.includes("OpenTUI"))
expect(y).toBeGreaterThanOrEqual(0)
const x = lines[y]!.indexOf("OpenTUI")
const attributes = commit.snapshot.buffers.attributes[y * commit.snapshot.width + x]!
expect(commit.snapshot.lib.linkGetUrl(getLinkId(attributes))).toBe(url)
}
} finally {
destroyClaimedCommits(commits)
}
surface.destroy()
expect(renderer.listenerCount("capabilities")).toBe(capabilityListenerCount)
})
test("ScrollbackSurface commitRows respects top-level block margins from custom renderNode blocks", async () => {
const { renderer } = await createSplitFooterRenderer()
const surface = renderer.createScrollbackSurface({ startOnNewLine: true })
+4 -1
View File
@@ -102,6 +102,8 @@ import { isBunfsPath } from "./lib/bunfs.js"
import { resolveNativeLibraryPath } from "#opentui/runtime-assets"
import { allocStruct } from "bun-ffi-structs"
export const MAX_LINK_URL_BYTES = 512
// Struct outputs use `buffer` instead of `ptr`. A `buffer` call is about 3x cheaper on Bun 1.3 and 2.5x cheaper on
// Bun 1.4. `buffer` rejects an argument that is not a view with a TypeError. `ptr` accepts a number as a raw address.
// `buffer` also skips the Node pointer normalizer. Bun 1.3 rejects ArrayBuffer and DataView for `buffer`, so keep one
@@ -2483,6 +2485,7 @@ export interface RenderLib extends AudioEngineLib {
) => NativeRenderOperationResult
getNextBuffer: (renderer: RendererHandle) => OptimizedBuffer
getCurrentBuffer: (renderer: RendererHandle) => OptimizedBuffer
linkGetUrl: (linkId: number, maxLen?: number) => string
rendererSetPaletteState: (
renderer: RendererHandle,
palette: readonly RGBA[],
@@ -4219,7 +4222,7 @@ class FFIRenderLib implements RenderLib {
return this.opentui.symbols.linkAlloc(viewOrNull(urlBytes), urlBytes.byteLength)
}
public linkGetUrl(linkId: number, maxLen: number = 512): string {
public linkGetUrl(linkId: number, maxLen: number = MAX_LINK_URL_BYTES): string {
const outBuffer = new Uint8Array(maxLen)
const actualLen = this.opentui.symbols.linkGetUrl(linkId, viewOrNull(outBuffer), maxLen)
return this.decoder.decode(outBuffer.slice(0, actualLen))
+52 -14
View File
@@ -3,20 +3,24 @@ import {
createCliRenderer,
t,
fg,
underline,
link,
bold,
italic,
BoxRenderable,
RGBA,
RenderableEvents,
TextAttributes,
TextRenderable,
getLinkId,
type MouseEvent,
type OptimizedBuffer,
type RenderContext,
type StyledText,
} from "@opentui/core"
import { spawn } from "node:child_process"
import { setupCommonDemoKeys } from "./lib/standalone-keys.js"
let nextZIndex = 100
let draggableBoxes: DraggableBox[] = []
let dragModeEnabled = false
class DraggableBox extends BoxRenderable {
@@ -94,10 +98,31 @@ export function run(renderer: CliRenderer): void {
renderer.start()
renderer.setBackgroundColor("#0f172a") // Deep slate blue background
let hoveredLinkId = 0
const highlightHoveredLink = (buffer: OptimizedBuffer): void => {
if (!hoveredLinkId) return
const attributes = buffer.buffers.attributes
for (let index = 0; index < attributes.length; index++) {
if (getLinkId(attributes[index]!) === hoveredLinkId) attributes[index] |= TextAttributes.UNDERLINE
}
}
const updateHoveredLink = (event: MouseEvent): void => {
const nextLinkId = renderer.getLinkIdAt(event.x, event.y)
if (nextLinkId === hoveredLinkId) return
if (nextLinkId === 0 || hoveredLinkId === 0) renderer.setMousePointer(nextLinkId ? "pointer" : "default")
hoveredLinkId = nextLinkId
renderer.requestRender()
}
const container = new BoxRenderable(renderer, {
id: "main-container",
width: "100%",
height: "100%",
onMouseMove: updateHoveredLink,
onMouseOver: updateHoveredLink,
})
renderer.root.add(container)
@@ -115,12 +140,18 @@ export function run(renderer: CliRenderer): void {
container.add(header)
// Toggle drag mode with 'd' key
renderer.keyInput.on("keypress", (event) => {
const handleKeypress = (event: { name: string }): void => {
if (event.name === "d") {
dragModeEnabled = !dragModeEnabled
header.content = getHeaderContent()
}
}
container.once(RenderableEvents.DESTROYED, () => {
renderer.removePostProcessFn(highlightHoveredLink)
renderer.keyInput.off("keypress", handleKeypress)
})
renderer.addPostProcessFn(highlightHoveredLink)
renderer.keyInput.on("keypress", handleKeypress)
// Card 1: Project Info
createCard(
@@ -134,9 +165,9 @@ export function run(renderer: CliRenderer): void {
RGBA.fromHex("#1e293be6"), // Dark slate
t`${bold(fg("#f472b6")("♥ Project Info"))}
${fg("#e2e8f0")("Source:")} ${link("https://github.com/anomalyco/opentui")(underline(fg("#38bdf8")("GitHub Repository")))}
${fg("#e2e8f0")("Web:")} ${link("https://opentui.com")(underline(fg("#34d399")("Official Website")))}
${fg("#e2e8f0")("License:")} ${link("https://github.com/anomalyco/opentui/blob/main/LICENSE")(underline(fg("#fbbf24")("MIT")))}`,
${fg("#e2e8f0")("Source:")} ${link("https://github.com/anomalyco/opentui")(fg("#38bdf8")("GitHub Repository"))}
${fg("#e2e8f0")("Web:")} ${link("https://opentui.com")(fg("#34d399")("Official Website"))}
${fg("#e2e8f0")("License:")} ${link("https://github.com/anomalyco/opentui/blob/main/LICENSE")(fg("#fbbf24")("MIT"))}`,
)
// Card 2: Documentation
@@ -183,7 +214,7 @@ function createCard(
width: number,
height: number,
bg: RGBA,
content: any,
content: StyledText,
) {
const card = new DraggableBox(renderer, id, x, y, width, height, bg)
@@ -192,21 +223,28 @@ function createCard(
content: content,
width: width - 2, // Account for padding
height: height - 2,
onMouseDown(event) {
const url = event.button === 0 ? renderer.getLinkAt(event.x, event.y) : null
if (!url) return
event.preventDefault()
event.stopPropagation()
const command =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open"
const child = spawn(command, [url], { detached: true, stdio: "ignore" })
child.on("error", (error) => console.error("Failed to open link:", error))
child.unref()
},
})
card.add(text)
container.add(card)
draggableBoxes.push(card)
}
export function destroy(renderer: CliRenderer): void {
for (const box of draggableBoxes) {
renderer.root.remove(box)
}
draggableBoxes = []
dragModeEnabled = false
const mainContainer = renderer.root.getRenderable("main-container")
if (mainContainer) renderer.root.remove(mainContainer)
renderer.root.getRenderable("main-container")?.destroyRecursively()
renderer.setMousePointer("default")
renderer.setCursorPosition(0, 0, false)
}
+9 -2
View File
@@ -675,7 +675,6 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
if (self.caps.rgb) {
self.caps.ansi256 = true;
self.caps.hyperlinks = true;
}
if (self.opts.remote_mode == .remote) {
@@ -831,6 +830,9 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
if (env_map.get("WT_SESSION") != null) {
self.caps.rgb = true;
self.caps.ansi256 = true;
if (builtin.os.tag == .windows and !self.term_info.from_xtversion and self.multiplexer == .none) {
self.caps.hyperlinks = true;
}
self.setNotificationProtocol(.osc777, .heuristic);
}
@@ -878,6 +880,10 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
}
}
if (self.is_foot and self.multiplexer != .none) {
self.caps.hyperlinks = false;
}
if (!self.caps.hyperlinks and self.term_info.from_xtversion) {
if (isHyperlinkTerm(self.getTerminalName())) {
self.caps.hyperlinks = true;
@@ -886,7 +892,7 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
if (!self.caps.hyperlinks and !self.term_info.from_xtversion) {
if (env_map.get("TERM")) |term| {
if (isHyperlinkTerm(term)) {
if (isHyperlinkTerm(term) and (!self.is_foot or self.multiplexer == .none)) {
self.caps.hyperlinks = true;
}
}
@@ -1420,6 +1426,7 @@ fn isHyperlinkTerm(value: []const u8) bool {
std.ascii.findIgnoreCase(value, "kitty") != null or
std.ascii.findIgnoreCase(value, "wezterm") != null or
std.ascii.findIgnoreCase(value, "alacritty") != null or
std.ascii.findIgnoreCase(value, "foot") != null or
std.ascii.findIgnoreCase(value, "iterm") != null;
}
@@ -25,6 +25,7 @@ test "parseXtversion - ghostty format" {
try testing.expectEqualStrings("1.1.3", term.getTerminalVersion());
try testing.expect(term.term_info.from_xtversion);
try testing.expect(term.caps.osc52);
try testing.expect(term.caps.hyperlinks);
}
test "parseXtversion - tmux format" {
@@ -668,6 +669,17 @@ test "setHostEnvVar applies env overrides in shared library mode" {
try testing.expect(term.skip_graphics_query);
}
test "environment overrides - direct Windows Terminal hyperlinks do not require TERM" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("WT_SESSION", "test-session");
const term = Terminal.init(.{ .env_map = &env });
try testing.expect(term.caps.rgb);
try testing.expectEqual(builtin.os.tag == .windows, term.caps.hyperlinks);
}
test "environment overrides - enables hyperlinks for WSL Windows Terminal xterm" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
@@ -703,6 +715,78 @@ test "environment overrides - does not enable hyperlinks for WSL non-xterm terms
try testing.expect(!term.caps.hyperlinks);
}
test "environment overrides - foot hyperlinks require a direct terminal" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "foot");
const direct = Terminal.init(.{ .env_map = &env });
try testing.expect(direct.caps.hyperlinks);
try env.put("STY", "1234.session");
const inherited = Terminal.init(.{ .env_map = &env });
try testing.expectEqual(Terminal.Multiplexer.screen, inherited.multiplexer);
try testing.expect(!inherited.caps.hyperlinks);
}
test "environment overrides - foot hyperlinks are revoked after forwarded multiplexer detection" {
const multiplexers = [_]struct {
key: []const u8,
value: []const u8,
kind: Terminal.Multiplexer,
}{
.{ .key = "STY", .value = "1234.session", .kind = .screen },
.{ .key = "TERM_PROGRAM", .value = "tmux", .kind = .tmux },
};
for (multiplexers) |multiplexer| {
var term = Terminal.init(.{});
defer term.deinit();
try term.setHostEnvVar(testing.allocator, "TERM", "foot");
try testing.expect(term.caps.hyperlinks);
try term.setHostEnvVar(testing.allocator, multiplexer.key, multiplexer.value);
try testing.expectEqual(multiplexer.kind, term.multiplexer);
try testing.expect(!term.caps.hyperlinks);
}
}
test "environment overrides - rgb does not imply hyperlinks after recheck for unknown truecolor terminals" {
var term = Terminal.init(.{});
defer term.deinit();
try term.setHostEnvVar(testing.allocator, "TERM", "xterm-256color");
try term.setHostEnvVar(testing.allocator, "COLORTERM", "truecolor");
try testing.expect(term.caps.rgb);
try testing.expect(!term.caps.hyperlinks);
try term.setHostEnvVar(testing.allocator, "OPENTUI_FORCE_UNICODE", "1");
try testing.expect(term.caps.rgb);
try testing.expect(!term.caps.hyperlinks);
}
test "environment overrides - rgb does not imply hyperlinks after recheck for inherited Windows Terminal screen" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("WSL_INTEROP", "/run/WSL/123_interop");
try env.put("WT_SESSION", "test-session");
try env.put("TERM", "screen");
var term = Terminal.init(.{ .env_map = &env });
defer term.deinit();
try testing.expect(term.caps.rgb);
try testing.expect(!term.caps.hyperlinks);
try term.setHostEnvVar(testing.allocator, "TERM", "screen");
try testing.expect(term.caps.rgb);
try testing.expect(!term.caps.hyperlinks);
}
test "setHostEnvVar detects ansi256 separately from rgb" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
@@ -1075,6 +1159,7 @@ test "processCapabilityResponse - foot applies osc52 heuristic without explicit
try testing.expect(!term.caps.rgb);
try testing.expect(!term.caps.ansi256);
try testing.expect(term.caps.osc52);
try testing.expect(term.caps.hyperlinks);
try testing.expect(!term.caps.explicit_cursor_positioning);
}