feat: inline completion for command and files

This commit is contained in:
d-kimsuon
2025-09-07 03:43:32 +09:00
parent 1e31eb4307
commit e90dc00a63
4 changed files with 268 additions and 55 deletions

View File

@@ -1,12 +1,10 @@
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { type FC, useId, useRef, useState } from "react";
import { type FC, useCallback, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea";
import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
import type { CommandCompletionRef } from "./CommandCompletion";
import type { FileCompletionRef } from "./FileCompletion";
import { InlineCompletion } from "./InlineCompletion";
export interface ChatInputProps {
projectId: string;
@@ -33,8 +31,14 @@ export const ChatInput: FC<ChatInputProps> = ({
disabled = false,
buttonSize = "lg",
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState("");
const [cursorPosition, setCursorPosition] = useState<{
relative: { top: number; left: number };
absolute: { top: number; left: number };
}>({ relative: { top: 0, left: 0 }, absolute: { top: 0, left: 0 } });
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const commandCompletionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(null);
const helpId = useId();
@@ -60,6 +64,65 @@ export const ChatInput: FC<ChatInputProps> = ({
}
};
const getCursorPosition = useCallback(() => {
const textarea = textareaRef.current;
const container = containerRef.current;
if (textarea === null || container === null) return undefined;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = textarea.value.substring(0, cursorPos);
const textAfterCursor = textarea.value.substring(cursorPos);
const pre = document.createTextNode(textBeforeCursor);
const post = document.createTextNode(textAfterCursor);
const caret = document.createElement("span");
caret.innerHTML = "&nbsp;";
const mirrored = document.createElement("div");
mirrored.innerHTML = "";
mirrored.append(pre, caret, post);
const textareaStyles = window.getComputedStyle(textarea);
for (const property of [
"border",
"boxSizing",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"padding",
"textDecoration",
"textIndent",
"textTransform",
"whiteSpace",
"wordSpacing",
"wordWrap",
] as const) {
mirrored.style[property] = textareaStyles[property];
}
mirrored.style.visibility = "hidden";
container.prepend(mirrored);
const caretRect = caret.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
container.removeChild(mirrored);
return {
relative: {
top: caretRect.top - containerRect.top - textarea.scrollTop,
left: caretRect.left - containerRect.left - textarea.scrollLeft,
},
absolute: {
top: caretRect.top - textarea.scrollTop,
left: caretRect.left - textarea.scrollLeft,
},
};
}, []);
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
@@ -80,11 +143,23 @@ export const ChatInput: FC<ChatInputProps> = ({
)}
<div className="space-y-3">
<div className="relative">
<div className="relative" ref={containerRef}>
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onChange={(e) => {
if (
e.target.value.endsWith("@") ||
e.target.value.endsWith("/")
) {
const position = getCursorPosition();
if (position) {
setCursorPosition(position);
}
}
setMessage(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`${minHeight} resize-none`}
@@ -97,22 +172,15 @@ export const ChatInput: FC<ChatInputProps> = ({
role="combobox"
aria-autocomplete="list"
/>
<CommandCompletion
ref={commandCompletionRef}
<InlineCompletion
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
message={message}
commandCompletionRef={commandCompletionRef}
fileCompletionRef={fileCompletionRef}
handleCommandSelect={handleCommandSelect}
handleFileSelect={handleFileSelect}
cursorPosition={cursorPosition}
/>
{
<FileCompletion
ref={fileCompletionRef}
projectId={projectId}
inputValue={message}
onFileSelect={handleFileSelect}
className="absolute top-full left-0 right-0"
/>
}
</div>
<div className="flex items-center justify-between">

View File

@@ -93,19 +93,19 @@ export const CommandCompletion = forwardRef<
);
// スクロール処理
const scrollToSelected = useCallback(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[
selectedIndex + 1
] as HTMLElement; // +1 for header
if (selectedElement) {
selectedElement.scrollIntoView({
const scrollToSelected = useCallback((index: number) => {
if (index >= 0 && listRef.current) {
// ボタン要素を直接検索
const buttons = listRef.current.querySelectorAll('button[role="option"]');
const selectedButton = buttons[index] as HTMLElement;
if (selectedButton) {
selectedButton.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, [selectedIndex]);
}, []);
// メモ化されたキーボードナビゲーション処理
const handleKeyboardNavigation = useCallback(
@@ -117,8 +117,8 @@ export const CommandCompletion = forwardRef<
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredCommands.length - 1 ? prev + 1 : 0;
// スクロールを次のタイクで実行
setTimeout(scrollToSelected, 0);
// スクロールを次のフレームで実行
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
@@ -126,8 +126,8 @@ export const CommandCompletion = forwardRef<
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredCommands.length - 1;
// スクロールを次のタイクで実行
setTimeout(scrollToSelected, 0);
// スクロールを次のフレームで実行
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
@@ -214,7 +214,7 @@ export const CommandCompletion = forwardRef<
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2",
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
@@ -223,11 +223,16 @@ export const CommandCompletion = forwardRef<
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
>
<span className="text-muted-foreground mr-1">/</span>
<span className="font-medium">{command}</span>
<span className="text-muted-foreground mr-1 flex-shrink-0">
/
</span>
<span className="font-medium truncate min-w-0">
{command}
</span>
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary" />
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}

View File

@@ -148,19 +148,19 @@ export const FileCompletion = forwardRef<
);
// Scroll to selected entry
const scrollToSelected = useCallback(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[
selectedIndex + 1
] as HTMLElement; // +1 for header
if (selectedElement) {
selectedElement.scrollIntoView({
const scrollToSelected = useCallback((index: number) => {
if (index >= 0 && listRef.current) {
// ボタン要素を直接検索
const buttons = listRef.current.querySelectorAll('button[role="option"]');
const selectedButton = buttons[index] as HTMLElement;
if (selectedButton) {
selectedButton.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, [selectedIndex]);
}, []);
// Keyboard navigation
const handleKeyboardNavigation = useCallback(
@@ -172,7 +172,7 @@ export const FileCompletion = forwardRef<
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredEntries.length - 1 ? prev + 1 : 0;
setTimeout(scrollToSelected, 0);
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
@@ -180,7 +180,7 @@ export const FileCompletion = forwardRef<
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredEntries.length - 1;
setTimeout(scrollToSelected, 0);
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
@@ -283,7 +283,7 @@ export const FileCompletion = forwardRef<
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2",
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
@@ -294,18 +294,23 @@ export const FileCompletion = forwardRef<
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
title={entry.path}
>
{entry.type === "directory" ? (
<FolderIcon className="w-3 h-3 mr-2 text-blue-500" />
<FolderIcon className="w-3 h-3 mr-2 text-blue-500 flex-shrink-0" />
) : (
<FileIcon className="w-3 h-3 mr-2 text-gray-500" />
<FileIcon className="w-3 h-3 mr-2 text-gray-500 flex-shrink-0" />
)}
<span className="font-medium">{entry.name}</span>
<span className="font-medium truncate min-w-0">
{entry.name}
</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1">/</span>
<span className="text-muted-foreground ml-1 flex-shrink-0">
/
</span>
)}
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary" />
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}

View File

@@ -0,0 +1,135 @@
import type { FC, RefObject } from "react";
import { useMemo } from "react";
import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
interface PositionStyle {
top: number;
left: number;
placement: "above" | "below";
}
const calculateOptimalPosition = (
relativeCursorPosition: { top: number; left: number },
absoluteCursorPosition: { top: number; left: number },
): PositionStyle => {
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 800;
const viewportCenter = viewportHeight / 2;
// Estimated completion height (we'll measure actual height later if needed)
const estimatedCompletionHeight = 200;
// Determine preferred placement based on viewport position
console.log("debug cursor and viewport", {
absoluteCursorTop: absoluteCursorPosition.top,
viewportCenter,
});
const isInUpperHalf = absoluteCursorPosition.top < viewportCenter;
// Check if there's enough space for preferred placement
const spaceBelow = viewportHeight - absoluteCursorPosition.top;
const spaceAbove = absoluteCursorPosition.top;
let placement: "above" | "below";
let top: number;
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight) {
// Cursor in upper half and enough space below - place below
placement = "below";
top = relativeCursorPosition.top + 16;
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight) {
// Cursor in lower half and enough space above - place above
placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
} else {
// Use whichever side has more space
if (spaceBelow > spaceAbove) {
placement = "below";
top = relativeCursorPosition.top + 16;
} else {
placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
}
}
// Ensure left position stays within viewport bounds
const estimatedCompletionWidth = 512; // Current w-lg width
const viewportWidth =
typeof window !== "undefined" ? window.innerWidth : 1200;
const maxLeft = viewportWidth - estimatedCompletionWidth - 16;
const adjustedLeft = Math.max(
16,
Math.min(relativeCursorPosition.left - 16, maxLeft),
);
return {
top,
left: adjustedLeft,
placement,
};
};
export const InlineCompletion: FC<{
projectId: string;
message: string;
commandCompletionRef: RefObject<CommandCompletionRef | null>;
fileCompletionRef: RefObject<FileCompletionRef | null>;
handleCommandSelect: (command: string) => void;
handleFileSelect: (filePath: string) => void;
cursorPosition: {
relative: { top: number; left: number };
absolute: { top: number; left: number };
};
}> = ({
projectId,
message,
commandCompletionRef,
fileCompletionRef,
handleCommandSelect,
handleFileSelect,
cursorPosition,
}) => {
const position = useMemo(() => {
return calculateOptimalPosition(
cursorPosition.relative,
cursorPosition.absolute,
);
}, [cursorPosition]);
return (
<div
className="absolute w-full max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl"
style={{
top: position.top,
left: position.left,
maxWidth:
typeof window !== "undefined"
? Math.min(512, window.innerWidth * 0.8)
: 512,
}}
>
<CommandCompletion
ref={commandCompletionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className={`absolute left-0 right-0 ${
position.placement === "above" ? "bottom-full mb-2" : "top-full mt-1"
}`}
/>
<FileCompletion
ref={fileCompletionRef}
projectId={projectId}
inputValue={message}
onFileSelect={handleFileSelect}
className={`absolute left-0 right-0 ${
position.placement === "above" ? "bottom-full mb-2" : "top-full mt-1"
}`}
/>
</div>
);
};