Merge branch 'dev' into feat/fff-search-tools

This commit is contained in:
Shoubhit Dash
2026-03-27 13:56:03 +05:30
committed by GitHub
200 changed files with 4152 additions and 4536 deletions
@@ -1 +0,0 @@
CaskaydiaCoveNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
CaskaydiaCoveNerdFontMono-Regular.woff2
@@ -1 +0,0 @@
FiraCodeNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
FiraCodeNerdFontMono-Regular.woff2
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
GeistMonoNerdFontMono-Bold.woff2
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
GeistMonoNerdFontMono-Medium.woff2
Binary file not shown.
@@ -1 +0,0 @@
GeistMonoNerdFontMono-Regular.woff2
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
HackNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
HackNerdFontMono-Regular.woff2
Binary file not shown.
@@ -1 +0,0 @@
InconsolataNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
InconsolataNerdFontMono-Regular.woff2
@@ -1 +0,0 @@
IntoneMonoNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
IntoneMonoNerdFontMono-Regular.woff2
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
JetBrainsMonoNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
JetBrainsMonoNerdFontMono-Regular.woff2
@@ -1 +0,0 @@
MesloLGSNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
MesloLGSNerdFontMono-Regular.woff2
@@ -1 +0,0 @@
RobotoMonoNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
RobotoMonoNerdFontMono-Regular.woff2
@@ -1 +0,0 @@
SauceCodeProNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
SauceCodeProNerdFontMono-Regular.woff2
@@ -1 +0,0 @@
UbuntuMonoNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
UbuntuMonoNerdFontMono-Regular.woff2
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { stream } from "./markdown-stream"
describe("markdown stream", () => {
test("heals incomplete emphasis while streaming", () => {
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
})
test("keeps incomplete links non-clickable until they finish", () => {
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
])
})
test("splits an unfinished trailing code fence from stable content", () => {
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
])
})
test("keeps reference-style markdown as one block", () => {
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
{
raw: "[docs][1]\n\n[1]: https://example.com",
src: "[docs][1]\n\n[1]: https://example.com",
mode: "live",
},
])
})
})
@@ -0,0 +1,49 @@
import { marked, type Tokens } from "marked"
import remend from "remend"
export type Block = {
raw: string
src: string
mode: "full" | "live"
}
function refs(text: string) {
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
}
function open(raw: string) {
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
if (!match) return false
const mark = match[1]
if (!mark) return false
const char = mark[0]
const size = mark.length
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
}
function heal(text: string) {
return remend(text, { linkMode: "text-only" })
}
export function stream(text: string, live: boolean) {
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
const src = heal(text)
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const tokens = marked.lexer(text)
const tail = tokens.findLastIndex((token) => token.type !== "space")
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const last = tokens[tail]
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
const code = last as Tokens.Code
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const head = tokens
.slice(0, tail)
.map((token) => token.raw)
.join("")
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
return [
{ raw: head, src: heal(head), mode: "live" },
{ raw: code.raw, src: code.raw, mode: "live" },
] satisfies Block[]
}
+53 -34
View File
@@ -5,6 +5,7 @@ import morphdom from "morphdom"
import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
import { stream } from "./markdown-stream"
type Entry = {
hash: string
@@ -180,10 +181,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
markCodeLinks(root)
}
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
const updateLabel = (button: HTMLButtonElement) => {
const labels = getLabels()
const copied = button.getAttribute("data-copied") === "true"
setCopyState(button, labels, copied)
}
@@ -200,6 +202,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
const clipboard = navigator?.clipboard
if (!clipboard) return
await clipboard.writeText(content)
const labels = getLabels()
setCopyState(button, labels, true)
const existing = timeouts.get(button)
if (existing) clearTimeout(existing)
@@ -207,8 +210,6 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
timeouts.set(button, timeout)
}
decorate(root, labels)
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
if (button instanceof HTMLButtonElement) updateLabel(button)
@@ -239,44 +240,56 @@ export function Markdown(
props: ComponentProps<"div"> & {
text: string
cacheKey?: string
streaming?: boolean
class?: string
classList?: Record<string, boolean>
},
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const marked = useMarked()
const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>()
const [html] = createResource(
() => local.text,
async (markdown) => {
if (isServer) return fallback(markdown)
() => ({
text: local.text,
key: local.cacheKey,
streaming: local.streaming ?? false,
}),
async (src) => {
if (isServer) return fallback(src.text)
if (!src.text) return ""
const hash = checksum(markdown)
const key = local.cacheKey ?? hash
const base = src.key ?? checksum(src.text)
return Promise.all(
stream(src.text, src.streaming).map(async (block, index) => {
const hash = checksum(block.raw)
const key = base ? `${base}:${index}:${block.mode}` : hash
if (key && hash) {
const cached = cache.get(key)
if (cached && cached.hash === hash) {
touch(key, cached)
return cached.html
}
}
if (key && hash) {
const cached = cache.get(key)
if (cached && cached.hash === hash) {
touch(key, cached)
return cached.html
}
}
const next = await marked.parse(markdown)
const safe = sanitize(next)
if (key && hash) touch(key, { hash, html: safe })
return safe
const next = await Promise.resolve(marked.parse(block.src))
const safe = sanitize(next)
if (key && hash) touch(key, { hash, html: safe })
return safe
}),
)
.then((list) => list.join(""))
.catch(() => fallback(src.text))
},
{ initialValue: isServer ? fallback(local.text) : "" },
{ initialValue: fallback(local.text) },
)
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
let copyCleanup: (() => void) | undefined
createEffect(() => {
const container = root()
const content = html()
const content = local.text ? (html.latest ?? html() ?? "") : ""
if (!container) return
if (isServer) return
@@ -285,33 +298,39 @@ export function Markdown(
return
}
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, {
const labels = {
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
})
}
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, labels)
morphdom(container, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLButtonElement &&
toEl instanceof HTMLButtonElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
fromEl.getAttribute("data-copied") === "true"
) {
setCopyState(toEl, labels, true)
}
if (fromEl.isEqualNode(toEl)) return false
return true
},
})
if (copySetupTimer) clearTimeout(copySetupTimer)
copySetupTimer = setTimeout(() => {
if (copyCleanup) copyCleanup()
copyCleanup = setupCodeCopy(container, {
if (!copyCleanup)
copyCleanup = setupCodeCopy(container, () => ({
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
})
}, 150)
}))
})
onCleanup(() => {
if (copySetupTimer) clearTimeout(copySetupTimer)
if (copyCleanup) copyCleanup()
})
+14 -26
View File
@@ -884,7 +884,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const i18n = useI18n()
const [state, setState] = createStore({
copied: false,
busy: undefined as "fork" | "revert" | undefined,
busy: false,
})
const copied = () => state.copied
const busy = () => state.busy
@@ -938,10 +938,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
setTimeout(() => setState("copied", false), 2000)
}
const run = (kind: "fork" | "revert") => {
const act = kind === "fork" ? props.actions?.fork : props.actions?.revert
const revert = () => {
const act = props.actions?.revert
if (!act || busy()) return
setState("busy", kind)
setState("busy", true)
void Promise.resolve()
.then(() =>
act({
@@ -949,9 +949,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
messageID: props.message.id,
}),
)
.finally(() => {
if (busy() === kind) setState("busy", undefined)
})
.finally(() => setState("busy", false))
}
return (
@@ -1017,22 +1015,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</Show>
</span>
</Show>
<Show when={props.actions?.fork}>
<Tooltip value={i18n.t("ui.message.forkMessage")} placement="top" gutter={4}>
<IconButton
icon="fork"
size="normal"
variant="ghost"
disabled={!!busy()}
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
run("fork")
}}
aria-label={i18n.t("ui.message.forkMessage")}
/>
</Tooltip>
</Show>
<Show when={props.actions?.revert}>
<Tooltip value={i18n.t("ui.message.revertMessage")} placement="top" gutter={4}>
<IconButton
@@ -1043,7 +1025,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
run("revert")
revert()
}}
aria-label={i18n.t("ui.message.revertMessage")}
/>
@@ -1352,6 +1334,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText)
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1378,7 +1363,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<Show when={throttledText()}>
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} />
<Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
@@ -1412,11 +1397,14 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
const part = () => props.part as ReasoningPart
const text = () => part().text.trim()
const throttledText = createThrottledValue(text)
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
return (
<Show when={throttledText()}>
<div data-component="reasoning-part">
<Markdown text={throttledText()} cacheKey={part().id} />
<Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
</div>
</Show>
)
+19 -17
View File
@@ -151,6 +151,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((diff) => diff.file))
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0
@@ -281,10 +282,11 @@ export const SessionReview = (props: SessionReviewProps) => {
<Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => {
<For each={files()}>
{(file) => {
let wrapper: HTMLDivElement | undefined
const file = diff.file
const item = createMemo(() => diffs().get(file)!)
const expanded = createMemo(() => open().includes(file))
const force = () => !!store.force[file]
@@ -292,9 +294,9 @@ export const SessionReview = (props: SessionReviewProps) => {
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const changedLines = () => diff.additions + diff.deletions
const beforeText = () => (typeof item().before === "string" ? item().before : "")
const afterText = () => (typeof item().after === "string" ? item().after : "")
const changedLines = () => item().additions + item().deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
const tooLarge = createMemo(() => {
@@ -305,9 +307,9 @@ export const SessionReview = (props: SessionReviewProps) => {
})
const isAdded = () =>
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const selectedLines = createMemo(() => {
const current = selection()
@@ -344,7 +346,7 @@ export const SessionReview = (props: SessionReviewProps) => {
file,
selection,
comment,
preview: selectionPreview(diff, selection),
preview: selectionPreview(item(), selection),
})
},
onUpdate: ({ id, comment, selection }) => {
@@ -353,7 +355,7 @@ export const SessionReview = (props: SessionReviewProps) => {
file,
selection,
comment,
preview: selectionPreview(diff, selection),
preview: selectionPreview(item(), selection),
})
},
onDelete: (comment) => {
@@ -430,7 +432,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
<DiffChanges changes={diff} />
<DiffChanges changes={item()} />
</div>
</Match>
<Match when={isDeleted()}>
@@ -444,7 +446,7 @@ export const SessionReview = (props: SessionReviewProps) => {
</span>
</Match>
<Match when={true}>
<DiffChanges changes={diff} />
<DiffChanges changes={item()} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
@@ -490,7 +492,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={fileComponent}
mode="diff"
preloadedDiff={diff.preloaded}
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
@@ -507,17 +509,17 @@ export const SessionReview = (props: SessionReviewProps) => {
commentedLines={commentedLines()}
before={{
name: file,
contents: typeof diff.before === "string" ? diff.before : "",
contents: typeof item().before === "string" ? item().before : "",
}}
after={{
name: file,
contents: typeof diff.after === "string" ? diff.after : "",
contents: typeof item().after === "string" ? item().after : "",
}}
media={{
mode: "auto",
path: file,
before: diff.before,
after: diff.after,
before: item().before,
after: item().after,
readFile: props.readFile,
}}
/>
-133
View File
@@ -1,133 +0,0 @@
type MonoFont = {
id: string
family: string
regular: string
bold: string
}
let files: Record<string, () => Promise<string>> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record<string, () => Promise<string>>
return files
}
export const MONO_NERD_FONTS = [
{
id: "jetbrains-mono",
family: "JetBrains Mono Nerd Font",
regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2",
bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2",
},
{
id: "fira-code",
family: "Fira Code Nerd Font",
regular: "./assets/fonts/fira-code-nerd-font.woff2",
bold: "./assets/fonts/fira-code-nerd-font-bold.woff2",
},
{
id: "cascadia-code",
family: "Cascadia Code Nerd Font",
regular: "./assets/fonts/cascadia-code-nerd-font.woff2",
bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2",
},
{
id: "hack",
family: "Hack Nerd Font",
regular: "./assets/fonts/hack-nerd-font.woff2",
bold: "./assets/fonts/hack-nerd-font-bold.woff2",
},
{
id: "source-code-pro",
family: "Source Code Pro Nerd Font",
regular: "./assets/fonts/source-code-pro-nerd-font.woff2",
bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2",
},
{
id: "inconsolata",
family: "Inconsolata Nerd Font",
regular: "./assets/fonts/inconsolata-nerd-font.woff2",
bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2",
},
{
id: "roboto-mono",
family: "Roboto Mono Nerd Font",
regular: "./assets/fonts/roboto-mono-nerd-font.woff2",
bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2",
},
{
id: "ubuntu-mono",
family: "Ubuntu Mono Nerd Font",
regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2",
bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2",
},
{
id: "intel-one-mono",
family: "Intel One Mono Nerd Font",
regular: "./assets/fonts/intel-one-mono-nerd-font.woff2",
bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2",
},
{
id: "meslo-lgs",
family: "Meslo LGS Nerd Font",
regular: "./assets/fonts/meslo-lgs-nerd-font.woff2",
bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2",
},
{
id: "iosevka",
family: "Iosevka Nerd Font",
regular: "./assets/fonts/iosevka-nerd-font.woff2",
bold: "./assets/fonts/iosevka-nerd-font-bold.woff2",
},
{
id: "geist-mono",
family: "GeistMono Nerd Font",
regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2",
bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2",
},
] satisfies MonoFont[]
const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record<string, MonoFont>
const loads = new Map<string, Promise<void>>()
function css(font: { family: string; regular: string; bold: string }) {
return `
@font-face {
font-family: "${font.family}";
src: url("${font.regular}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "${font.family}";
src: url("${font.bold}") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 700;
}
`
}
export function ensureMonoFont(id: string | undefined) {
if (!id || id === "ibm-plex-mono") return Promise.resolve()
if (typeof document !== "object") return Promise.resolve()
const font = mono[id]
if (!font) return Promise.resolve()
const styleId = `oc-font-${font.id}`
if (document.getElementById(styleId)) return Promise.resolve()
const hit = loads.get(font.id)
if (hit) return hit
const files = getFiles()
const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => {
if (!regular || !bold) return
if (document.getElementById(styleId)) return
const style = document.createElement("style")
style.id = styleId
style.textContent = css({ family: font.family, regular, bold })
document.head.appendChild(style)
})
loads.set(font.id, load)
return load
}
-2
View File
@@ -1,7 +1,5 @@
export const dict: Record<string, string> = {
"ui.sessionReview.title": "Session changes",
"ui.sessionReview.title.git": "Git changes",
"ui.sessionReview.title.branch": "Branch changes",
"ui.sessionReview.title.lastTurn": "Last turn changes",
"ui.sessionReview.diffStyle.unified": "Unified",
"ui.sessionReview.diffStyle.split": "Split",