Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/hooks/use-enhanced-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface EnhancedInputHook {
isMultiline: boolean;
setInput: (text: string) => void;
setCursorPosition: (position: number) => void;
setInputWithCursor: (text: string, position: number) => void;
clearInput: () => void;
insertAtCursor: (text: string) => void;
resetHistory: () => void;
Expand Down Expand Up @@ -81,6 +82,14 @@ export function useEnhancedInput({
setCursorPositionState(Math.max(0, Math.min(input.length, position)));
}, [input.length]);

const setInputWithCursor = useCallback((text: string, position: number) => {
setInputState(text);
setCursorPositionState(Math.max(0, Math.min(text.length, position)));
if (!isNavigatingHistory()) {
setOriginalInput(text);
}
}, [isNavigatingHistory, setOriginalInput]);

const clearInput = useCallback(() => {
setInputState("");
setCursorPositionState(0);
Expand Down Expand Up @@ -290,6 +299,7 @@ export function useEnhancedInput({
isMultiline: isMultilineRef.current,
setInput,
setCursorPosition,
setInputWithCursor,
clearInput,
insertAtCursor,
resetHistory,
Expand Down
134 changes: 133 additions & 1 deletion src/hooks/use-input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEnhancedInput, Key } from "./use-enhanced-input";

import { filterCommandSuggestions } from "../ui/components/command-suggestions";
import { loadModelConfig, updateCurrentModel } from "../utils/model-config";
import { FileFinder, FileSuggestion } from "../utils/file-finder";

interface UseInputHandlerProps {
agent: GrokAgent;
Expand All @@ -30,6 +31,12 @@ interface ModelOption {
model: string;
}


interface AttachedFile {
path: string;
content: string;
}

export function useInputHandler({
agent,
chatHistory,
Expand All @@ -52,6 +59,13 @@ export function useInputHandler({
const sessionFlags = confirmationService.getSessionFlags();
return sessionFlags.allOperations;
});

// File selection state
const [showFilePicker, setShowFilePicker] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
const [fileQuery, setFileQuery] = useState("");
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
const [fileFinder] = useState(() => new FileFinder());

const handleSpecialKey = (key: Key): boolean => {
// Don't handle input if confirmation dialog is active
Expand Down Expand Up @@ -87,6 +101,12 @@ export function useInputHandler({
setSelectedModelIndex(0);
return true;
}
if (showFilePicker) {
setShowFilePicker(false);
setSelectedFileIndex(0);
setFileQuery("");
return true;
}
if (isProcessing || isStreaming) {
agent.abortCurrentOperation();
setIsProcessing(false);
Expand Down Expand Up @@ -166,6 +186,40 @@ export function useInputHandler({
return true;
}
}

// Handle file picker navigation
if (showFilePicker) {
const fileSuggestions = fileFinder.getFileSuggestions(fileQuery);

if (fileSuggestions.length === 0) {
setShowFilePicker(false);
setSelectedFileIndex(0);
setFileQuery("");
return false;
}

if (key.upArrow) {
setSelectedFileIndex((prev) =>
prev === 0 ? fileSuggestions.length - 1 : prev - 1
);
return true;
}
if (key.downArrow) {
setSelectedFileIndex((prev) => (prev + 1) % fileSuggestions.length);
return true;
}
if (key.tab || key.return) {
const selectedFile = fileSuggestions[selectedFileIndex];
if (!selectedFile.isDirectory) {
// Attach file to prompt
attachFileToPrompt(selectedFile.relativePath);
}
setShowFilePicker(false);
setSelectedFileIndex(0);
setFileQuery("");
return true;
}
}

return false; // Let default handling proceed
};
Expand All @@ -189,17 +243,74 @@ export function useInputHandler({
if (newInput.startsWith("/")) {
setShowCommandSuggestions(true);
setSelectedCommandIndex(0);
setShowFilePicker(false);
setFileQuery("");
} else {
setShowCommandSuggestions(false);
setSelectedCommandIndex(0);
}

// Handle @ file selection (only the current @-token up to whitespace)
const searchPos = Math.max(0, cursorPosition - 1);
const atIndex = newInput.lastIndexOf("@", searchPos);
if (atIndex !== -1) {
const segment = newInput.slice(atIndex + 1, cursorPosition);
const containsWhitespace = /\s/.test(segment);

if (!containsWhitespace) {
setShowFilePicker(true);
setFileQuery(segment);
setSelectedFileIndex(0);
setShowCommandSuggestions(false);
return;
}
}
setShowFilePicker(false);
setFileQuery("");
};

const attachFileToPrompt = async (relativePath: string) => {
try {
const content = await fileFinder.readFileContent(relativePath);
if (content) {
const newAttachedFile: AttachedFile = {
path: relativePath,
content
};
setAttachedFiles(prev => [...prev, newAttachedFile]);

// Replace @query with @filename in input and position cursor at end
const searchPos = Math.max(0, cursorPosition - 1);
const atIndex = input.lastIndexOf("@", searchPos);
if (atIndex !== -1) {
const beforeAt = input.slice(0, atIndex);
// Find end of current token (first whitespace after '@')
const rest = input.slice(atIndex + 1);
const whitespaceRel = rest.search(/\s/);
const tokenEnd = whitespaceRel === -1 ? input.length : atIndex + 1 + whitespaceRel;

const afterQuery = input.slice(tokenEnd);
const insertion = `@${relativePath} `;
const newInput = beforeAt + insertion + afterQuery;
const newCursor = beforeAt.length + insertion.length;
setInputWithCursor(newInput, newCursor);
}
}
} catch (error) {
console.error("Error attaching file:", error);
}
};

const removeAttachedFile = (pathToRemove: string) => {
setAttachedFiles(prev => prev.filter(file => file.path !== pathToRemove));
};

const {
input,
cursorPosition,
setInput,
setCursorPosition,
setInputWithCursor,
clearInput,
resetHistory,
handleInput,
Expand Down Expand Up @@ -608,12 +719,26 @@ Respond with ONLY the commit message, no additional text.`;
};

const processUserMessage = async (userInput: string) => {
// Display user input without file content in chat history
const userEntry: ChatEntry = {
type: "user",
content: userInput,
timestamp: new Date(),
};
setChatHistory((prev) => [...prev, userEntry]);

// Prepare the actual message content with attached files for AI processing
let messageContent = userInput;

if (attachedFiles.length > 0) {
messageContent += "\n\n--- Attached Files ---\n";
for (const file of attachedFiles) {
messageContent += `\n**File: ${file.path}**\n\`\`\`\n${file.content}\n\`\`\`\n`;
}
}

// Clear attached files after processing
setAttachedFiles([]);

setIsProcessing(true);
clearInput();
Expand All @@ -622,7 +747,7 @@ Respond with ONLY the commit message, no additional text.`;
setIsStreaming(true);
let streamingEntry: ChatEntry | null = null;

for await (const chunk of agent.processUserMessageStream(userInput)) {
for await (const chunk of agent.processUserMessageStream(messageContent)) {
switch (chunk.type) {
case "content":
if (chunk.content) {
Expand Down Expand Up @@ -748,5 +873,12 @@ Respond with ONLY the commit message, no additional text.`;
availableModels,
agent,
autoEditEnabled,
// File picker state
showFilePicker,
selectedFileIndex,
fileQuery,
attachedFiles,
fileSuggestions: fileFinder.getFileSuggestions(fileQuery),
removeAttachedFile,
};
}
24 changes: 21 additions & 3 deletions src/ui/components/chat-interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CommandSuggestions } from "./command-suggestions";
import { ModelSelection } from "./model-selection";
import { ChatHistory } from "./chat-history";
import { ChatInput } from "./chat-input";
import { FilePicker } from "./file-picker";
import { MCPStatus } from "./mcp-status";
import ConfirmationDialog from "./confirmation-dialog";
import {
Expand Down Expand Up @@ -44,6 +45,13 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) {
commandSuggestions,
availableModels,
autoEditEnabled,
// File picker state
showFilePicker,
selectedFileIndex,
fileQuery,
attachedFiles,
fileSuggestions,
removeAttachedFile,
} = useInputHandler({
agent,
chatHistory,
Expand Down Expand Up @@ -166,12 +174,15 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) {
</Text>
<Text color="gray">2. Be specific for the best results.</Text>
<Text color="gray">
3. Create GROK.md files to customize your interactions with Grok.
3. Use @ to attach files to your prompt (e.g., "@package.json").
</Text>
<Text color="gray">
4. Press Shift+Tab to toggle auto-edit mode.
4. Create GROK.md files to customize your interactions with Grok.
</Text>
<Text color="gray">5. /help for more information.</Text>
<Text color="gray">
5. Press Shift+Tab to toggle auto-edit mode.
</Text>
<Text color="gray">6. /help for more information.</Text>
</Box>
</Box>
)}
Expand Down Expand Up @@ -243,6 +254,13 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) {
isVisible={showModelSelection}
currentModel={agent.getCurrentModel()}
/>

<FilePicker
suggestions={fileSuggestions}
selectedIndex={selectedFileIndex}
query={fileQuery}
isVisible={showFilePicker}
/>
</>
)}
</Box>
Expand Down
92 changes: 92 additions & 0 deletions src/ui/components/file-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from "react";
import { Box, Text } from "ink";
import path from "path";
import { FileSuggestion } from "../../utils/file-finder";

interface FilePickerProps {
suggestions: FileSuggestion[];
selectedIndex: number;
query: string;
isVisible: boolean;
}

export function FilePicker({
suggestions,
selectedIndex,
query,
isVisible,
}: FilePickerProps) {
if (!isVisible || suggestions.length === 0) {
return null;
}

const maxVisible = 8;
const startIndex = Math.max(0, selectedIndex - Math.floor(maxVisible / 2));
const endIndex = Math.min(suggestions.length, startIndex + maxVisible);
const adjustedStartIndex = Math.max(0, endIndex - maxVisible);

const visibleSuggestions = suggestions.slice(adjustedStartIndex, endIndex);
const adjustedSelectedIndex = selectedIndex - adjustedStartIndex;

return (
<Box
borderStyle="round"
borderColor="blue"
flexDirection="column"
paddingX={1}
marginTop={1}
marginBottom={1}
>
<Box marginBottom={1}>
<Text color="cyan" bold>
File Selection {query && `(${query})`}
</Text>
{suggestions.length > maxVisible && (
<Text color="gray" dimColor>
{selectedIndex + 1}/{suggestions.length} files
</Text>
)}
</Box>

{adjustedStartIndex > 0 && (
<Box>
<Text color="gray" dimColor>
↑ {adjustedStartIndex} more above
</Text>
</Box>
)}

{visibleSuggestions.map((suggestion, index) => {
const isSelected = index === adjustedSelectedIndex;
const displayPath = suggestion.isDirectory ? `${suggestion.relativePath}/` : suggestion.relativePath;

return (
<Box key={suggestion.relativePath}>
<Text
color={isSelected ? "black" : "white"}
backgroundColor={isSelected ? "blue" : undefined}
bold={isSelected}
>
{isSelected ? "❯ " : " "}
{displayPath}
</Text>
</Box>
);
})}

{endIndex < suggestions.length && (
<Box>
<Text color="gray" dimColor>
↓ {suggestions.length - endIndex} more below
</Text>
</Box>
)}

<Box marginTop={1}>
<Text color="gray" dimColor>
↑/↓ Navigate • Tab/Enter Select • Esc Cancel
</Text>
</Box>
</Box>
);
}
Loading