Fix shiki and diff caching

This commit is contained in:
Shantur Rathore
2025-11-10 21:54:01 +00:00
parent 910249ff25
commit d277d50ed7
8 changed files with 197 additions and 102 deletions

View File

@@ -5,6 +5,9 @@ import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
const inlineLoadedLanguages = new Set<string>()
type LoadLanguageArg = Parameters<Highlighter["loadLanguage"]>[0]
type CodeToHtmlOptions = Parameters<Highlighter["codeToHtml"]>[1]
interface CodeBlockInlineProps {
code: string
language?: string
@@ -41,13 +44,14 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
}
try {
const language = props.language as LoadLanguageArg
if (!inlineLoadedLanguages.has(props.language)) {
await highlighter.loadLanguage(props.language)
await highlighter.loadLanguage(language)
inlineLoadedLanguages.add(props.language)
}
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language,
lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light",
})
setHtml(highlighted)

View File

@@ -1,62 +1,56 @@
import { createMemo, Show } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import { getLanguageFromPath } from "../lib/markdown"
import { createMemo } from "solid-js"
import type { TextPart } from "../types/message"
import { Markdown } from "./markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import type { DiffViewMode } from "../stores/preferences"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
type ThemeKey = "light" | "dark"
interface ToolCallDiffViewerProps {
diffText: string
filePath?: string
theme: "light" | "dark"
mode: DiffViewMode
theme: ThemeKey
renderCacheKey?: string
}
type DiffData = {
oldFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
newFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
hunks: string[]
function formatDiffMarkdown(diffText: string, filePath?: string): string {
const body = normalizeDiffText(diffText) || diffText
const trimmed = body.trimStart()
const alreadyFenced = trimmed.startsWith("```")
const fenced = alreadyFenced ? body : `\`\`\`diff\n${body}\n\`\`\``
if (!filePath) {
return fenced
}
return `### ${filePath}\n\n${fenced}`
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const diffData = createMemo<DiffData | null>(() => {
const normalized = normalizeDiffText(props.diffText)
if (!normalized) {
return null
}
const language = getLanguageFromPath(props.filePath) || "text"
const fileName = props.filePath || "diff"
return {
oldFile: {
fileName,
fileLang: language,
},
newFile: {
fileName,
fileLang: language,
},
hunks: [normalized],
}
const diffMarkdown = createMemo(() => {
return formatDiffMarkdown(props.diffText, props.filePath)
})
const diffPart = createMemo<TextPart>(() => {
const part: TextPart = { type: "text", text: diffMarkdown() }
if (props.renderCacheKey) {
const cached = getToolRenderCache(props.renderCacheKey)
if (cached) {
part.renderCache = cached
}
}
return part
})
const handleRendered = () => {
if (!props.renderCacheKey) return
setToolRenderCache(props.renderCacheKey, diffPart().renderCache)
}
return (
<div class="tool-call-diff-viewer">
<Show
when={diffData()}
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
>
{(data) => (
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
)}
</Show>
<Markdown part={diffPart()} isDark={props.theme === "dark"} onRendered={handleRendered} />
</div>
)
}

View File

@@ -170,6 +170,9 @@ interface ToolDisplayItem {
key: string
toolPart: any
messageInfo?: any
messageId: string
messageVersion: number
partVersion: number
}
type DisplayItem = MessageDisplayItem | ToolDisplayItem
@@ -191,14 +194,35 @@ interface ToolCacheEntry {
item: ToolDisplayItem
}
interface SessionCache {
messageItemCache: Map<string, MessageCacheEntry>
toolItemCache: Map<string, ToolCacheEntry>
}
const sessionCaches = new Map<string, SessionCache>()
function getSessionCache(instanceId: string, sessionId: string): SessionCache {
const key = `${instanceId}:${sessionId}`
let cache = sessionCaches.get(key)
if (!cache) {
cache = {
messageItemCache: new Map(),
toolItemCache: new Map(),
}
sessionCaches.set(key, cache)
}
return cache
}
export default function MessageStream(props: MessageStreamProps) {
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
let messageItemCache = new Map<string, MessageCacheEntry>()
let toolItemCache = new Map<string, ToolCacheEntry>()
const sessionCache = getSessionCache(props.instanceId, props.sessionId)
let messageItemCache = sessionCache.messageItemCache
let toolItemCache = sessionCache.toolItemCache
let scrollAnimationFrame: number | null = null
let lastKnownScrollTop = 0
@@ -334,7 +358,6 @@ export default function MessageStream(props: MessageStreamProps) {
}
const messageView = createMemo(() => {
// Ensure memo reacts to preference changes
const showThinking = preferences().showThinkingBlocks
const items: DisplayItem[] = []
@@ -358,7 +381,6 @@ export default function MessageStream(props: MessageStreamProps) {
const message = props.messages[index]
const messageInfo = props.messagesInfo?.get(message.id)
// If we hit the revert point, stop rendering messages
if (props.revert?.messageID && message.id === props.revert.messageID) {
break
}
@@ -367,9 +389,9 @@ export default function MessageStream(props: MessageStreamProps) {
const baseDisplayParts = message.displayParts
const displayParts: MessageDisplayParts =
baseDisplayParts && baseDisplayParts.showThinking === showThinking
? baseDisplayParts
: computeDisplayParts(message, showThinking)
!baseDisplayParts || baseDisplayParts.showThinking !== showThinking
? computeDisplayParts(message, showThinking)
: (baseDisplayParts as MessageDisplayParts)
const combinedParts = displayParts.combined
const version = message.version ?? 0
@@ -424,6 +446,8 @@ export default function MessageStream(props: MessageStreamProps) {
for (let toolIndex = 0; toolIndex < displayParts.tool.length; toolIndex++) {
const toolPart = displayParts.tool[toolIndex]
const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}`
const messageVersion = typeof message.version === "number" ? message.version : 0
const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0
const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo)
const contentKey = createToolContentKey(toolPart, messageInfo)
@@ -434,6 +458,9 @@ export default function MessageStream(props: MessageStreamProps) {
...toolEntry.item,
toolPart,
messageInfo,
messageId: message.id,
messageVersion,
partVersion,
}
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
@@ -444,8 +471,16 @@ export default function MessageStream(props: MessageStreamProps) {
newToolCache.set(toolKey, toolEntry)
items.push(updatedItem)
} else {
const cachedItem = toolEntry.item
cachedItem.toolPart = toolPart
cachedItem.messageInfo = messageInfo
cachedItem.messageId = message.id
cachedItem.messageVersion = messageVersion
cachedItem.partVersion = partVersion
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
newToolCache.set(toolKey, toolEntry)
items.push(toolEntry.item)
items.push(cachedItem)
}
} else {
const toolItem: ToolDisplayItem = {
@@ -453,6 +488,9 @@ export default function MessageStream(props: MessageStreamProps) {
key: toolKey,
toolPart,
messageInfo,
messageId: message.id,
messageVersion,
partVersion,
}
console.debug("[ToolCall] create", toolKey, toolPart?.state?.status)
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem })
@@ -463,6 +501,8 @@ export default function MessageStream(props: MessageStreamProps) {
messageItemCache = newMessageCache
toolItemCache = newToolCache
sessionCache.messageItemCache = messageItemCache
sessionCache.toolItemCache = toolItemCache
tokenSegments.push(`items:${items.length}`)
@@ -681,7 +721,13 @@ export default function MessageStream(props: MessageStreamProps) {
</button>
</Show>
</div>
<ToolCall toolCall={toolPart} toolCallId={item.key} />
<ToolCall
toolCall={toolPart}
toolCallId={item.key}
messageId={item.messageId}
messageVersion={item.messageVersion}
partVersion={item.partVersion}
/>
</div>
)
}}

View File

@@ -47,10 +47,12 @@ export default function PromptInput(props: PromptInputProps) {
const minHeight = lineHeight * MIN_TEXTAREA_LINES
textarea.style.height = "auto"
const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), MAX_TEXTAREA_HEIGHT)
const scrollHeight = textarea.scrollHeight
const newHeight = Math.min(Math.max(scrollHeight, minHeight), MAX_TEXTAREA_HEIGHT)
textarea.style.height = newHeight + "px"
}
const attachments = () => getAttachments(props.instanceId, props.sessionId)
const instanceAgents = () => agents().get(props.instanceId) || []
@@ -309,7 +311,9 @@ export default function PromptInput(props: PromptInputProps) {
function handleKeyDown(e: KeyboardEvent) {
const textarea = textareaRef
if (!textarea) return
if (!textarea) {
return
}
if (e.key === "Backspace" || e.key === "Delete") {
const cursorPos = textarea.selectionStart
@@ -478,7 +482,6 @@ export default function PromptInput(props: PromptInputProps) {
setTimeout(() => {
adjustTextareaHeight(textarea)
}, 0)
return
}
}
@@ -497,11 +500,9 @@ export default function PromptInput(props: PromptInputProps) {
try {
await addToHistory(props.instanceFolder, text)
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
await props.onSend(text, currentAttachments)
} catch (error) {
console.error("Failed to send message:", error)

View File

@@ -7,6 +7,7 @@ import { keyboardRegistry } from "../lib/keyboard-registry"
import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
interface SessionListProps {
instanceId: string
sessions: Map<string, Session>
@@ -51,6 +52,10 @@ const SessionList: Component<SessionListProps> = (props) => {
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
const infoShortcut = keyboardRegistry.get("switch-to-info")
const selectSession = (sessionId: string) => {
props.onSelect(sessionId)
}
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
let mouseUpHandler: (() => void) | null = null
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
@@ -246,7 +251,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-item group">
<button
class={`session-item-base ${props.activeSessionId === "info" ? "session-item-active" : "session-item-inactive"}`}
onClick={() => props.onSelect("info")}
onClick={() => selectSession("info")}
title="Instance Info"
role="button"
aria-selected={props.activeSessionId === "info"}
@@ -280,7 +285,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-item group">
<button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
onClick={() => props.onSelect(id)}
onClick={() => selectSession(id)}
title={title()}
role="button"
aria-selected={isActive()}
@@ -338,7 +343,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-item group">
<button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
onClick={() => props.onSelect(id)}
onClick={() => selectSession(id)}
title={title()}
role="button"
aria-selected={isActive()}

View File

@@ -5,12 +5,24 @@ import { ToolCallDiffViewer } from "./diff-viewer"
import { useTheme } from "../lib/theme"
import { getLanguageFromPath } from "../lib/markdown"
import { isRenderableDiffText } from "../lib/diff-utils"
import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
import type { TextPart } from "../types/message"
const toolScrollState = new Map<string, { scrollTop: number; atBottom: boolean }>()
function makeRenderCacheKey(
baseId?: string | null,
messageId?: string,
messageVersion?: number,
partVersion?: number,
) {
if (!baseId && !messageId) return undefined
const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}`
const keyBase = baseId || messageId || "tool"
return `${keyBase}::${suffix}`
}
function updateScrollState(id: string, element: HTMLElement) {
if (!id) return
const distanceFromBottom = element.scrollHeight - (element.scrollTop + element.clientHeight)
@@ -44,6 +56,9 @@ function restoreScrollState(id: string, element: HTMLElement) {
interface ToolCallProps {
toolCall: any
toolCallId?: string
messageId?: string
messageVersion?: number
partVersion?: number
}
function getToolIcon(tool: string): string {
@@ -367,11 +382,9 @@ export default function ToolCall(props: ToolCallProps) {
}
function renderDiffTool(payload: DiffPayload) {
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const handleModeChange = (mode: DiffViewMode) => {
setDiffViewMode(mode)
}
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
return (
<div
@@ -379,32 +392,14 @@ export default function ToolCall(props: ToolCallProps) {
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<span class="tool-call-diff-toolbar-label">Diff view</span>
<div class="tool-call-diff-toggle">
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
aria-pressed={diffMode() === "split"}
onClick={() => handleModeChange("split")}
>
Split
</button>
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
aria-pressed={diffMode() === "unified"}
onClick={() => handleModeChange("unified")}
>
Unified
</button>
</div>
<div class="tool-call-diff-toolbar">
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
</div>
<ToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={isDark() ? "dark" : "light"}
mode={diffMode()}
renderCacheKey={cacheKey}
/>
</div>
)
@@ -419,8 +414,22 @@ export default function ToolCall(props: ToolCallProps) {
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
const disableHighlight = state?.status === "running"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const markdownPart: TextPart = { type: "text", text: content }
if (cacheKey) {
const cached = getToolRenderCache(cacheKey)
if (cached) {
markdownPart.renderCache = cached
}
}
const handleMarkdownRendered = () => {
if (cacheKey) {
setToolRenderCache(cacheKey, markdownPart.renderCache)
}
handleScrollRendered()
}
return (
<div
@@ -432,7 +441,7 @@ export default function ToolCall(props: ToolCallProps) {
part={markdownPart}
isDark={isDark()}
disableHighlight={disableHighlight}
onRendered={handleScrollRendered}
onRendered={handleMarkdownRendered}
/>
</div>
)

View File

@@ -0,0 +1,22 @@
import type { RenderCache } from "../types/message"
const toolRenderCache = new Map<string, RenderCache>()
export function getToolRenderCache(key?: string | null): RenderCache | undefined {
if (!key) return undefined
return toolRenderCache.get(key)
}
export function setToolRenderCache(key: string | undefined | null, cache?: RenderCache): void {
if (!key) return
if (cache) {
toolRenderCache.set(key, cache)
} else {
toolRenderCache.delete(key)
}
}
export function clearToolRenderCache(key?: string | null): void {
if (!key) return
toolRenderCache.delete(key)
}

View File

@@ -17,6 +17,27 @@ interface SessionInfo {
contextUsageTokens: number
}
interface SessionForkResponse {
id: string
title?: string
parentID?: string | null
agent?: string
model?: {
providerID?: string
modelID?: string
}
time?: {
created?: number
updated?: number
}
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
@@ -103,13 +124,6 @@ const [loading, setLoading] = createSignal({
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
if (typeof globalThis !== "undefined") {
const debugGlobal = globalThis as any
debugGlobal.__OPENCODE_DEBUG__ = {
...(debugGlobal.__OPENCODE_DEBUG__ ?? {}),
getSessions: () => sessions(),
}
}
// Message index cache structure: instanceId -> sessionId -> { messageIndex, partIndex }
const sessionIndexes = new Map<
@@ -718,8 +732,8 @@ async function forkSession(
throw new Error("Failed to fork session: No data returned")
}
const info = response.data
const forkedSession: Session = {
const info = response.data as SessionForkResponse
const forkedSession = {
id: info.id,
instanceId,
title: info.title || "Forked Session",
@@ -743,7 +757,7 @@ async function forkSession(
: undefined,
messages: [],
messagesInfo: new Map(),
}
} as unknown as Session
setSessions((prev) => {
const next = new Map(prev)