Skip to content

Commit 7c5fb38

Browse files
committed
fix: use transparent textarea pattern for command highlighting
- Move typography (font-size, font-family) to wrapper container - Overlay inherits all styles via 'font: inherit', no duplication - Make textarea text transparent when command prefix exists - Overlay positioned behind textarea, shows through transparent text - Caret remains visible via caret-color - Background moved to wrapper so overlay renders correctly - CommandPrefixText accepts className for font override when needed
1 parent 3a45e81 commit 7c5fb38

File tree

3 files changed

+81
-32
lines changed

3 files changed

+81
-32
lines changed

src/browser/components/CommandHighlightOverlay.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,37 +34,50 @@ export function extractCommandPrefix(input: string): string | null {
3434
return commandLine;
3535
}
3636

37+
interface CommandPrefixTextProps {
38+
children: React.ReactNode;
39+
/** Additional className - use to add font-mono when not inheriting from parent */
40+
className?: string;
41+
}
42+
3743
/**
3844
* Shared styling for command prefix text (e.g., "/compact" or "/skill-name").
3945
* Used in both the chat input overlay and sent message display.
46+
*
47+
* Font-family is inherited by default. When used outside of VimTextArea
48+
* (e.g., in UserMessageContent), pass className="font-mono" explicitly.
4049
*/
41-
export const CommandPrefixText: React.FC<{ children: React.ReactNode }> = (props) => (
42-
<span className="font-mono text-[13px] font-medium text-[var(--color-plan-mode-light)]">
50+
export const CommandPrefixText: React.FC<CommandPrefixTextProps> = (props) => (
51+
<span className={`font-medium text-[var(--color-plan-mode-light)] ${props.className ?? ""}`}>
4352
{props.children}
4453
</span>
4554
);
4655

4756
interface CommandHighlightOverlayProps {
4857
/** The current input text value */
4958
value: string;
50-
/** Whether vim mode is enabled (affects font) */
51-
vimEnabled?: boolean;
5259
/** Additional className for the container */
5360
className?: string;
61+
/** Whether there's a command prefix (controls visibility) */
62+
hasCommand: boolean;
5463
}
5564

5665
/**
5766
* Renders a highlight overlay for slash command prefixes.
58-
* This component should be positioned absolutely ABOVE the textarea,
59-
* showing the highlighted command text while keeping the rest transparent
60-
* so the original textarea text shows through.
6167
*
62-
* The overlay mirrors the textarea's text layout exactly.
68+
* Uses the "transparent textarea text" pattern:
69+
* - Overlay is positioned BEHIND the textarea
70+
* - Textarea text is made transparent (via parent) so overlay shows through
71+
* - Caret remains visible via caret-color on textarea
72+
*
73+
* All typography is inherited from the parent wrapper container,
74+
* eliminating duplication and ensuring perfect alignment.
6375
*/
6476
export const CommandHighlightOverlay: React.FC<CommandHighlightOverlayProps> = (props) => {
6577
const commandPrefix = extractCommandPrefix(props.value);
6678

67-
if (!commandPrefix) {
79+
// Don't render if no command or parent says no command
80+
if (!props.hasCommand || !commandPrefix) {
6881
return null;
6982
}
7083

@@ -75,10 +88,10 @@ export const CommandHighlightOverlay: React.FC<CommandHighlightOverlayProps> = (
7588
<div
7689
className={props.className}
7790
style={{
78-
// Match textarea exactly - these match VimTextArea's styling
79-
padding: "6px 8px", // py-1.5 px-2
80-
fontSize: "13px",
81-
lineHeight: "1.5",
91+
// Inherit ALL typography from parent container
92+
font: "inherit",
93+
letterSpacing: "inherit",
94+
// Text handling - match textarea behavior
8295
whiteSpace: "pre-wrap",
8396
wordWrap: "break-word",
8497
overflowWrap: "break-word",
@@ -90,8 +103,8 @@ export const CommandHighlightOverlay: React.FC<CommandHighlightOverlayProps> = (
90103
>
91104
{/* Command prefix in highlight color */}
92105
<CommandPrefixText>{commandPrefix}</CommandPrefixText>
93-
{/* Rest of text is transparent so textarea text shows through */}
94-
<span style={{ color: "transparent" }}>{rest}</span>
106+
{/* Rest of text in normal color - textarea is transparent so this shows */}
107+
<span className="text-light">{rest}</span>
95108
</div>
96109
);
97110
};

src/browser/components/Messages/UserMessageContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export const UserMessageContent: React.FC<UserMessageContentProps> = (props) =>
129129
// Space after prefix: inline layout (prefix + content on same line)
130130
return (
131131
<div className={hasNewlineAfterPrefix ? "" : "flex flex-wrap items-baseline"}>
132-
<CommandPrefixText>{shouldHighlightPrefix}</CommandPrefixText>
132+
<CommandPrefixText className="font-mono">{shouldHighlightPrefix}</CommandPrefixText>
133133
{hasSpaceAfterPrefix && <span>&nbsp;</span>}
134134
{remainingContent.trim() && (
135135
<MarkdownRenderer

src/browser/components/VimTextArea.tsx

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { stopKeyboardPropagation } from "@/browser/utils/events";
88
import { cn } from "@/common/lib/utils";
99
import { usePersistedState } from "@/browser/hooks/usePersistedState";
1010
import { VIM_ENABLED_KEY } from "@/common/constants/storage";
11-
import { CommandHighlightOverlay } from "./CommandHighlightOverlay";
11+
import { CommandHighlightOverlay, extractCommandPrefix } from "./CommandHighlightOverlay";
1212

1313
/**
1414
* VimTextArea – minimal Vim-like editing for a textarea.
@@ -183,6 +183,9 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
183183
const pendingCommand = showVimMode ? vim.formatPendingCommand(pendingOp) : "";
184184
const showFocusHint = !isFocused && !isVscodeWebview();
185185

186+
// Check if there's a command prefix to highlight
187+
const hasCommandPrefix = extractCommandPrefix(value) !== null;
188+
186189
return (
187190
<div style={{ width: "100%" }} data-component="VimTextAreaContainer">
188191
<div
@@ -228,7 +231,30 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
228231
</div>
229232
)}
230233
</div>
231-
<div style={{ position: "relative" }} data-component="VimTextAreaWrapper">
234+
{/*
235+
Wrapper owns ALL shared typography (font, size, line-height).
236+
Both textarea and highlight overlay inherit from here.
237+
This ensures pixel-perfect alignment without duplicating styles.
238+
Background is on wrapper so overlay (behind textarea) shows correctly.
239+
*/}
240+
<div
241+
className={cn(
242+
"relative rounded text-[13px]",
243+
vimEnabled ? "font-monospace" : "font-sans",
244+
isEditing ? "bg-editing-mode-alpha" : "bg-dark"
245+
)}
246+
data-component="VimTextAreaWrapper"
247+
>
248+
{/*
249+
Command highlight overlay - positioned BEHIND textarea.
250+
Uses transparent textarea text pattern: overlay shows through
251+
the transparent textarea, caret remains visible via caret-color.
252+
*/}
253+
<CommandHighlightOverlay
254+
value={value}
255+
hasCommand={hasCommandPrefix}
256+
className="absolute inset-0 overflow-hidden rounded border border-transparent px-2 py-1.5"
257+
/>
232258
<textarea
233259
ref={textareaRef}
234260
value={value}
@@ -249,32 +275,42 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
249275
...(trailingAction ? { scrollbarGutter: "stable both-edges" } : {}),
250276
// Focus border color from agent definition
251277
"--focus-border-color": !isEditing ? focusBorderColor : undefined,
278+
// Transparent text pattern: when command is present, make textarea
279+
// text invisible so the highlight overlay shows through.
280+
// The caret remains visible via caret-color below.
281+
WebkitTextFillColor: hasCommandPrefix ? "transparent" : undefined,
252282
} as React.CSSProperties
253283
}
254284
className={cn(
255-
"w-full border text-light py-1.5 px-2 rounded text-[13px] resize-none min-h-8 max-h-[50vh] overflow-y-auto",
256-
vimEnabled ? "font-monospace" : "font-sans",
285+
// Layout & sizing
286+
"relative w-full py-1.5 px-2 rounded resize-none min-h-8 max-h-[50vh] overflow-y-auto",
287+
// Typography inherited from wrapper, but explicitly set color for non-command state
288+
"text-light",
289+
// Font inherited from wrapper via `font: inherit` - but we need to repeat for
290+
// the CSS cascade since className comes after style inheritance
291+
"font-[inherit]",
292+
// Border
293+
"border",
294+
// Background - always transparent so overlay can show through
295+
"bg-transparent",
296+
// Placeholder
257297
"placeholder:text-placeholder",
298+
// Focus
258299
"focus:outline-none",
300+
// Trailing action padding
259301
trailingAction && "pr-10",
302+
// Border colors based on state
260303
isEditing
261-
? "bg-editing-mode-alpha border-editing-mode focus:border-editing-mode"
262-
: "bg-dark border-border-light focus:border-[var(--focus-border-color)]",
304+
? "border-editing-mode focus:border-editing-mode"
305+
: "border-border-light focus:border-[var(--focus-border-color)]",
306+
// Caret: always visible (the highlight overlay handles text color)
307+
// In vim normal mode, hide caret and show block selection
263308
vimMode === "normal"
264309
? "caret-transparent selection:bg-white/50"
265-
: "caret-current selection:bg-selection",
310+
: "caret-light selection:bg-selection",
266311
rest.className
267312
)}
268313
/>
269-
{/* Command highlight overlay - positioned on top of textarea, pointer-events: none allows clicks through */}
270-
<CommandHighlightOverlay
271-
value={value}
272-
vimEnabled={vimEnabled}
273-
className={cn(
274-
"absolute inset-0 overflow-hidden rounded border border-transparent",
275-
vimEnabled ? "font-monospace" : "font-sans"
276-
)}
277-
/>
278314
{trailingAction && (
279315
<div className="pointer-events-none absolute right-3.5 bottom-2.5 flex items-center">
280316
<div className="pointer-events-auto">{trailingAction}</div>

0 commit comments

Comments
 (0)