Skip to content
Draft
79 changes: 79 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,18 @@ 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>("");
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,
Expand Down Expand Up @@ -313,6 +329,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 +407,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 @@ -544,6 +611,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
)}
</OutputContainer>

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

<ChatInput
workspaceId={workspaceId}
onMessageSent={handleMessageSent}
Expand All @@ -556,10 +625,20 @@ const AIViewInner: React.FC<AIViewProps> = ({
onEditLastUserMessage={handleEditLastUserMessage}
canInterrupt={canInterrupt}
onReady={handleChatInputReady}
onFocusChange={setChatInputFocused}
/>
</ChatArea>

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

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

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

export interface ChatInputProps {
Expand All @@ -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
Expand Down Expand Up @@ -382,6 +385,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
onEditLastUserMessage,
canInterrupt = false,
onReady,
onFocusChange,
}) => {
const [input, setInput] = usePersistedState(getInputKey(workspaceId), "", { listener: true });
const [isSending, setIsSending] = useState(false);
Expand Down Expand Up @@ -430,13 +434,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 @@ -613,13 +610,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({
[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
Expand All @@ -628,10 +637,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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(),
Expand All @@ -643,10 +649,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

// 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(),
Expand All @@ -659,7 +662,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// 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);
Expand All @@ -685,7 +688,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

// Handle /model command
if (parsed.type === "model-set") {
setInput(""); // Clear input immediately
clearInputIfNeeded();
setPreferredModel(parsed.modelString);
onModelChange?.(parsed.modelString);
setToast({
Expand All @@ -698,7 +701,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

// Handle /telemetry command
if (parsed.type === "telemetry-set") {
setInput(""); // Clear input immediately
clearInputIfNeeded();
setTelemetryEnabled(parsed.enabled);
setToast({
id: Date.now().toString(),
Expand All @@ -710,7 +713,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

// Handle /compact command
if (parsed.type === "compact") {
setInput(""); // Clear input immediately
clearInputIfNeeded();
setIsSending(true);

try {
Expand Down Expand Up @@ -761,7 +764,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

// Handle /fork command
if (parsed.type === "fork") {
setInput(""); // Clear input immediately
clearInputIfNeeded();
setIsSending(true);

try {
Expand Down Expand Up @@ -862,12 +865,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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();
Expand Down Expand Up @@ -942,6 +941,48 @@ 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;
}

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) {
Expand Down Expand Up @@ -982,6 +1023,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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)}
Expand Down
Loading
Loading