Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 26 additions & 21 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import { shouldTriggerAutoCompaction } from "@/browser/utils/compaction/shouldTriggerAutoCompaction";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { findAtMentionAtCursor } from "@/common/utils/atMentions";
import { getCaretOffset, setCaretOffset } from "@/browser/utils/contentEditableSelection";
import {
getSlashCommandSuggestions,
type SlashSuggestion,
Expand All @@ -81,7 +82,7 @@ import { stopKeyboardPropagation } from "@/browser/utils/events";
import { ModelSelector, type ModelSelectorRef } from "../ModelSelector";
import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings";
import { SendHorizontal } from "lucide-react";
import { VimTextArea } from "../VimTextArea";
import { RichTextInput } from "../RichTextInput";
import { ChatAttachments, type ChatAttachment } from "../ChatAttachments";
import {
extractAttachmentsFromClipboard,
Expand Down Expand Up @@ -310,7 +311,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
isMountedRef.current = false;
};
}, []);
const inputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(null);
const [atMentionCursorNonce, setAtMentionCursorNonce] = useState(0);
const lastAtMentionCursorRef = useRef<number | null>(null);
Expand All @@ -320,7 +321,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return;
}

const nextCursor = el.selectionStart ?? input.length;
const nextCursor = getCaretOffset(el) ?? input.length;
if (lastAtMentionCursorRef.current === nextCursor) {
return;
}
Expand Down Expand Up @@ -759,20 +760,18 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

const focusMessageInput = useCallback(() => {
const element = inputRef.current;
if (!element || element.disabled) {
if (!element?.isContentEditable) {
return;
}

element.focus();

requestAnimationFrame(() => {
const cursor = element.value.length;
element.selectionStart = cursor;
element.selectionEnd = cursor;
setCaretOffset(element, input.length);
element.style.height = "auto";
element.style.height = Math.min(element.scrollHeight, window.innerHeight * 0.5) + "px";
});
}, []);
}, [input.length]);

// Method to restore text to input (used by compaction cancel)
const restoreText = useCallback(
Expand Down Expand Up @@ -919,7 +918,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return;
}

const cursor = inputRef.current?.selectionStart ?? input.length;
const cursor = getCaretOffset(inputRef.current) ?? input.length;
const match = findAtMentionAtCursor(input, cursor);

if (!match) {
Expand Down Expand Up @@ -1292,7 +1291,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

// Handle paste events to extract attachments
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
(e: React.ClipboardEvent<HTMLDivElement>) => {
const items = e.clipboardData?.items;
if (!items) return;

Expand Down Expand Up @@ -1424,7 +1423,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

// Handle drag over to allow drop
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLTextAreaElement>) => {
(e: React.DragEvent<HTMLDivElement>) => {
// Check if drag contains files
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
Expand All @@ -1436,7 +1435,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

// Handle drop to extract attachments
const handleDrop = useCallback(
(e: React.DragEvent<HTMLTextAreaElement>) => {
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();

const attachmentFiles = extractAttachmentsFromDrop(e.dataTransfer);
Expand Down Expand Up @@ -1469,7 +1468,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

const handleAtMentionSelect = useCallback(
(suggestion: SlashSuggestion) => {
const cursor = inputRef.current?.selectionStart ?? input.length;
const cursor = getCaretOffset(inputRef.current) ?? input.length;
const match = findAtMentionAtCursor(input, cursor);
if (!match) {
return;
Expand All @@ -1488,15 +1487,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

requestAnimationFrame(() => {
const el = inputRef.current;
if (!el || el.disabled) {
if (!el?.isContentEditable) {
return;
}

el.focus();
// +1 for the trailing space we added
const newCursor = match.startIndex + suggestion.replacement.length + 1;
el.selectionStart = newCursor;
el.selectionEnd = newCursor;
setCaretOffset(el, newCursor);
});
},
[input, setInput]
Expand All @@ -1505,7 +1503,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
(suggestion: SlashSuggestion) => {
setInput(suggestion.replacement);
setShowCommandSuggestions(false);
inputRef.current?.focus();
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el?.isContentEditable) {
return;
}
el.focus();
setCaretOffset(el, suggestion.replacement.length);
});
},
[setInput]
);
Expand Down Expand Up @@ -1585,7 +1590,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (isMountedRef.current) {
setInput("");
setAttachments([]);
// Height is managed by VimTextArea's useLayoutEffect - clear inline style
// Height is managed by RichTextInput's useLayoutEffect - clear inline style
// to let CSS min-height take over
if (inputRef.current) {
inputRef.current.style.height = "";
Expand Down Expand Up @@ -1813,7 +1818,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
setInput("");
setAttachments([]);
setHideReviewsDuringSend(true);
// Clear inline height style - VimTextArea's useLayoutEffect will handle sizing
// Clear inline height style - RichTextInput's useLayoutEffect will handle sizing
if (inputRef.current) {
inputRef.current.style.height = "";
}
Expand Down Expand Up @@ -1967,7 +1972,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return;
}

// Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal)
// Note: ESC handled by RichTextInput (for mode transitions) and CommandSuggestions (for dismissal)

const hasCommandSuggestionMenu = showCommandSuggestions && commandSuggestions.length > 0;
const hasAtMentionSuggestionMenu = showAtMentionSuggestions && atMentionSuggestions.length > 0;
Expand Down Expand Up @@ -2128,7 +2133,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
/>
) : (
<>
<VimTextArea
<RichTextInput
ref={inputRef}
value={input}
isEditing={!!editingMessage}
Expand Down
38 changes: 38 additions & 0 deletions src/browser/components/CommandHighlightOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { extractCommandPrefix } from "@/browser/components/CommandHighlightOverlay";

describe("extractCommandPrefix", () => {
it("returns null for non-command input", () => {
expect(extractCommandPrefix("hello world")).toBeNull();
expect(extractCommandPrefix("")).toBeNull();
expect(extractCommandPrefix(" /command")).toBeNull(); // leading whitespace
});

it("returns null for just a slash", () => {
expect(extractCommandPrefix("/")).toBeNull();
});

it("extracts simple command", () => {
expect(extractCommandPrefix("/compact")).toBe("/compact");
expect(extractCommandPrefix("/help")).toBe("/help");
});

it("extracts command with arguments", () => {
expect(extractCommandPrefix("/compact -t 5000")).toBe("/compact -t 5000");
expect(extractCommandPrefix("/model sonnet")).toBe("/model sonnet");
expect(extractCommandPrefix("/providers set anthropic apiKey")).toBe(
"/providers set anthropic apiKey"
);
});

it("extracts command up to newline", () => {
expect(extractCommandPrefix("/compact\nContinue working")).toBe("/compact");
expect(extractCommandPrefix("/model sonnet\nDo the thing")).toBe("/model sonnet");
});

it("preserves trailing spaces on command line", () => {
// Trailing spaces are part of the first line and should be included
// so the overlay matches the textarea layout exactly
expect(extractCommandPrefix("/compact ")).toBe("/compact ");
expect(extractCommandPrefix("/model sonnet ")).toBe("/model sonnet ");
});
});
110 changes: 110 additions & 0 deletions src/browser/components/CommandHighlightOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from "react";

/**
* Extract the command prefix from input text for highlighting.
* Returns the prefix if the input starts with a valid slash command pattern,
* otherwise returns null.
*
* The command prefix is the first line (everything before the first newline),
* since slash commands use the first line for the command and subsequent lines
* for the message body.
*
* Examples:
* - "/compact" → "/compact"
* - "/compact -t 5000" → "/compact -t 5000"
* - "/compact\nContinue working" → "/compact"
* - "/model sonnet" → "/model sonnet"
* - "regular message" → null
*/
export function extractCommandPrefix(input: string): string | null {
// Must start with slash (no leading whitespace allowed for commands)
if (!input.startsWith("/")) {
return null;
}

// Find where the command part ends (first newline)
const firstLineEnd = input.indexOf("\n");
const commandLine = firstLineEnd >= 0 ? input.slice(0, firstLineEnd) : input;

// If the command line is just the slash, don't highlight yet
if (commandLine.length <= 1) {
return null;
}

return commandLine;
}

interface CommandPrefixTextProps {
children: React.ReactNode;
/** Additional className - use to add font-mono when not inheriting from parent */
className?: string;
}

/**
* Shared styling for command prefix text (e.g., "/compact" or "/skill-name").
* Used in the chat input highlight and sent message display.
*
* Font-family is inherited by default. When used outside of RichTextInput
* (e.g., in UserMessageContent), pass className="font-mono" explicitly.
*/
export const CommandPrefixText: React.FC<CommandPrefixTextProps> = (props) => (
<span className={`font-medium text-[var(--color-plan-mode-light)] ${props.className ?? ""}`}>
{props.children}
</span>
);

interface CommandHighlightOverlayProps {
/** The current input text value */
value: string;
/** Additional className for the container */
className?: string;
/** Whether there's a command prefix (controls visibility) */
hasCommand: boolean;
}

/**
* Renders a highlight overlay for slash command prefixes.
*
* Uses the "transparent textarea text" pattern:
* - Overlay is positioned BEHIND the textarea
* - Textarea text is made transparent (via parent) so overlay shows through
* - Caret remains visible via caret-color on textarea
*
* All typography is inherited from the parent wrapper container,
* eliminating duplication and ensuring perfect alignment.
*/
export const CommandHighlightOverlay: React.FC<CommandHighlightOverlayProps> = (props) => {
const commandPrefix = extractCommandPrefix(props.value);

// Don't render if no command or parent says no command
if (!props.hasCommand || !commandPrefix) {
return null;
}

// Split the value into highlighted prefix and rest
const rest = props.value.slice(commandPrefix.length);

return (
<div
className={props.className}
style={{
// Inherit ALL typography from parent container
font: "inherit",
letterSpacing: "inherit",
// Text handling - match textarea behavior
whiteSpace: "pre-wrap",
wordWrap: "break-word",
overflowWrap: "break-word",
// Prevent any interaction - clicks pass through to textarea
pointerEvents: "none",
userSelect: "none",
}}
aria-hidden="true"
>
{/* Command prefix in highlight color */}
<CommandPrefixText>{commandPrefix}</CommandPrefixText>
{/* Rest of text in normal color - textarea is transparent so this shows */}
<span className="text-light">{rest}</span>
</div>
);
};
14 changes: 4 additions & 10 deletions src/browser/components/Messages/UserMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { FilePart } from "@/common/orpc/schemas";
import { ReviewBlockFromData } from "../shared/ReviewBlock";
import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { CommandPrefixText } from "../CommandHighlightOverlay";

interface UserMessageContentProps {
content: string;
Expand Down Expand Up @@ -77,13 +78,6 @@ const imageStyles = {
queued: "border-border-light max-h-[300px] max-w-80 rounded border",
} as const;

/** Styled command prefix (e.g., "/compact" or "/skill-name") */
const CommandPrefixBadge: React.FC<{ prefix: string }> = (props) => (
<span className="font-mono text-[13px] font-medium text-[var(--color-plan-mode-light)]">
{props.prefix}
</span>
);

/**
* Shared content renderer for user messages (sent and queued).
* Handles reviews, text content, and attachments.
Expand Down Expand Up @@ -131,11 +125,11 @@ export const UserMessageContent: React.FC<UserMessageContentProps> = (props) =>
const hasSpaceAfterPrefix = charAfterPrefix === " ";
const hasNewlineAfterPrefix = charAfterPrefix === "\n";

// Newline after prefix: block layout (badge on own line)
// Space after prefix: inline layout (badge + content on same line)
// Newline after prefix: block layout (prefix on own line)
// Space after prefix: inline layout (prefix + content on same line)
return (
<div className={hasNewlineAfterPrefix ? "" : "flex flex-wrap items-baseline"}>
<CommandPrefixBadge prefix={shouldHighlightPrefix} />
<CommandPrefixText className="font-mono">{shouldHighlightPrefix}</CommandPrefixText>
{hasSpaceAfterPrefix && <span>&nbsp;</span>}
{remainingContent.trim() && (
<MarkdownRenderer
Expand Down
Loading
Loading