diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index e4e94dbb7..8e72dd772 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -28,6 +28,10 @@ import { useGitStatus } from "@/stores/GitStatusStore"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { DisplayedMessage } from "@/types/message"; import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds"; +import { FKeyBar } from "./FKeyBar"; +import { EditKeybindModal } from "./EditKeybindModal"; +import { useFKeyBinds } from "@/hooks/useFKeyBinds"; +import type { Keybind, KeybindsConfig } from "@/types/keybinds"; const ViewContainer = styled.div` flex: 1; @@ -230,6 +234,18 @@ const AIViewInner: React.FC = ({ { listener: true } // Enable cross-component synchronization ); + // Keybinds state + const [keybinds, setKeybinds] = useState([]); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingKey, setEditingKey] = useState(""); + const [editingKeyMessage, setEditingKeyMessage] = useState(""); + const [chatInputFocused, setChatInputFocused] = useState(false); + + // Load keybinds on mount + useEffect(() => { + void window.api.keybinds.get().then(setKeybinds); + }, []); + // Use auto-scroll hook for scroll management const { contentRef, @@ -313,6 +329,50 @@ const AIViewInner: React.FC = ({ void window.api.workspace.openTerminal(namedWorkspacePath); }, [namedWorkspacePath]); + // Keybind handlers + const handleEditKeybind = useCallback((key: string, currentMessage = "") => { + setEditingKey(key); + setEditingKeyMessage(currentMessage); + setEditModalOpen(true); + }, []); + + const handleSaveKeybind = useCallback( + async (message: string) => { + const trimmedMessage = message.trim(); + + if (trimmedMessage) { + // Save or update keybind + const newKeybind: Keybind = { + key: editingKey, + action: { type: "send_message", message: trimmedMessage }, + }; + const updated = [...keybinds.filter((kb) => kb.key !== editingKey), newKeybind]; + await window.api.keybinds.set(updated); + setKeybinds(updated); + } else { + // Empty message means delete + const updated = keybinds.filter((kb) => kb.key !== editingKey); + await window.api.keybinds.set(updated); + setKeybinds(updated); + } + + setEditModalOpen(false); + }, + [editingKey, keybinds] + ); + + const handleClearKeybind = useCallback(async () => { + // Remove the keybind + const updated = keybinds.filter((kb) => kb.key !== editingKey); + await window.api.keybinds.set(updated); + setKeybinds(updated); + setEditModalOpen(false); + }, [editingKey, keybinds]); + + const handleCloseKeybindModal = useCallback(() => { + setEditModalOpen(false); + }, []); + // Auto-scroll when messages update (during streaming) useEffect(() => { if (workspaceState && autoScroll) { @@ -347,6 +407,13 @@ const AIViewInner: React.FC = ({ handleOpenTerminal, }); + // F-key keybinds hook (disabled when modal is open) + useFKeyBinds({ + keybinds, + chatInputAPI, + enabled: !editModalOpen, + }); + // Clear editing state if the message being edited no longer exists // Must be before early return to satisfy React Hooks rules useEffect(() => { @@ -544,6 +611,8 @@ const AIViewInner: React.FC = ({ )} + {chatInputFocused && } + = ({ onEditLastUserMessage={handleEditLastUserMessage} canInterrupt={canInterrupt} onReady={handleChatInputReady} + onFocusChange={setChatInputFocused} /> + + ); }; diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index a5f0523d7..a9226b2f9 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -120,6 +120,8 @@ const ModelDisplayWrapper = styled.div` export interface ChatInputAPI { focus: () => void; + sendMessage: (message: string) => void; + onFocusChange?: (focused: boolean) => void; } export interface ChatInputProps { @@ -135,6 +137,7 @@ export interface ChatInputProps { onEditLastUserMessage?: () => void; canInterrupt?: boolean; // Whether Esc can be used to interrupt streaming onReady?: (api: ChatInputAPI) => void; // Callback with focus method + onFocusChange?: (focused: boolean) => void; // Callback when input focus changes } // Helper function to convert parsed command to display toast @@ -382,6 +385,7 @@ export const ChatInput: React.FC = ({ onEditLastUserMessage, canInterrupt = false, onReady, + onFocusChange, }) => { const [input, setInput] = usePersistedState(getInputKey(workspaceId), "", { listener: true }); const [isSending, setIsSending] = useState(false); @@ -430,13 +434,6 @@ export const ChatInput: React.FC = ({ }); }, []); - // Provide API to parent via callback - useEffect(() => { - if (onReady) { - onReady({ focus: focusMessageInput }); - } - }, [onReady, focusMessageInput]); - useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { if (isEditableElement(event.target)) { @@ -613,13 +610,25 @@ export const ChatInput: React.FC = ({ [setInput] ); - const handleSend = async () => { + const handleSend = async (messageOverride?: string) => { + // Use override message if provided (for programmatic sends), otherwise use input state + const messageText = messageOverride?.trim() ?? input.trim(); + const isProgrammaticSend = messageOverride !== undefined; + // Allow sending if there's text or images - if ((!input.trim() && imageAttachments.length === 0) || disabled || isSending || isCompacting) { + if ((!messageText && imageAttachments.length === 0) || disabled || isSending || isCompacting) { return; } - const messageText = input.trim(); + // Helper to clear input only for non-programmatic sends + const clearInputIfNeeded = () => { + if (!isProgrammaticSend) { + setInput(""); + if (inputRef.current) { + inputRef.current.style.height = "36px"; + } + } + }; try { // Parse command @@ -628,10 +637,7 @@ export const ChatInput: React.FC = ({ if (parsed) { // Handle /clear command if (parsed.type === "clear") { - setInput(""); - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } + clearInputIfNeeded(); await onTruncateHistory(1.0); setToast({ id: Date.now().toString(), @@ -643,10 +649,7 @@ export const ChatInput: React.FC = ({ // Handle /truncate command if (parsed.type === "truncate") { - setInput(""); - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } + clearInputIfNeeded(); await onTruncateHistory(parsed.percentage); setToast({ id: Date.now().toString(), @@ -659,7 +662,7 @@ export const ChatInput: React.FC = ({ // Handle /providers set command if (parsed.type === "providers-set" && onProviderConfig) { setIsSending(true); - setInput(""); // Clear input immediately + clearInputIfNeeded(); try { await onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); @@ -685,7 +688,7 @@ export const ChatInput: React.FC = ({ // Handle /model command if (parsed.type === "model-set") { - setInput(""); // Clear input immediately + clearInputIfNeeded(); setPreferredModel(parsed.modelString); onModelChange?.(parsed.modelString); setToast({ @@ -698,7 +701,7 @@ export const ChatInput: React.FC = ({ // Handle /telemetry command if (parsed.type === "telemetry-set") { - setInput(""); // Clear input immediately + clearInputIfNeeded(); setTelemetryEnabled(parsed.enabled); setToast({ id: Date.now().toString(), @@ -710,7 +713,7 @@ export const ChatInput: React.FC = ({ // Handle /compact command if (parsed.type === "compact") { - setInput(""); // Clear input immediately + clearInputIfNeeded(); setIsSending(true); try { @@ -761,7 +764,7 @@ export const ChatInput: React.FC = ({ // Handle /fork command if (parsed.type === "fork") { - setInput(""); // Clear input immediately + clearInputIfNeeded(); setIsSending(true); try { @@ -862,12 +865,8 @@ export const ChatInput: React.FC = ({ telemetry.messageSent(sendMessageOptions.model, mode, actualMessageText.length); // Success - clear input and images - setInput(""); + clearInputIfNeeded(); setImageAttachments([]); - // Reset textarea height - if (inputRef.current) { - inputRef.current.style.height = "36px"; - } // Exit editing mode if we were editing if (editingMessage && onCancelEdit) { onCancelEdit(); @@ -942,6 +941,48 @@ export const ChatInput: React.FC = ({ } }; + // Programmatically send a message (for F-key macros, etc.) + const sendMessageProgrammatically = useCallback( + (message: string) => { + if (!message.trim() || disabled || isSending || isCompacting) { + return; + } + + const currentInput = input.trim(); + + if (currentInput) { + // If there's existing input, append the message (don't auto-send) + const newInput = currentInput + " " + message; + setInput(newInput); + // Focus the input so user can edit/send + if (inputRef.current) { + inputRef.current.focus(); + // Move cursor to end + setTimeout(() => { + if (inputRef.current) { + inputRef.current.selectionStart = inputRef.current.value.length; + inputRef.current.selectionEnd = inputRef.current.value.length; + } + }, 0); + } + } else { + // If input is empty, auto-send using the refactored handleSend + void handleSend(message); + } + }, + [disabled, isSending, isCompacting, input, setInput, handleSend] + ); + + // Provide API to parent via callback + useEffect(() => { + if (onReady) { + onReady({ + focus: focusMessageInput, + sendMessage: sendMessageProgrammatically, + }); + } + }, [onReady, focusMessageInput, sendMessageProgrammatically]); + // Build placeholder text based on current state const placeholder = (() => { if (editingMessage) { @@ -982,6 +1023,8 @@ export const ChatInput: React.FC = ({ onChange={setInput} onKeyDown={handleKeyDown} onPaste={handlePaste} + onFocus={() => onFocusChange?.(true)} + onBlur={() => onFocusChange?.(false)} suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined} placeholder={placeholder} disabled={!editingMessage && (disabled || isSending || isCompacting)} diff --git a/src/components/EditKeybindModal.tsx b/src/components/EditKeybindModal.tsx new file mode 100644 index 000000000..c5abac471 --- /dev/null +++ b/src/components/EditKeybindModal.tsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect, useRef } from "react"; +import styled from "@emotion/styled"; +import { Modal } from "./Modal"; + +const KeybindModalContent = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const Label = styled.label` + font-size: 13px; + color: #ccc; + margin-bottom: 6px; + display: block; +`; + +const TextArea = styled.textarea` + width: 100%; + min-height: 100px; + padding: 8px; + background: #1e1e1e; + border: 1px solid #3e3e42; + border-radius: 3px; + color: #d4d4d4; + font-family: var(--font-monospace); + font-size: 13px; + resize: vertical; + + &:focus { + outline: none; + border-color: #007acc; + } +`; + +const ButtonRow = styled.div` + display: flex; + gap: 8px; + justify-content: flex-end; +`; + +const Button = styled.button<{ variant?: "primary" | "danger" }>` + padding: 6px 16px; + border-radius: 3px; + font-size: 13px; + cursor: pointer; + border: none; + transition: all 0.15s ease; + + ${(props) => { + if (props.variant === "primary") { + return ` + background: #007acc; + color: white; + &:hover { background: #005a9e; } + &:disabled { + background: #555; + color: #888; + cursor: not-allowed; + } + `; + } else if (props.variant === "danger") { + return ` + background: #c72e2e; + color: white; + &:hover { background: #a02020; } + `; + } else { + return ` + background: #3e3e42; + color: #ccc; + &:hover { background: #505055; } + `; + } + }} +`; + +const HintText = styled.div` + font-size: 12px; + color: #888; + line-height: 1.4; +`; + +interface EditKeybindModalProps { + isOpen: boolean; + fKey: string; + currentMessage: string; + onSave: (message: string) => void; + onClear: () => void; + onClose: () => void; +} + +export function EditKeybindModal({ + isOpen, + fKey, + currentMessage, + onSave, + onClear, + onClose, +}: EditKeybindModalProps) { + const [message, setMessage] = useState(currentMessage); + const textareaRef = useRef(null); + + // Reset message when modal opens with new key + useEffect(() => { + setMessage(currentMessage); + }, [currentMessage, isOpen]); + + // Focus textarea when modal opens + useEffect(() => { + if (isOpen && textareaRef.current) { + textareaRef.current.focus(); + } + }, [isOpen]); + + const handleSave = () => { + onSave(message.trim()); + }; + + const handleClear = () => { + onClear(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Ctrl+Enter / Cmd+Enter to save + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSave(); + } + }; + + return ( + + +
+ +