Skip to content
77 changes: 77 additions & 0 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -230,6 +234,17 @@ const AIViewInner: React.FC<AIViewProps> = ({
{ listener: true } // Enable cross-component synchronization
);

// Keybinds state
const [keybinds, setKeybinds] = useState<KeybindsConfig>([]);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingKey, setEditingKey] = useState<string>("");
const [editingKeyMessage, setEditingKeyMessage] = useState<string>("");

// Load keybinds on mount
useEffect(() => {
void window.api.keybinds.get().then(setKeybinds);
}, []);

// Use auto-scroll hook for scroll management
const {
contentRef,
Expand Down Expand Up @@ -313,6 +328,50 @@ const AIViewInner: React.FC<AIViewProps> = ({
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) {
Expand Down Expand Up @@ -347,6 +406,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
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(() => {
Expand Down Expand Up @@ -454,6 +520,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
</WorkspaceTitle>
</ViewHeader>

<FKeyBar keybinds={keybinds} onEditKeybind={handleEditKeybind} />

<OutputContainer>
<OutputContent
ref={contentRef}
Expand Down Expand Up @@ -560,6 +628,15 @@ const AIViewInner: React.FC<AIViewProps> = ({
</ChatArea>

<ChatMetaSidebar key={workspaceId} workspaceId={workspaceId} chatAreaRef={chatAreaRef} />

<EditKeybindModal
isOpen={editModalOpen}
fKey={editingKey}
currentMessage={editingKeyMessage}
onSave={handleSaveKeybind}
onClear={handleClearKeybind}
onClose={handleCloseKeybindModal}
/>
</ViewContainer>
);
};
Expand Down
39 changes: 32 additions & 7 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const ModelDisplayWrapper = styled.div`

export interface ChatInputAPI {
focus: () => void;
sendMessage: (message: string) => void;
}

export interface ChatInputProps {
Expand Down Expand Up @@ -430,13 +431,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
});
}, []);

// Provide API to parent via callback
useEffect(() => {
if (onReady) {
onReady({ focus: focusMessageInput });
}
}, [onReady, focusMessageInput]);

useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (isEditableElement(event.target)) {
Expand Down Expand Up @@ -942,6 +936,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
};

// 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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Programmatic send uses stale input state

The new sendMessage helper inserts the macro text and then triggers handleSend via a delayed callback, but handleSend captures the input value from the previous render. When the timeout fires, the closure still sees the old (often empty) input and returns immediately, leaving the macro message sitting in the input field instead of being sent. This means F‑key macros don’t actually transmit anything unless the user manually hits Enter afterward. The helper needs to send the message without relying on the stale closure—e.g. by passing the text directly into the send routine or by using a ref to a stable sender.

Useful? React with 👍 / 👎.

},
[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) {
Expand Down
168 changes: 168 additions & 0 deletions src/components/EditKeybindModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>(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 (
<Modal isOpen={isOpen} onClose={onClose} title={`Edit ${fKey} Macro`}>
<ModalContent>
<div>
<Label htmlFor="keybind-message">Message to send:</Label>
<TextArea
ref={textareaRef}
id="keybind-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter message (supports slash commands like /edit, /compact, etc.)"
/>
</div>

<HintText>
Tip: You can use slash commands like <code>/edit</code>, <code>/compact</code>, or any
other message. Press Ctrl+Enter to save.
</HintText>

<ButtonRow>
{currentMessage && (
<Button variant="danger" onClick={handleClear}>
Clear
</Button>
)}
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSave}>
Save
</Button>
</ButtonRow>
</ModalContent>
</Modal>
);
}

Loading
Loading