fix(terminal): prevent native caret blink in ghostty focus inputs (#329)

This commit is contained in:
shekohex
2026-02-06 03:16:02 +02:00
committed by GitHub
parent 16d7a1820b
commit 9dbd3dbd46
2 changed files with 137 additions and 30 deletions
@@ -2,6 +2,7 @@ import React from 'react';
import { createPortal } from 'react-dom';
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
import { isMobileDeviceViaCSS } from '@/lib/device';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import type { TerminalChunk } from '@/stores/useTerminalStore';
@@ -99,17 +100,44 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
inputHandlerRef.current = onInput;
resizeHandlerRef.current = onResize;
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const prefersTouchOnlyPointer = typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(pointer: coarse)').matches
&& window.matchMedia('(hover: none)').matches;
const useHiddenInputOverlay = Boolean(enableTouchScroll)
&& (isAndroid || isMobileDeviceViaCSS() || prefersTouchOnlyPointer);
const disableTerminalTextareas = React.useCallback(() => {
if (!enableTouchScroll) {
return;
}
const container = containerRef.current;
const hiddenInput = hiddenInputRef.current;
if (!container) {
return;
}
const editableNodes = Array.from(container.querySelectorAll<HTMLElement>('[contenteditable="true"]'));
editableNodes.forEach((node) => {
node.style.setProperty('caret-color', 'transparent');
node.style.outline = 'none';
});
const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[];
textareas.forEach((textarea) => {
textarea.style.setProperty('caret-color', 'transparent');
textarea.style.color = 'transparent';
textarea.style.setProperty('-webkit-text-fill-color', 'transparent');
textarea.style.background = 'transparent';
textarea.style.border = '0';
textarea.style.outline = 'none';
textarea.style.boxShadow = 'none';
textarea.style.textShadow = 'none';
textarea.style.fontSize = '0';
textarea.style.lineHeight = '0';
if (!useHiddenInputOverlay) {
return;
}
if (textarea === hiddenInput) {
return;
}
@@ -127,10 +155,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
textarea.style.pointerEvents = 'none';
textarea.style.zIndex = '-1';
});
}, [enableTouchScroll]);
}, [useHiddenInputOverlay]);
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const useTextInput = enableTouchScroll && isAndroid;
const useTextInput = useHiddenInputOverlay && isAndroid;
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null;
@@ -230,7 +257,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
React.useEffect(() => {
const container = containerRef.current;
if (!enableTouchScroll || !container) {
if (!useHiddenInputOverlay || !container) {
return;
}
@@ -255,7 +282,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return () => {
container.removeEventListener('focusin', handleContainerFocusIn, true);
};
}, [enableTouchScroll, focusHiddenInput]);
}, [useHiddenInputOverlay, focusHiddenInput]);
const copySelectionToClipboard = React.useCallback(async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
@@ -579,7 +606,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} catch { /* ignored */ }
if (wasTap) {
focusHiddenInput(event.clientX, event.clientY);
if (useHiddenInputOverlay) {
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
}
return;
}
@@ -730,7 +761,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
if (wasTap) {
const point = event.changedTouches?.[0];
focusHiddenInput(point?.clientX, point?.clientY);
if (useHiddenInputOverlay) {
focusHiddenInput(point?.clientX, point?.clientY);
} else {
terminalRef.current?.focus();
}
return;
}
@@ -788,7 +823,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
container.style.touchAction = previousTouchAction;
};
}, [enableTouchScroll, focusHiddenInput, fontSize]);
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput, fontSize]);
React.useEffect(() => {
let disposed = false;
@@ -802,7 +837,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
container.tabIndex = enableTouchScroll ? -1 : 0;
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
const initialize = async () => {
try {
@@ -811,7 +846,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, Boolean(enableTouchScroll));
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
const terminal = new GhosttyTerminal(options);
@@ -827,7 +862,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
disableTerminalTextareas();
if (enableTouchScroll && typeof MutationObserver !== 'undefined') {
if (typeof MutationObserver !== 'undefined') {
localTextareaObserver = new MutationObserver(() => {
disableTerminalTextareas();
});
@@ -845,7 +880,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal();
setupTouchScroll();
if (!enableTouchScroll) {
if (!useHiddenInputOverlay) {
terminal.focus();
}
@@ -888,7 +923,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
lastReportedSizeRef.current = null;
resetWriteState();
};
}, [disableTerminalTextareas, enableTouchScroll, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
}, [disableTerminalTextareas, useHiddenInputOverlay, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
React.useEffect(() => {
@@ -900,10 +935,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
resetWriteState();
lastReportedSizeRef.current = null;
fitTerminal();
if (!enableTouchScroll) {
if (!useHiddenInputOverlay) {
terminal.focus();
}
}, [enableTouchScroll, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
}, [useHiddenInputOverlay, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
React.useEffect(() => {
setupTouchScroll();
@@ -949,7 +984,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
ref,
(): TerminalController => ({
focus: () => {
if (enableTouchScroll) {
if (useHiddenInputOverlay) {
focusHiddenInput();
return;
}
@@ -968,12 +1003,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal();
},
}),
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
[useHiddenInputOverlay, focusHiddenInput, fitTerminal, resetWriteState]
);
const handleHiddenInputBlur = React.useCallback(
(event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!enableTouchScroll) {
if (!useHiddenInputOverlay) {
return;
}
@@ -993,7 +1028,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
}
},
[enableTouchScroll, focusHiddenInput]
[useHiddenInputOverlay, focusHiddenInput]
);
const handleHiddenBeforeInput = React.useCallback(
@@ -1215,7 +1250,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
zIndex: -1,
background: 'transparent',
color: 'transparent',
WebkitTextFillColor: 'transparent',
caretColor: 'transparent',
textShadow: 'none',
WebkitAppearance: 'none',
appearance: 'none',
resize: 'none',
overflow: 'hidden',
whiteSpace: 'nowrap',
@@ -1237,13 +1276,13 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
className={cn('relative h-full w-full terminal-viewport-container', className)}
style={{ backgroundColor: theme.background }}
onTouchStart={(event) => {
if (enableTouchScroll) {
if (useHiddenInputOverlay) {
const touch = event.touches?.[0];
focusHiddenInput(touch?.clientX, touch?.clientY);
}
}}
onClick={(event) => {
if (enableTouchScroll) {
if (useHiddenInputOverlay) {
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
@@ -1260,7 +1299,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
}}
>
{enableTouchScroll && typeof document !== 'undefined'
{useHiddenInputOverlay && typeof document !== 'undefined'
? createPortal(
<>
<input
@@ -1271,10 +1310,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
autoComplete="off"
autoCorrect="off"
spellCheck={false}
tabIndex={0}
tabIndex={-1}
enterKeyHint="send"
data-terminal-hidden-input="true"
placeholder="Terminal input"
aria-label="Terminal input"
aria-hidden="true"
style={{
...hiddenInputStyle,
display: useTextInput ? 'block' : 'none',
@@ -1297,10 +1337,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
autoComplete="off"
autoCorrect="off"
spellCheck={false}
tabIndex={0}
tabIndex={-1}
enterKeyHint="send"
data-terminal-hidden-input="true"
placeholder="Terminal input"
aria-label="Terminal input"
aria-hidden="true"
style={{
...hiddenInputStyle,
display: useTextInput ? 'none' : 'block',
+67 -1
View File
@@ -700,10 +700,76 @@ html:not(.dark) .chat-scroll {
}
/* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */
.terminal-viewport-container textarea {
.terminal-viewport-container textarea,
.terminal-viewport-container input {
caret-color: transparent !important;
}
.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] {
caret-color: transparent !important;
outline: none !important;
}
[contenteditable="true"][aria-label="Terminal input"] {
caret-color: transparent !important;
outline: none !important;
}
/* Keep Ghostty's internal focus textarea fully non-painting on desktop. */
.terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) {
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
font-size: 0 !important;
line-height: 0 !important;
clip-path: inset(100%) !important;
color: transparent !important;
-webkit-text-fill-color: transparent !important;
background: transparent !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
text-shadow: none !important;
}
textarea[aria-label="Terminal input"],
input[aria-label="Terminal input"] {
caret-color: transparent !important;
color: transparent !important;
-webkit-text-fill-color: transparent !important;
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
clip-path: inset(100%) !important;
outline: none !important;
}
/* Terminal touch input is portaled to document.body; keep it fully non-painting across engines. */
input[data-terminal-hidden-input="true"],
textarea[data-terminal-hidden-input="true"] {
caret-color: transparent !important;
color: transparent !important;
-webkit-text-fill-color: transparent !important;
background: transparent !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
text-shadow: none !important;
-webkit-appearance: none;
appearance: none;
}
input[data-terminal-hidden-input="true"]::selection,
textarea[data-terminal-hidden-input="true"]::selection {
background: transparent;
color: transparent;
}
input[data-terminal-hidden-input="true"]::placeholder,
textarea[data-terminal-hidden-input="true"]::placeholder {
color: transparent !important;
}
/* Mobile Terminal Optimizations */
@media (max-width: 1024px) {
.terminal-viewport-container {