From ff827e2eb49b07dc0930ae8eea207adae00b382a Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Mon, 4 Aug 2025 22:11:55 +0200 Subject: [PATCH] minor tweaks --- src/agent/grok-agent.ts | 50 ++-- src/grok/tools.ts | 5 +- src/hooks/use-enhanced-input.ts | 298 +++++++++++++++++++++ src/hooks/use-input-handler.ts | 374 +++++++++++++-------------- src/hooks/use-input-history.ts | 75 ++++++ src/index.ts | 19 +- src/mcp/client.ts | 7 +- src/ui/components/chat-input.tsx | 118 ++++++++- src/ui/components/chat-interface.tsx | 20 +- src/utils/text-utils.ts | 221 ++++++++++++++++ 10 files changed, 940 insertions(+), 247 deletions(-) create mode 100644 src/hooks/use-enhanced-input.ts create mode 100644 src/hooks/use-input-history.ts create mode 100644 src/utils/text-utils.ts diff --git a/src/agent/grok-agent.ts b/src/agent/grok-agent.ts index ea3201f..f21d052 100644 --- a/src/agent/grok-agent.ts +++ b/src/agent/grok-agent.ts @@ -55,7 +55,12 @@ export class GrokAgent extends EventEmitter { private mcpInitialized: boolean = false; private maxToolRounds: number; - constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number) { + constructor( + apiKey: string, + baseURL?: string, + model?: string, + maxToolRounds?: number + ) { super(); const manager = getSettingsManager(); const savedModel = manager.getCurrentModel(); @@ -87,7 +92,11 @@ export class GrokAgent extends EventEmitter { You have access to these tools: - view_file: View file contents or directory listings - create_file: Create new files with content (ONLY use this for files that don't exist yet) -- str_replace_editor: Replace text in existing files (ALWAYS use this to edit or update existing files)${this.morphEditor ? '\n- edit_file: High-speed file editing with Morph Fast Apply (4,500+ tokens/sec with 98% accuracy)' : ''} +- str_replace_editor: Replace text in existing files (ALWAYS use this to edit or update existing files)${ + this.morphEditor + ? "\n- edit_file: High-speed file editing with Morph Fast Apply (4,500+ tokens/sec with 98% accuracy)" + : "" + } - bash: Execute bash commands (use for searching, file discovery, navigation, and system operations) - search: Unified search tool for finding text content or files (similar to Cursor's search functionality) - create_todo_list: Create a visual todo list for planning and tracking tasks @@ -143,26 +152,19 @@ Current working directory: ${process.cwd()}`, } private async initializeMCP(): Promise { - try { - const config = loadMCPConfig(); - if (config.servers.length > 0) { - console.log( - `Found ${config.servers.length} MCP server(s) - connecting now...` - ); - await initializeMCPServers(); - console.log(`Successfully connected to MCP servers`); + // Initialize MCP in the background without blocking + Promise.resolve().then(async () => { + try { + const config = loadMCPConfig(); + if (config.servers.length > 0) { + await initializeMCPServers(); + } + } catch (error) { + console.warn('MCP initialization failed:', error); + } finally { + this.mcpInitialized = true; } - this.mcpInitialized = true; - } catch (error) { - console.warn("Failed to initialize MCP servers:", error); - this.mcpInitialized = true; // Don't block if MCP fails - } - } - - private async waitForMCPInitialization(): Promise { - while (!this.mcpInitialized) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } + }); } private isGrokModel(): boolean { @@ -171,9 +173,6 @@ Current working directory: ${process.cwd()}`, } async processUserMessage(message: string): Promise { - // Wait for MCP initialization before processing - await this.waitForMCPInitialization(); - // Add user message to conversation const userEntry: ChatEntry = { type: "user", @@ -625,7 +624,8 @@ Current working directory: ${process.cwd()}`, if (!this.morphEditor) { return { success: false, - error: "Morph Fast Apply not available. Please set MORPH_API_KEY environment variable to use this feature.", + error: + "Morph Fast Apply not available. Please set MORPH_API_KEY environment variable to use this feature.", }; } return await this.morphEditor.editFile( diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 809f1c9..2b9a6e4 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -367,6 +367,9 @@ export function addMCPToolsToGrokTools(baseTools: GrokTool[]): GrokTool[] { export async function getAllGrokTools(): Promise { const manager = getMCPManager(); - await manager.ensureServersInitialized(); + // Try to initialize servers if not already done, but don't block + manager.ensureServersInitialized().catch(() => { + // Ignore initialization errors to avoid blocking + }); return addMCPToolsToGrokTools(GROK_TOOLS); } diff --git a/src/hooks/use-enhanced-input.ts b/src/hooks/use-enhanced-input.ts new file mode 100644 index 0000000..299f27d --- /dev/null +++ b/src/hooks/use-enhanced-input.ts @@ -0,0 +1,298 @@ +import { useState, useCallback, useRef } from "react"; +import { + deleteCharBefore, + deleteCharAfter, + deleteWordBefore, + deleteWordAfter, + insertText, + moveToLineStart, + moveToLineEnd, + moveToPreviousWord, + moveToNextWord, +} from "../utils/text-utils"; +import { useInputHistory } from "./use-input-history"; + +export interface Key { + name?: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + paste?: boolean; + sequence?: string; + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + return?: boolean; + escape?: boolean; + tab?: boolean; + backspace?: boolean; + delete?: boolean; +} + +export interface EnhancedInputHook { + input: string; + cursorPosition: number; + isMultiline: boolean; + setInput: (text: string) => void; + setCursorPosition: (position: number) => void; + clearInput: () => void; + insertAtCursor: (text: string) => void; + resetHistory: () => void; + handleInput: (inputChar: string, key: Key) => void; +} + +interface UseEnhancedInputProps { + onSubmit?: (text: string) => void; + onEscape?: () => void; + onSpecialKey?: (key: Key) => boolean; // Return true to prevent default handling + disabled?: boolean; + multiline?: boolean; +} + +export function useEnhancedInput({ + onSubmit, + onEscape, + onSpecialKey, + disabled = false, + multiline = false, +}: UseEnhancedInputProps = {}): EnhancedInputHook { + const [input, setInputState] = useState(""); + const [cursorPosition, setCursorPositionState] = useState(0); + const isMultilineRef = useRef(multiline); + + const { + addToHistory, + navigateHistory, + resetHistory, + setOriginalInput, + isNavigatingHistory, + } = useInputHistory(); + + const setInput = useCallback((text: string) => { + setInputState(text); + setCursorPositionState(Math.min(text.length, cursorPosition)); + if (!isNavigatingHistory()) { + setOriginalInput(text); + } + }, [cursorPosition, isNavigatingHistory, setOriginalInput]); + + const setCursorPosition = useCallback((position: number) => { + setCursorPositionState(Math.max(0, Math.min(input.length, position))); + }, [input.length]); + + const clearInput = useCallback(() => { + setInputState(""); + setCursorPositionState(0); + setOriginalInput(""); + }, [setOriginalInput]); + + const insertAtCursor = useCallback((text: string) => { + const result = insertText(input, cursorPosition, text); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + }, [input, cursorPosition, setOriginalInput]); + + const handleSubmit = useCallback(() => { + if (input.trim()) { + addToHistory(input); + onSubmit?.(input); + clearInput(); + } + }, [input, addToHistory, onSubmit, clearInput]); + + const handleInput = useCallback((inputChar: string, key: Key) => { + if (disabled) return; + + // Handle Ctrl+C - check multiple ways it could be detected + if ((key.ctrl && inputChar === "c") || inputChar === "\x03") { + setInputState(""); + setCursorPositionState(0); + setOriginalInput(""); + return; + } + + // Allow special key handler to override default behavior + if (onSpecialKey?.(key)) { + return; + } + + // Handle Escape + if (key.escape) { + onEscape?.(); + return; + } + + // Handle Enter/Return + if (key.return) { + if (multiline && key.shift) { + // Shift+Enter in multiline mode inserts newline + const result = insertText(input, cursorPosition, "\n"); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + } else { + handleSubmit(); + } + return; + } + + // Handle history navigation + if ((key.upArrow || key.name === 'up') && !key.ctrl && !key.meta) { + const historyInput = navigateHistory("up"); + if (historyInput !== null) { + setInputState(historyInput); + setCursorPositionState(historyInput.length); + } + return; + } + + if ((key.downArrow || key.name === 'down') && !key.ctrl && !key.meta) { + const historyInput = navigateHistory("down"); + if (historyInput !== null) { + setInputState(historyInput); + setCursorPositionState(historyInput.length); + } + return; + } + + // Handle cursor movement - ignore meta flag for arrows as it's unreliable in terminals + // Only do word movement if ctrl is pressed AND no arrow escape sequence is in inputChar + if ((key.leftArrow || key.name === 'left') && key.ctrl && !inputChar.includes('[')) { + const newPos = moveToPreviousWord(input, cursorPosition); + setCursorPositionState(newPos); + return; + } + + if ((key.rightArrow || key.name === 'right') && key.ctrl && !inputChar.includes('[')) { + const newPos = moveToNextWord(input, cursorPosition); + setCursorPositionState(newPos); + return; + } + + // Handle regular cursor movement - single character (ignore meta flag) + if (key.leftArrow || key.name === 'left') { + const newPos = Math.max(0, cursorPosition - 1); + setCursorPositionState(newPos); + return; + } + + if (key.rightArrow || key.name === 'right') { + const newPos = Math.min(input.length, cursorPosition + 1); + setCursorPositionState(newPos); + return; + } + + // Handle Home/End keys or Ctrl+A/E + if ((key.ctrl && inputChar === "a") || key.name === "home") { + setCursorPositionState(0); // Simple start of input + return; + } + + if ((key.ctrl && inputChar === "e") || key.name === "end") { + setCursorPositionState(input.length); // Simple end of input + return; + } + + // Handle deletion - check multiple ways backspace might be detected + // Backspace can be detected in different ways depending on terminal + // In some terminals, backspace shows up as delete:true with empty inputChar + const isBackspace = key.backspace || + key.name === 'backspace' || + inputChar === '\b' || + inputChar === '\x7f' || + (key.delete && inputChar === '' && !key.shift); + + if (isBackspace) { + if (key.ctrl || key.meta) { + // Ctrl/Cmd + Backspace: Delete word before cursor + const result = deleteWordBefore(input, cursorPosition); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + } else { + // Regular backspace + const result = deleteCharBefore(input, cursorPosition); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + } + return; + } + + // Handle forward delete (Del key) - but not if it was already handled as backspace above + if ((key.delete && inputChar !== '') || (key.ctrl && inputChar === "d")) { + if (key.ctrl || key.meta) { + // Ctrl/Cmd + Delete: Delete word after cursor + const result = deleteWordAfter(input, cursorPosition); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + } else { + // Regular delete + const result = deleteCharAfter(input, cursorPosition); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + } + return; + } + + // Handle Ctrl+K: Delete from cursor to end of line + if (key.ctrl && inputChar === "k") { + const lineEnd = moveToLineEnd(input, cursorPosition); + const newText = input.slice(0, cursorPosition) + input.slice(lineEnd); + setInputState(newText); + setOriginalInput(newText); + return; + } + + // Handle Ctrl+U: Delete from cursor to start of line + if (key.ctrl && inputChar === "u") { + const lineStart = moveToLineStart(input, cursorPosition); + const newText = input.slice(0, lineStart) + input.slice(cursorPosition); + setInputState(newText); + setCursorPositionState(lineStart); + setOriginalInput(newText); + return; + } + + // Handle Ctrl+W: Delete word before cursor + if (key.ctrl && inputChar === "w") { + const result = deleteWordBefore(input, cursorPosition); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + return; + } + + // Handle Ctrl+X: Clear entire input + if (key.ctrl && inputChar === "x") { + setInputState(""); + setCursorPositionState(0); + setOriginalInput(""); + return; + } + + // Handle regular character input + if (inputChar && !key.ctrl && !key.meta) { + const result = insertText(input, cursorPosition, inputChar); + setInputState(result.text); + setCursorPositionState(result.position); + setOriginalInput(result.text); + } + }, [disabled, onSpecialKey, input, cursorPosition, multiline, handleSubmit, navigateHistory, setOriginalInput]); + + return { + input, + cursorPosition, + isMultiline: isMultilineRef.current, + setInput, + setCursorPosition, + clearInput, + insertAtCursor, + resetHistory, + handleInput, + }; +} \ No newline at end of file diff --git a/src/hooks/use-input-handler.ts b/src/hooks/use-input-handler.ts index e9d1948..847d137 100644 --- a/src/hooks/use-input-handler.ts +++ b/src/hooks/use-input-handler.ts @@ -1,7 +1,8 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useInput } from "ink"; import { GrokAgent, ChatEntry } from "../agent/grok-agent"; import { ConfirmationService } from "../utils/confirmation-service"; +import { useEnhancedInput, Key } from "./use-enhanced-input"; import { filterCommandSuggestions } from "../ui/components/command-suggestions"; import { loadModelConfig, updateCurrentModel } from "../utils/model-config"; @@ -42,8 +43,6 @@ export function useInputHandler({ isStreaming, isConfirmationActive = false, }: UseInputHandlerProps) { - const [input, setInput] = useState(""); - const [cursorPosition, setCursorPosition] = useState(0); const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [showModelSelection, setShowModelSelection] = useState(false); @@ -53,7 +52,172 @@ export function useInputHandler({ const sessionFlags = confirmationService.getSessionFlags(); return sessionFlags.allOperations; }); - // Removed useApp().exit - using process.exit(0) instead for better terminal handling + + const handleSpecialKey = (key: Key): boolean => { + // Don't handle input if confirmation dialog is active + if (isConfirmationActive) { + return true; // Prevent default handling + } + + // Handle shift+tab to toggle auto-edit mode + if (key.shift && key.tab) { + const newAutoEditState = !autoEditEnabled; + setAutoEditEnabled(newAutoEditState); + + const confirmationService = ConfirmationService.getInstance(); + if (newAutoEditState) { + // Enable auto-edit: set all operations to be accepted + confirmationService.setSessionFlag("allOperations", true); + } else { + // Disable auto-edit: reset session flags + confirmationService.resetSession(); + } + return true; // Handled + } + + // Handle escape key for closing menus + if (key.escape) { + if (showCommandSuggestions) { + setShowCommandSuggestions(false); + setSelectedCommandIndex(0); + return true; + } + if (showModelSelection) { + setShowModelSelection(false); + setSelectedModelIndex(0); + return true; + } + if (isProcessing || isStreaming) { + agent.abortCurrentOperation(); + setIsProcessing(false); + setIsStreaming(false); + setTokenCount(0); + setProcessingTime(0); + processingStartTime.current = 0; + return true; + } + return false; // Let default escape handling work + } + + // Handle command suggestions navigation + if (showCommandSuggestions) { + const filteredSuggestions = filterCommandSuggestions( + commandSuggestions, + input + ); + + if (filteredSuggestions.length === 0) { + setShowCommandSuggestions(false); + setSelectedCommandIndex(0); + return false; // Continue processing + } else { + if (key.upArrow) { + setSelectedCommandIndex((prev) => + prev === 0 ? filteredSuggestions.length - 1 : prev - 1 + ); + return true; + } + if (key.downArrow) { + setSelectedCommandIndex( + (prev) => (prev + 1) % filteredSuggestions.length + ); + return true; + } + if (key.tab || key.return) { + const safeIndex = Math.min( + selectedCommandIndex, + filteredSuggestions.length - 1 + ); + const selectedCommand = filteredSuggestions[safeIndex]; + const newInput = selectedCommand.command + " "; + setInput(newInput); + setCursorPosition(newInput.length); + setShowCommandSuggestions(false); + setSelectedCommandIndex(0); + return true; + } + } + } + + // Handle model selection navigation + if (showModelSelection) { + if (key.upArrow) { + setSelectedModelIndex((prev) => + prev === 0 ? availableModels.length - 1 : prev - 1 + ); + return true; + } + if (key.downArrow) { + setSelectedModelIndex((prev) => (prev + 1) % availableModels.length); + return true; + } + if (key.tab || key.return) { + const selectedModel = availableModels[selectedModelIndex]; + agent.setModel(selectedModel.model); + updateCurrentModel(selectedModel.model); + const confirmEntry: ChatEntry = { + type: "assistant", + content: `✓ Switched to model: ${selectedModel.model}`, + timestamp: new Date(), + }; + setChatHistory((prev) => [...prev, confirmEntry]); + setShowModelSelection(false); + setSelectedModelIndex(0); + return true; + } + } + + return false; // Let default handling proceed + }; + + const handleInputSubmit = async (userInput: string) => { + if (userInput === "exit" || userInput === "quit") { + process.exit(0); + return; + } + + if (userInput.trim()) { + const directCommandResult = await handleDirectCommand(userInput); + if (!directCommandResult) { + await processUserMessage(userInput); + } + } + }; + + const handleInputChange = (newInput: string) => { + // Update command suggestions based on input + if (newInput.startsWith("/")) { + setShowCommandSuggestions(true); + setSelectedCommandIndex(0); + } else { + setShowCommandSuggestions(false); + setSelectedCommandIndex(0); + } + }; + + const { + input, + cursorPosition, + setInput, + setCursorPosition, + clearInput, + resetHistory, + handleInput, + } = useEnhancedInput({ + onSubmit: handleInputSubmit, + onSpecialKey: handleSpecialKey, + disabled: isConfirmationActive, + }); + + // Hook up the actual input handling + useInput((inputChar: string, key: Key) => { + handleInput(inputChar, key); + }); + + // Update command suggestions when input changes + useEffect(() => { + handleInputChange(input); + }, [input]); const commandSuggestions: CommandSuggestion[] = [ { command: "/help", description: "Show help information" }, @@ -86,8 +250,8 @@ export function useInputHandler({ const confirmationService = ConfirmationService.getInstance(); confirmationService.resetSession(); - setInput(""); - setCursorPosition(0); + clearInput(); + resetHistory(); return true; } @@ -106,7 +270,14 @@ Built-in Commands: Git Commands: /commit-and-push - AI-generated commit + push to remote -Keyboard Shortcuts: +Enhanced Input Features: + ↑/↓ Arrow - Navigate command history + Ctrl+C - Clear input (press twice to exit) + Ctrl+←/→ - Move by word + Ctrl+A/E - Move to line start/end + Ctrl+W - Delete word before cursor + Ctrl+K - Delete to end of line + Ctrl+U - Delete to start of line Shift+Tab - Toggle auto-edit mode (bypass confirmations) Direct Commands (executed immediately): @@ -128,8 +299,7 @@ Examples: timestamp: new Date(), }; setChatHistory((prev) => [...prev, helpEntry]); - setInput(""); - setCursorPosition(0); + clearInput(); return true; } @@ -141,8 +311,7 @@ Examples: if (trimmedInput === "/models") { setShowModelSelection(true); setSelectedModelIndex(0); - setInput(""); - setCursorPosition(0); + clearInput(); return true; } @@ -170,8 +339,7 @@ Available models: ${modelNames.join(", ")}`, setChatHistory((prev) => [...prev, errorEntry]); } - setInput(""); - setCursorPosition(0); + clearInput(); return true; } @@ -375,8 +543,7 @@ Respond with ONLY the commit message, no additional text.`; setIsProcessing(false); setIsStreaming(false); - setInput(""); - setCursorPosition(0); + clearInput(); return true; } @@ -433,8 +600,7 @@ Respond with ONLY the commit message, no additional text.`; setChatHistory((prev) => [...prev, errorEntry]); } - setInput(""); - setCursorPosition(0); + clearInput(); return true; } @@ -450,8 +616,7 @@ Respond with ONLY the commit message, no additional text.`; setChatHistory((prev) => [...prev, userEntry]); setIsProcessing(true); - setInput(""); - setCursorPosition(0); + clearInput(); try { setIsStreaming(true); @@ -571,177 +736,6 @@ Respond with ONLY the commit message, no additional text.`; processingStartTime.current = 0; }; - useInput(async (inputChar: string, key: any) => { - // Don't handle input if confirmation dialog is active - if (isConfirmationActive) { - return; - } - - if (key.ctrl && inputChar === "c") { - process.exit(0); - return; - } - - // Handle shift+tab to toggle auto-edit mode - if (key.shift && key.tab) { - const newAutoEditState = !autoEditEnabled; - setAutoEditEnabled(newAutoEditState); - - const confirmationService = ConfirmationService.getInstance(); - if (newAutoEditState) { - // Enable auto-edit: set all operations to be accepted - confirmationService.setSessionFlag("allOperations", true); - } else { - // Disable auto-edit: reset session flags - confirmationService.resetSession(); - } - - return; - } - - if (key.escape) { - if (showCommandSuggestions) { - setShowCommandSuggestions(false); - setSelectedCommandIndex(0); - return; - } - if (showModelSelection) { - setShowModelSelection(false); - setSelectedModelIndex(0); - return; - } - if (isProcessing || isStreaming) { - agent.abortCurrentOperation(); - setIsProcessing(false); - setIsStreaming(false); - setTokenCount(0); - setProcessingTime(0); - processingStartTime.current = 0; - return; - } - } - - if (showCommandSuggestions) { - const filteredSuggestions = filterCommandSuggestions( - commandSuggestions, - input - ); - - if (filteredSuggestions.length === 0) { - setShowCommandSuggestions(false); - setSelectedCommandIndex(0); - // Continue processing the input instead of returning - } else { - if (key.upArrow) { - setSelectedCommandIndex((prev) => - prev === 0 ? filteredSuggestions.length - 1 : prev - 1 - ); - return; - } - if (key.downArrow) { - setSelectedCommandIndex( - (prev) => (prev + 1) % filteredSuggestions.length - ); - return; - } - if (key.tab || key.return) { - const safeIndex = Math.min( - selectedCommandIndex, - filteredSuggestions.length - 1 - ); - const selectedCommand = filteredSuggestions[safeIndex]; - const newInput = selectedCommand.command + " "; - setInput(newInput); - setCursorPosition(newInput.length); - setShowCommandSuggestions(false); - setSelectedCommandIndex(0); - return; - } - } - } - - if (showModelSelection) { - if (key.upArrow) { - setSelectedModelIndex((prev) => - prev === 0 ? availableModels.length - 1 : prev - 1 - ); - return; - } - if (key.downArrow) { - setSelectedModelIndex((prev) => (prev + 1) % availableModels.length); - return; - } - if (key.tab || key.return) { - const selectedModel = availableModels[selectedModelIndex]; - agent.setModel(selectedModel.model); - updateCurrentModel(selectedModel.model); // Update project current model - const confirmEntry: ChatEntry = { - type: "assistant", - content: `✓ Switched to model: ${selectedModel.model}`, - timestamp: new Date(), - }; - setChatHistory((prev) => [...prev, confirmEntry]); - setShowModelSelection(false); - setSelectedModelIndex(0); - return; - } - } - - if (key.return) { - const userInput = input.trim(); - if (userInput === "exit" || userInput === "quit") { - process.exit(0); - return; - } - - if (userInput) { - const directCommandResult = await handleDirectCommand(userInput); - if (!directCommandResult) { - await processUserMessage(userInput); - } - } - return; - } - - // Handle left and right arrow keys for cursor movement - if (key.leftArrow && !showCommandSuggestions && !showModelSelection) { - setCursorPosition(prev => Math.max(0, prev - 1)); - return; - } - - if (key.rightArrow && !showCommandSuggestions && !showModelSelection) { - setCursorPosition(prev => Math.min(input.length, prev + 1)); - return; - } - - if (key.backspace || key.delete) { - if (cursorPosition > 0) { - const newInput = input.slice(0, cursorPosition - 1) + input.slice(cursorPosition); - setInput(newInput); - setCursorPosition(prev => prev - 1); - - if (!newInput.startsWith("/")) { - setShowCommandSuggestions(false); - setSelectedCommandIndex(0); - } - } - return; - } - - if (inputChar && !key.ctrl && !key.meta) { - const newInput = input.slice(0, cursorPosition) + inputChar + input.slice(cursorPosition); - setInput(newInput); - setCursorPosition(prev => prev + 1); - - if (newInput.startsWith("/")) { - setShowCommandSuggestions(true); - setSelectedCommandIndex(0); - } else { - setShowCommandSuggestions(false); - setSelectedCommandIndex(0); - } - } - }); return { input, diff --git a/src/hooks/use-input-history.ts b/src/hooks/use-input-history.ts new file mode 100644 index 0000000..41900cd --- /dev/null +++ b/src/hooks/use-input-history.ts @@ -0,0 +1,75 @@ +import { useState, useCallback } from "react"; + +export interface InputHistoryHook { + addToHistory: (input: string) => void; + navigateHistory: (direction: "up" | "down") => string | null; + getCurrentHistoryIndex: () => number; + resetHistory: () => void; + isNavigatingHistory: () => boolean; + setOriginalInput: (input: string) => void; +} + +export function useInputHistory(): InputHistoryHook { + const [history, setHistory] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); + const [originalInput, setOriginalInput] = useState(""); + + const addToHistory = useCallback((input: string) => { + if (input.trim() && !history.includes(input.trim())) { + setHistory(prev => [...prev, input.trim()]); + } + setCurrentIndex(-1); + setOriginalInput(""); + }, [history]); + + const navigateHistory = useCallback((direction: "up" | "down"): string | null => { + if (history.length === 0) return null; + + let newIndex: number; + + if (direction === "up") { + if (currentIndex === -1) { + newIndex = history.length - 1; + } else { + newIndex = Math.max(0, currentIndex - 1); + } + } else { + if (currentIndex === -1) { + return null; + } else if (currentIndex === history.length - 1) { + newIndex = -1; + return originalInput; + } else { + newIndex = Math.min(history.length - 1, currentIndex + 1); + } + } + + setCurrentIndex(newIndex); + return newIndex === -1 ? originalInput : history[newIndex]; + }, [history, currentIndex, originalInput]); + + const getCurrentHistoryIndex = useCallback(() => currentIndex, [currentIndex]); + + const resetHistory = useCallback(() => { + setHistory([]); + setCurrentIndex(-1); + setOriginalInput(""); + }, []); + + const isNavigatingHistory = useCallback(() => currentIndex !== -1, [currentIndex]); + + const setOriginalInputCallback = useCallback((input: string) => { + if (currentIndex === -1) { + setOriginalInput(input); + } + }, [currentIndex]); + + return { + addToHistory, + navigateHistory, + getCurrentHistoryIndex, + resetHistory, + isNavigatingHistory, + setOriginalInput: setOriginalInputCallback, + }; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f072bb4..839b4e6 100755 --- a/src/index.ts +++ b/src/index.ts @@ -14,20 +14,17 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat"; // Load environment variables dotenv.config(); -// Add proper signal handling for terminal cleanup -process.on("SIGINT", () => { - // Restore terminal to normal mode before exit - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } - console.log("\nGracefully shutting down..."); - process.exit(0); -}); +// Disable default SIGINT handling to let Ink handle Ctrl+C +// We'll handle exit through the input system instead process.on("SIGTERM", () => { // Restore terminal to normal mode before exit - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); + if (process.stdin.isTTY && process.stdin.setRawMode) { + try { + process.stdin.setRawMode(false); + } catch (e) { + // Ignore errors when setting raw mode + } } console.log("\nGracefully shutting down..."); process.exit(0); diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 6bc51cb..598f367 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -156,12 +156,15 @@ export class MCPManager extends EventEmitter { const { loadMCPConfig } = await import('../mcp/config'); const config = loadMCPConfig(); - for (const serverConfig of config.servers) { + // Initialize servers in parallel to avoid blocking + const initPromises = config.servers.map(async (serverConfig) => { try { await this.addServer(serverConfig); } catch (error) { console.warn(`Failed to initialize MCP server ${serverConfig.name}:`, error); } - } + }); + + await Promise.all(initPromises); } } \ No newline at end of file diff --git a/src/ui/components/chat-input.tsx b/src/ui/components/chat-input.tsx index 8fc2cab..cfff6a5 100644 --- a/src/ui/components/chat-input.tsx +++ b/src/ui/components/chat-input.tsx @@ -16,15 +16,117 @@ export function ChatInput({ }: ChatInputProps) { const beforeCursor = input.slice(0, cursorPosition); const afterCursor = input.slice(cursorPosition); - + + // Handle multiline input display + const lines = input.split("\n"); + const isMultiline = lines.length > 1; + + // Calculate cursor position across lines + let currentLineIndex = 0; + let currentCharIndex = 0; + let totalChars = 0; + + for (let i = 0; i < lines.length; i++) { + if (totalChars + lines[i].length >= cursorPosition) { + currentLineIndex = i; + currentCharIndex = cursorPosition - totalChars; + break; + } + totalChars += lines[i].length + 1; // +1 for newline + } + + const showCursor = !isProcessing && !isStreaming; + const borderColor = isProcessing || isStreaming ? "yellow" : "blue"; + const promptColor = "cyan"; + + // Display placeholder when input is empty + const placeholderText = "Ask me anything..."; + const isPlaceholder = !input; + + if (isMultiline) { + return ( + + {lines.map((line, index) => { + const isCurrentLine = index === currentLineIndex; + const promptChar = index === 0 ? "❯" : "│"; + + if (isCurrentLine) { + const beforeCursorInLine = line.slice(0, currentCharIndex); + const cursorChar = + line.slice(currentCharIndex, currentCharIndex + 1) || " "; + const afterCursorInLine = line.slice(currentCharIndex + 1); + + return ( + + {promptChar} + + {beforeCursorInLine} + {showCursor && ( + + {cursorChar} + + )} + {!showCursor && cursorChar !== " " && cursorChar} + {afterCursorInLine} + + + ); + } else { + return ( + + {promptChar} + {line} + + ); + } + })} + + ); + } + + // Single line input box + const cursorChar = input.slice(cursorPosition, cursorPosition + 1) || " "; + const afterCursorText = input.slice(cursorPosition + 1); + return ( - - ❯ - - {beforeCursor} - {!isProcessing && !isStreaming && █} - {afterCursor} - + + + ❯ + {isPlaceholder ? ( + <> + + {placeholderText} + + {showCursor && ( + + {" "} + + )} + + ) : ( + + {beforeCursor} + {showCursor && ( + + {cursorChar} + + )} + {!showCursor && cursorChar !== " " && cursorChar} + {afterCursorText} + + )} + ); } diff --git a/src/ui/components/chat-interface.tsx b/src/ui/components/chat-interface.tsx index 0bbdf31..b4490ca 100644 --- a/src/ui/components/chat-interface.tsx +++ b/src/ui/components/chat-interface.tsx @@ -177,8 +177,8 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) { )} - - Type your request in natural language. Type 'exit' or Ctrl+C to quit. + + Type your request in natural language. Ctrl+C to clear, 'exit' to quit. @@ -216,15 +216,15 @@ function ChatInterfaceWithAgent({ agent }: { agent: GrokAgent }) { isStreaming={isStreaming} /> - - - {autoEditEnabled ? "▶" : "⏸"} auto-edit:{" "} - {autoEditEnabled ? "on" : "off"} - - - (shift + tab) + + + + {autoEditEnabled ? "▶" : "⏸"} auto-edit:{" "} + {autoEditEnabled ? "on" : "off"} + + (shift + tab) - + ≋ {agent.getCurrentModel()} diff --git a/src/utils/text-utils.ts b/src/utils/text-utils.ts new file mode 100644 index 0000000..108adb0 --- /dev/null +++ b/src/utils/text-utils.ts @@ -0,0 +1,221 @@ +/** + * Text manipulation utilities for terminal input handling + * Inspired by Gemini CLI's text processing capabilities + */ + +export interface TextPosition { + index: number; + line: number; + column: number; +} + +export interface TextSelection { + start: number; + end: number; +} + +/** + * Check if a character is a word boundary + */ +export function isWordBoundary(char: string | undefined): boolean { + if (!char) return true; + return /\s/.test(char) || /[^\w]/.test(char); +} + +/** + * Find the start of the current word at the given position + */ +export function findWordStart(text: string, position: number): number { + if (position <= 0) return 0; + + let pos = position - 1; + while (pos > 0 && !isWordBoundary(text[pos])) { + pos--; + } + + // If we stopped at a word boundary, move forward to the actual word start + if (pos > 0 && isWordBoundary(text[pos])) { + pos++; + } + + return pos; +} + +/** + * Find the end of the current word at the given position + */ +export function findWordEnd(text: string, position: number): number { + if (position >= text.length) return text.length; + + let pos = position; + while (pos < text.length && !isWordBoundary(text[pos])) { + pos++; + } + + return pos; +} + +/** + * Move cursor to the previous word boundary + */ +export function moveToPreviousWord(text: string, position: number): number { + if (position <= 0) return 0; + + let pos = position - 1; + + // Skip whitespace + while (pos > 0 && isWordBoundary(text[pos])) { + pos--; + } + + // Find start of the word + while (pos > 0 && !isWordBoundary(text[pos - 1])) { + pos--; + } + + return pos; +} + +/** + * Move cursor to the next word boundary + */ +export function moveToNextWord(text: string, position: number): number { + if (position >= text.length) return text.length; + + let pos = position; + + // Skip current word + while (pos < text.length && !isWordBoundary(text[pos])) { + pos++; + } + + // Skip whitespace + while (pos < text.length && isWordBoundary(text[pos])) { + pos++; + } + + return pos; +} + +/** + * Delete the word before the cursor + */ +export function deleteWordBefore(text: string, position: number): { text: string; position: number } { + const wordStart = moveToPreviousWord(text, position); + const newText = text.slice(0, wordStart) + text.slice(position); + + return { + text: newText, + position: wordStart, + }; +} + +/** + * Delete the word after the cursor + */ +export function deleteWordAfter(text: string, position: number): { text: string; position: number } { + const wordEnd = moveToNextWord(text, position); + const newText = text.slice(0, position) + text.slice(wordEnd); + + return { + text: newText, + position, + }; +} + +/** + * Get the current line and column from text position + */ +export function getTextPosition(text: string, index: number): TextPosition { + const lines = text.slice(0, index).split('\n'); + return { + index, + line: lines.length - 1, + column: lines[lines.length - 1].length, + }; +} + +/** + * Move to the beginning of the current line + */ +export function moveToLineStart(text: string, position: number): number { + const beforeCursor = text.slice(0, position); + const lastNewlineIndex = beforeCursor.lastIndexOf('\n'); + return lastNewlineIndex === -1 ? 0 : lastNewlineIndex + 1; +} + +/** + * Move to the end of the current line + */ +export function moveToLineEnd(text: string, position: number): number { + const afterCursor = text.slice(position); + const nextNewlineIndex = afterCursor.indexOf('\n'); + return nextNewlineIndex === -1 ? text.length : position + nextNewlineIndex; +} + +/** + * Handle proper Unicode-aware character deletion + */ +export function deleteCharBefore(text: string, position: number): { text: string; position: number } { + if (position <= 0) { + return { text, position }; + } + + // Handle surrogate pairs and combining characters + let deleteCount = 1; + const charBefore = text.charAt(position - 1); + + // Check for high surrogate (first part of surrogate pair) + if (position >= 2) { + const charBeforeBefore = text.charAt(position - 2); + if (charBeforeBefore >= '\uD800' && charBeforeBefore <= '\uDBFF' && + charBefore >= '\uDC00' && charBefore <= '\uDFFF') { + deleteCount = 2; + } + } + + const newText = text.slice(0, position - deleteCount) + text.slice(position); + return { + text: newText, + position: position - deleteCount, + }; +} + +/** + * Handle proper Unicode-aware character deletion forward + */ +export function deleteCharAfter(text: string, position: number): { text: string; position: number } { + if (position >= text.length) { + return { text, position }; + } + + // Handle surrogate pairs and combining characters + let deleteCount = 1; + const charAfter = text.charAt(position); + + // Check for high surrogate (first part of surrogate pair) + if (position + 1 < text.length) { + const charAfterAfter = text.charAt(position + 1); + if (charAfter >= '\uD800' && charAfter <= '\uDBFF' && + charAfterAfter >= '\uDC00' && charAfterAfter <= '\uDFFF') { + deleteCount = 2; + } + } + + const newText = text.slice(0, position) + text.slice(position + deleteCount); + return { + text: newText, + position, + }; +} + +/** + * Insert text at the given position with proper Unicode handling + */ +export function insertText(text: string, position: number, insert: string): { text: string; position: number } { + const newText = text.slice(0, position) + insert + text.slice(position); + return { + text: newText, + position: position + insert.length, + }; +} \ No newline at end of file