mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-24 16:54:21 +01:00
feat: inline completion for command and files
This commit is contained in:
@@ -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 = " ";
|
||||
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user