();
// Auto-detection only needs to run once a fence has settled, not on every
// streamed character. Debouncing the `code` value that feeds detection keeps
@@ -35,6 +37,28 @@ function isExplicitLang(lang: string): boolean {
return Boolean(lang) && lang !== FALLBACK_LANG;
}
+function highlightCacheKey(lang: string, code: string, mode: Theme['mode']): string {
+ return `${themeNameForMode(mode)}\0${lang}\0${code}`;
+}
+
+function getCachedHighlightHtml(lang: string, code: string, mode: Theme['mode']): string | null {
+ return highlightedHtmlCache.get(highlightCacheKey(lang, code, mode)) ?? null;
+}
+
+function rememberHighlightedHtml(
+ lang: string,
+ code: string,
+ mode: Theme['mode'],
+ html: string
+): void {
+ const key = highlightCacheKey(lang, code, mode);
+ highlightedHtmlCache.set(key, html);
+ if (highlightedHtmlCache.size > HIGHLIGHT_CACHE_LIMIT) {
+ const oldestKey = highlightedHtmlCache.keys().next().value;
+ if (oldestKey) highlightedHtmlCache.delete(oldestKey);
+ }
+}
+
/**
* Renders a code fence with Shiki syntax highlighting. Falls back to a plain
* `` while Shiki loads, while detection runs, or when the resolved
@@ -47,14 +71,17 @@ export const CodeFence = memo(function CodeFence({
theme,
onCopy,
}: CodeFenceProps) {
+ const initialResolvedLang = resolveLanguageSync(language) ?? language ?? FALLBACK_LANG;
// What the picker shows / what Shiki renders. Starts from the fence tag
// (resolved against our local alias table so common short tags like `js`
// surface as `javascript` on first paint), overridden by detection on
// no-language fences, overridden again by user picker choice.
- const [resolvedLang, setResolvedLang] = useState(
- () => resolveLanguageSync(language) ?? language ?? FALLBACK_LANG
+ const [resolvedLang, setResolvedLang] = useState(() => initialResolvedLang);
+ const [html, setHtml] = useState(() =>
+ initialResolvedLang === FALLBACK_LANG
+ ? null
+ : getCachedHighlightHtml(initialResolvedLang, code, theme.mode)
);
- const [html, setHtml] = useState(null);
const userOverrodeRef = useRef(false);
// Debounced code feeds auto-detection only. Without this, the effect below
@@ -104,6 +131,13 @@ export const CodeFence = memo(function CodeFence({
setHtml(null);
return;
}
+
+ const cached = getCachedHighlightHtml(resolvedLang, code, theme.mode);
+ if (cached) {
+ setHtml((prev) => (prev === cached ? prev : cached));
+ return;
+ }
+
let cancelled = false;
void (async () => {
try {
@@ -115,6 +149,7 @@ export const CodeFence = memo(function CodeFence({
}
const themeName = themeNameForMode(theme.mode);
const rendered = highlighter.codeToHtml(code, { lang, theme: themeName });
+ rememberHighlightedHtml(resolvedLang, code, theme.mode, rendered);
if (!cancelled) setHtml(rendered);
} catch (err) {
// Shiki failed (WASM load, missing grammar, malformed theme, …).
diff --git a/src/renderer/components/InputArea/components/InputTextarea.tsx b/src/renderer/components/InputArea/components/InputTextarea.tsx
index 8e2969f9a1..ed29955573 100644
--- a/src/renderer/components/InputArea/components/InputTextarea.tsx
+++ b/src/renderer/components/InputArea/components/InputTextarea.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useMemo, useRef } from 'react';
+import React, { memo, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Session, Group, Theme } from '../../../types';
import { getProviderDisplayName } from '../../../utils/sessionValidation';
import { useSettingsStore } from '../../../stores/settingsStore';
@@ -10,6 +10,7 @@ import {
} from '../../../utils/mentionChipResolve';
import { useSessionStore } from '../../../stores/sessionStore';
import { buildKnownMentionNameSet } from '../../../hooks/input/useAgentMentionCompletion';
+import { useEventListener } from '../../../hooks/utils/useEventListener';
interface InputTextareaProps {
session: Session;
@@ -27,7 +28,7 @@ interface InputTextareaProps {
}
/**
- * Typography the transparent textarea and the highlight overlay MUST share
+ * Typography the native textarea and the decorative overlay MUST share
* exactly, or the mention highlights drift away from the caret. Pulled into one
* constant so the two layers can never disagree (font size / line height /
* family / letter spacing). Padding is kept in sync separately: the textarea
@@ -72,6 +73,7 @@ export const InputTextarea = memo(function InputTextarea({
const overlayEnabled = !isTerminalMode;
const overlayRef = useRef(null);
+ const [hasSelection, setHasSelection] = useState(false);
// The mentionable agent/group roster (from this agent's vantage point).
// A bare `@word` only lights up when it names a known agent/group; unknown
@@ -99,6 +101,29 @@ export const InputTextarea = memo(function InputTextarea({
() => (overlayEnabled ? tokenizeMentions(inputValue, knownMentionNames) : []),
[overlayEnabled, inputValue, knownMentionNames]
);
+ const overlayRendered = overlayEnabled && segments.some((segment) => segment.kind !== 'text');
+ const overlayVisible = overlayRendered && !hasSelection;
+
+ const updateSelectionState = (target: HTMLTextAreaElement) => {
+ const nextHasSelection = target.selectionStart !== target.selectionEnd;
+ setHasSelection((current) => (current === nextHasSelection ? current : nextHasSelection));
+ };
+
+ // React's textarea onSelect fires on mouseup, after a drag selection has
+ // already become visible. Track the document selectionchange event so the
+ // decorative layer disappears during the drag itself.
+ useEventListener(
+ 'selectionchange',
+ () => {
+ const textarea = inputRef.current;
+ if (!textarea || document.activeElement !== textarea) return;
+ updateSelectionState(textarea);
+ },
+ {
+ target: typeof document !== 'undefined' ? document : null,
+ enabled: overlayRendered,
+ }
+ );
// Keep the decorative overlay pinned to the textarea's scroll position so the
// mention highlights track the text as the input grows past one line.
@@ -109,15 +134,31 @@ export const InputTextarea = memo(function InputTextarea({
el.scrollLeft = target.scrollLeft;
};
+ // A newly mounted overlay misses any scroll that happened before the mention
+ // became recognizable. Sync after the textarea resize/scroll frame so its
+ // first painted position matches the native text.
+ useLayoutEffect(() => {
+ if (!overlayRendered) return;
+
+ const frameId = requestAnimationFrame(() => {
+ const textarea = inputRef.current;
+ if (textarea) syncOverlayScroll(textarea);
+ });
+
+ return () => cancelAnimationFrame(frameId);
+ }, [inputRef, inputValue, overlayRendered]);
+
// Chip palette shared with the sent-transcript pill (same fill + border), so
// the mention reads as the same object whether the user is typing it or reading
// it back in a bubble.
const chipColors = useMemo(() => getMentionChipColors(theme), [theme]);
- // Style for a single mention chip in the LIVE overlay. The overlay sits over a
- // transparent