mirror of
https://github.com/aljazceru/goose.git
synced 2026-01-24 08:44:25 +01:00
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
import React, { useRef, useState, useEffect } from 'react';
|
|
import { Button } from './ui/button';
|
|
import Stop from './ui/Stop';
|
|
import { Attach, Send } from './icons';
|
|
|
|
interface InputProps {
|
|
handleSubmit: (e: React.FormEvent) => void;
|
|
isLoading?: boolean;
|
|
onStop?: () => void;
|
|
commandHistory?: string[];
|
|
}
|
|
|
|
export default function Input({
|
|
handleSubmit,
|
|
isLoading = false,
|
|
onStop,
|
|
commandHistory = [],
|
|
}: InputProps) {
|
|
const [value, setValue] = useState('');
|
|
// State to track if the IME is composing (i.e., in the middle of Japanese IME input)
|
|
const [isComposing, setIsComposing] = useState(false);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const [savedInput, setSavedInput] = useState('');
|
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.focus();
|
|
}
|
|
}, [value]);
|
|
|
|
const useAutosizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: string) => {
|
|
useEffect(() => {
|
|
if (textAreaRef) {
|
|
textAreaRef.style.height = '0px'; // Reset height
|
|
const scrollHeight = textAreaRef.scrollHeight;
|
|
textAreaRef.style.height = Math.min(scrollHeight, maxHeight) + 'px';
|
|
}
|
|
}, [textAreaRef, value]);
|
|
};
|
|
|
|
const minHeight = '1rem';
|
|
const maxHeight = 10 * 24;
|
|
|
|
useAutosizeTextArea(textAreaRef.current, value);
|
|
|
|
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const val = evt.target.value;
|
|
setValue(val);
|
|
};
|
|
|
|
// Handlers for composition events, which are crucial for proper IME behavior
|
|
const handleCompositionStart = (evt: React.CompositionEvent<HTMLTextAreaElement>) => {
|
|
setIsComposing(true);
|
|
};
|
|
|
|
const handleCompositionEnd = (evt: React.CompositionEvent<HTMLTextAreaElement>) => {
|
|
setIsComposing(false);
|
|
};
|
|
|
|
const handleHistoryNavigation = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
evt.preventDefault();
|
|
|
|
// Save current input if we're just starting to navigate history
|
|
if (historyIndex === -1) {
|
|
setSavedInput(value);
|
|
}
|
|
|
|
// Calculate new history index
|
|
let newIndex = historyIndex;
|
|
if (evt.key === 'ArrowUp') {
|
|
// Move backwards through history
|
|
if (historyIndex < commandHistory.length - 1) {
|
|
newIndex = historyIndex + 1;
|
|
}
|
|
} else {
|
|
// Move forwards through history
|
|
if (historyIndex > -1) {
|
|
newIndex = historyIndex - 1;
|
|
}
|
|
}
|
|
|
|
// Update index and value
|
|
setHistoryIndex(newIndex);
|
|
if (newIndex === -1) {
|
|
// Restore saved input when going past the end of history
|
|
setValue(savedInput);
|
|
} else {
|
|
setValue(commandHistory[newIndex] || '');
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Handle command history navigation
|
|
if ((evt.metaKey || evt.ctrlKey) && (evt.key === 'ArrowUp' || evt.key === 'ArrowDown')) {
|
|
handleHistoryNavigation(evt);
|
|
return;
|
|
}
|
|
|
|
if (evt.key === 'Enter') {
|
|
// should not trigger submit on Enter if it's composing (IME input in progress) or shift is pressed
|
|
if (evt.shiftKey || isComposing) {
|
|
// Allow line break for Shift+Enter or during IME composition
|
|
return;
|
|
}
|
|
|
|
// Prevent default Enter behavior when loading or when not loading but has content
|
|
// So it won't trigger a new line
|
|
evt.preventDefault();
|
|
|
|
// Only submit if not loading and has content
|
|
if (!isLoading && value.trim()) {
|
|
handleSubmit(new CustomEvent('submit', { detail: { value } }));
|
|
setValue('');
|
|
setHistoryIndex(-1);
|
|
setSavedInput('');
|
|
}
|
|
}
|
|
};
|
|
|
|
const onFormSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (value.trim() && !isLoading) {
|
|
handleSubmit(new CustomEvent('submit', { detail: { value } }));
|
|
setValue('');
|
|
setHistoryIndex(-1);
|
|
setSavedInput('');
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = async () => {
|
|
const path = await window.electron.selectFileOrDirectory();
|
|
if (path) {
|
|
setValue(path);
|
|
textAreaRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={onFormSubmit}
|
|
className="flex relative h-auto px-[16px] pr-[68px] py-[1rem] border-t border-borderSubtle"
|
|
>
|
|
<textarea
|
|
autoFocus
|
|
id="dynamic-textarea"
|
|
placeholder="What can goose help with? ⌘↑/⌘↓"
|
|
value={value}
|
|
onChange={handleChange}
|
|
onCompositionStart={handleCompositionStart}
|
|
onCompositionEnd={handleCompositionEnd}
|
|
onKeyDown={handleKeyDown}
|
|
ref={textAreaRef}
|
|
rows={1}
|
|
style={{
|
|
minHeight: `${minHeight}px`,
|
|
maxHeight: `${maxHeight}px`,
|
|
overflowY: 'auto',
|
|
}}
|
|
className="w-full outline-none border-none focus:ring-0 bg-transparent p-0 text-base resize-none text-textStandard"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={handleFileSelect}
|
|
className="absolute right-[40px] top-1/2 -translate-y-1/2 text-textSubtle hover:text-textStandard"
|
|
>
|
|
<Attach />
|
|
</Button>
|
|
{isLoading ? (
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onStop();
|
|
}}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 [&_svg]:size-5 text-textSubtle hover:text-textStandard"
|
|
>
|
|
<Stop size={24} />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
size="icon"
|
|
variant="ghost"
|
|
disabled={!value.trim()}
|
|
className={`absolute right-2 top-1/2 -translate-y-1/2 text-textSubtle hover:text-textStandard ${
|
|
!value.trim() ? 'text-textSubtle cursor-not-allowed' : ''
|
|
}`}
|
|
>
|
|
<Send />
|
|
</Button>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|