From 4a235a7ef98b95316321baaf9005a81cf5be9f55 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 13:32:10 -0500 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=A4=96=20Add=20customizable=20F-key?= =?UTF-8?q?=20macros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add F1-F10 keybind bar above workspace title - Click F-key buttons to edit macro messages - Press F-keys to send messages (supports slash commands) - Configuration stored in ~/.cmux/keybinds.jsonc - Full IPC plumbing with type-safe backend service - 8 unit tests for keybind service (all passing) Backend: - KeybindService for loading/saving keybinds.jsonc - IPC handlers for get/set operations - Type definitions for keybind actions Frontend: - FKeyBar component showing F1-F10 buttons - EditKeybindModal for configuring macros - useFKeyBinds hook for global keyboard handling - Extended ChatInputAPI with sendMessage() method Generated with `cmux` --- src/components/AIView.tsx | 77 +++++++++++++ src/components/ChatInput.tsx | 39 +++++-- src/components/EditKeybindModal.tsx | 168 ++++++++++++++++++++++++++++ src/components/FKeyBar.tsx | 105 +++++++++++++++++ src/constants/ipc-constants.ts | 4 + src/hooks/useFKeyBinds.ts | 49 ++++++++ src/preload.ts | 4 + src/services/ipcMain.ts | 15 +++ src/services/keybindService.test.ts | 127 +++++++++++++++++++++ src/services/keybindService.ts | 117 +++++++++++++++++++ src/types/ipc.ts | 4 + src/types/keybinds.ts | 19 ++++ 12 files changed, 721 insertions(+), 7 deletions(-) create mode 100644 src/components/EditKeybindModal.tsx create mode 100644 src/components/FKeyBar.tsx create mode 100644 src/hooks/useFKeyBinds.ts create mode 100644 src/services/keybindService.test.ts create mode 100644 src/services/keybindService.ts create mode 100644 src/types/keybinds.ts diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index e4e94dbb7..753e3f061 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,17 @@ 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(""); + + // Load keybinds on mount + useEffect(() => { + void window.api.keybinds.get().then(setKeybinds); + }, []); + // Use auto-scroll hook for scroll management const { contentRef, @@ -313,6 +328,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 +406,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(() => { @@ -454,6 +520,8 @@ const AIViewInner: React.FC = ({ + + = ({ + + ); }; diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index a5f0523d7..b39c4581d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -120,6 +120,7 @@ const ModelDisplayWrapper = styled.div` export interface ChatInputAPI { focus: () => void; + sendMessage: (message: string) => void; } export interface ChatInputProps { @@ -430,13 +431,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)) { @@ -942,6 +936,37 @@ 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; + } + + // Set the input value and let the regular send flow handle it + setInput(message); + // Focus the input to show what's being sent + if (inputRef.current) { + inputRef.current.focus(); + } + // Trigger send after a brief delay to ensure state updates + setTimeout(() => { + void handleSend(); + }, 10); + }, + [disabled, isSending, isCompacting, 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) { diff --git a/src/components/EditKeybindModal.tsx b/src/components/EditKeybindModal.tsx new file mode 100644 index 000000000..cdf0aa93a --- /dev/null +++ b/src/components/EditKeybindModal.tsx @@ -0,0 +1,168 @@ +import React, { useState, useEffect, useRef } from "react"; +import styled from "@emotion/styled"; +import { Modal } from "./Modal"; + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + min-width: 500px; +`; + +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 ( + + +
+ +