From 0e25386cfe2aba2090916b6f29f1092d05b9afbe Mon Sep 17 00:00:00 2001 From: Alex Spinu Date: Fri, 19 Sep 2025 15:24:09 +0300 Subject: [PATCH 1/4] feature: add inline cursor navigation --- package.json | 2 + source/app.tsx | 169 ++++++++++++++++--------------- source/components/user-input.tsx | 166 +++++++----------------------- source/hooks/useInputState.ts | 80 +++++++++++++++ source/hooks/useUIState.ts | 73 +++++++++++++ 5 files changed, 278 insertions(+), 212 deletions(-) create mode 100644 source/hooks/useInputState.ts create mode 100644 source/hooks/useUIState.ts diff --git a/package.json b/package.json index ee93688..e082ba9 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "meow": "^11.0.0", "nanostores": "^1.0.1", "ollama": "^0.5.17", @@ -61,6 +62,7 @@ }, "devDependencies": { "@sindresorhus/tsconfig": "^3.0.1", + "@types/ink-text-input": "^2.0.5", "@types/node": "^24.3.0", "@types/react": "^19.0.0", "@vdemedes/prettier-config": "^2.0.1", diff --git a/source/app.tsx b/source/app.tsx index 80ddce2..3505519 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -26,10 +26,13 @@ import {useModeHandlers} from './app/hooks/useModeHandlers.js'; import {useAppInitialization} from './app/hooks/useAppInitialization.js'; import {useDirectoryTrust} from './app/hooks/useDirectoryTrust.js'; import { - handleMessageSubmission, createClearMessagesHandler, + handleMessageSubmission, } from './app/utils/appUtils.js'; +// Provide shared UI state to components +import {UIStateProvider} from './hooks/useUIState.js'; + export default function App() { // Use extracted hooks const appState = useAppState(); @@ -233,88 +236,94 @@ export default function App() { return ( - - - - {appState.startChat && ( - - )} - - {appState.startChat && ( - - {appState.isCancelling ? ( - - ) : appState.isThinking ? ( - - ) : null} - {appState.isModelSelectionMode ? ( - - ) : appState.isProviderSelectionMode ? ( - + + + + {appState.startChat && ( + - ) : appState.isThemeSelectionMode ? ( - - ) : appState.isToolConfirmationMode && - appState.pendingToolCalls[appState.currentToolIndex] ? ( - - ) : appState.isToolExecuting && - appState.pendingToolCalls[appState.currentToolIndex] ? ( - - ) : appState.isBashExecuting ? ( - - ) : appState.mcpInitialized && appState.client ? ( - - ) : appState.mcpInitialized && !appState.client ? ( - - ⚠️ No LLM provider available. Chat is disabled. Please fix your - provider configuration and restart. - - ) : ( - - Loading... - )} - )} - + {appState.startChat && ( + + {appState.isCancelling ? ( + + ) : appState.isThinking ? ( + + ) : null} + {appState.isModelSelectionMode ? ( + + ) : appState.isProviderSelectionMode ? ( + + ) : appState.isThemeSelectionMode ? ( + + ) : appState.isToolConfirmationMode && + appState.pendingToolCalls[appState.currentToolIndex] ? ( + + ) : appState.isToolExecuting && + appState.pendingToolCalls[appState.currentToolIndex] ? ( + + ) : appState.isBashExecuting ? ( + + ) : appState.mcpInitialized && appState.client ? ( + + ) : appState.mcpInitialized && !appState.client ? ( + + ⚠️ No LLM provider available. Chat is disabled. Please fix + your provider configuration and restart. + + ) : ( + + Loading... + + )} + + )} + + ); } diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index 1cb7be5..3225332 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -1,9 +1,13 @@ -import {Box, Text, useInput, useFocus} from 'ink'; -import {useState, useEffect, useCallback, useRef} from 'react'; +import {Box, Text, useFocus, useInput} from 'ink'; +import TextInput from 'ink-text-input'; +import {useCallback, useEffect} from 'react'; import {useTheme} from '../hooks/useTheme.js'; import {promptHistory} from '../prompt-history.js'; import {commandRegistry} from '../commands.js'; import {useTerminalWidth} from '../hooks/useTerminalWidth.js'; +import {useUIStateContext} from '../hooks/useUIState.js'; +import {useInputState} from '../hooks/useInputState.js'; +import {Completion} from '../types/index.js'; interface ChatProps { onSubmit?: (message: string) => void; @@ -13,88 +17,6 @@ interface ChatProps { onCancel?: () => void; // Callback when user presses escape while thinking } -// Types for better organization -type Completion = {name: string; isCustom: boolean}; - -// Custom hooks -function useInputState() { - const [input, setInput] = useState(''); - const [hasLargeContent, setHasLargeContent] = useState(false); - const [originalInput, setOriginalInput] = useState(''); - const [historyIndex, setHistoryIndex] = useState(-1); - const debounceTimerRef = useRef(null); - - const [cachedLineCount, setCachedLineCount] = useState(1); - - // Cache the line count - const updateInput = useCallback((newInput: string) => { - setInput(newInput); - - debounceTimerRef.current = setTimeout(() => { - setHasLargeContent(newInput.length > 150); - // Cache line count calculation - if (newInput.length > 150) { - const lineCount = Math.max( - newInput.split('\n').length, - newInput.split('\r').length, - ); - setCachedLineCount(lineCount); - } - }, 50); - }, []); - - const resetInput = useCallback(() => { - // Clear any pending debounce timer - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - setInput(''); - setHasLargeContent(false); - setOriginalInput(''); - setHistoryIndex(-1); - }, []); - - return { - input, - hasLargeContent, - originalInput, - historyIndex, - setInput, - setHasLargeContent, - setOriginalInput, - setHistoryIndex, - updateInput, - resetInput, - }; -} - -function useUIState() { - const [showClearMessage, setShowClearMessage] = useState(false); - const [showFullContent, setShowFullContent] = useState(false); - const [showCompletions, setShowCompletions] = useState(false); - const [completions, setCompletions] = useState([]); - - const resetUIState = useCallback(() => { - setShowClearMessage(false); - setShowFullContent(false); - setShowCompletions(false); - setCompletions([]); - }, []); - - return { - showClearMessage, - showFullContent, - showCompletions, - completions, - setShowClearMessage, - setShowFullContent, - setShowCompletions, - setCompletions, - resetUIState, - }; -} - export default function UserInput({ onSubmit, placeholder = 'Type `/` and then press Tab for command suggestions or `!` to execute bash commands. Use ↑/↓ for history.', @@ -105,7 +27,7 @@ export default function UserInput({ const {isFocused} = useFocus({autoFocus: !disabled}); const {colors} = useTheme(); const inputState = useInputState(); - const uiState = useUIState(); + const uiState = useUIStateContext(); const boxWidth = useTerminalWidth(); const { @@ -117,6 +39,7 @@ export default function UserInput({ setHistoryIndex, updateInput, resetInput, + cachedLineCount, } = inputState; const { @@ -281,11 +204,6 @@ export default function UserInput({ return; } - if (key.return) { - handleSubmit(); - return; - } - // Handle navigation if (key.upArrow) { handleHistoryNavigation('up'); @@ -296,25 +214,6 @@ export default function UserInput({ handleHistoryNavigation('down'); return; } - - // Handle deletion - if (key.backspace || key.delete) { - const newInput = input.slice(0, -1); - updateInput(newInput); - return; - } - - // Handle character input - if (inputChar) { - // Normalize line endings and tabs for pasted content - let normalizedChar = inputChar; - if (inputChar.includes('\r') || inputChar.includes('\n')) { - normalizedChar = inputChar.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - } - // Convert tabs to 2 spaces for more compact display - normalizedChar = normalizedChar.replace(/\t/g, ' '); - updateInput(input + normalizedChar); - } }); // Render function - NEVER modifies state, only for display @@ -322,15 +221,10 @@ export default function UserInput({ if (!input) return placeholder; if (hasLargeContent && input.length > 150 && !showFullContent) { - // Count lines properly - handle both \n and \r line endings - const lineCount = Math.max( - input.split('\n').length, - input.split('\r').length, - ); - + // Use cached line count from the hook (debounced) return ( <> - {`[${input.length} characters, ${lineCount} lines] `} + {`[${input.length} characters, ${cachedLineCount} lines] `} ({getExpandKey()} to expand) ); @@ -339,6 +233,8 @@ export default function UserInput({ return input; }; + const textColor = disabled || !input ? colors.secondary : colors.primary; + return ( )} - - {'>'} {disabled ? '...' : renderDisplayContent()} - {!disabled && input && isFocused && ( - - {' '} - - )} - + {/* Input row */} + {hasLargeContent && input.length > 150 && !showFullContent ? ( + + {'>'} {renderDisplayContent()} + + ) : ( + + {'>'} + {disabled ? ( + ... + ) : ( + + )} + + )} + {isBashMode && ( Bash Mode diff --git a/source/hooks/useInputState.ts b/source/hooks/useInputState.ts new file mode 100644 index 0000000..6ff4a95 --- /dev/null +++ b/source/hooks/useInputState.ts @@ -0,0 +1,80 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; + +export function useInputState() { + const [input, setInput] = useState(''); + const [hasLargeContent, setHasLargeContent] = useState(false); + const [originalInput, setOriginalInput] = useState(''); + const [historyIndex, setHistoryIndex] = useState(-1); + const debounceTimerRef = useRef | null>(null); + + const [cachedLineCount, setCachedLineCount] = useState(1); + + // Cache the line count + const updateInput = useCallback((newInput: string) => { + setInput(newInput); + + // Compute and set line count immediately so UI doesn't show stale values + const immediateLineCount = Math.max(1, newInput.split(/\r\n|\r|\n/).length); + setCachedLineCount(immediateLineCount); + + // Clear any previous debounce timer before setting a new one + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + setHasLargeContent(newInput.length > 150); + // Also refresh cached line count in case something changed quickly + const lineCount = Math.max(1, newInput.split(/\r\n|\r|\n/).length); + setCachedLineCount(lineCount); + }, 50); + }, []); + + const resetInput = useCallback(() => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + setInput(''); + setHasLargeContent(false); + setOriginalInput(''); + setHistoryIndex(-1); + setCachedLineCount(1); + }, []); + + // Cleanup on unmount to avoid leaked timers + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, []); + + return useMemo( + () => ({ + input, + hasLargeContent, + originalInput, + historyIndex, + setInput, + setHasLargeContent, + setOriginalInput, + setHistoryIndex, + updateInput, + resetInput, + cachedLineCount, + }), + [ + input, + hasLargeContent, + originalInput, + historyIndex, + updateInput, + resetInput, + cachedLineCount, + ], + ); +} diff --git a/source/hooks/useUIState.ts b/source/hooks/useUIState.ts new file mode 100644 index 0000000..10aed92 --- /dev/null +++ b/source/hooks/useUIState.ts @@ -0,0 +1,73 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import {Completion} from '../types/index.js'; + +export type UIState = { + showClearMessage: boolean; + showFullContent: boolean; + showCompletions: boolean; + completions: Completion[]; + setShowClearMessage: React.Dispatch>; + setShowFullContent: React.Dispatch>; + setShowCompletions: React.Dispatch>; + setCompletions: React.Dispatch>; + resetUIState: () => void; +}; + +const UIStateContext = createContext(undefined); + +// Existing hook that builds the UI state (kept to separate creation from context) +export function useUIState(): UIState { + const [showClearMessage, setShowClearMessage] = useState(false); + const [showFullContent, setShowFullContent] = useState(false); + const [showCompletions, setShowCompletions] = useState(false); + const [completions, setCompletions] = useState([]); + + const resetUIState = useCallback(() => { + setShowClearMessage(false); + setShowFullContent(false); + setShowCompletions(false); + setCompletions([]); + }, []); + + return useMemo( + () => ({ + showClearMessage, + showFullContent, + showCompletions, + completions, + setShowClearMessage, + setShowFullContent, + setShowCompletions, + setCompletions, + resetUIState, + }), + [ + showClearMessage, + showFullContent, + showCompletions, + completions, + resetUIState, + ], + ); +} + +// Provider to expose a single shared UI state instance to the subtree +export function UIStateProvider({children}: {children: React.ReactNode}) { + const state = useUIState(); + return React.createElement(UIStateContext.Provider, {value: state}, children); +} + +// Hook to consume the shared UI state from context (preferred for consumers) +export function useUIStateContext(): UIState { + const ctx = useContext(UIStateContext); + if (!ctx) { + throw new Error('useUIStateContext must be used within a UIStateProvider'); + } + return ctx; +} From 2d8c9d9227ed1f065bcc46c22e3841ae6899a32d Mon Sep 17 00:00:00 2001 From: Alex Spinu Date: Fri, 19 Sep 2025 16:21:43 +0300 Subject: [PATCH 2/4] feat: add controlled focus --- source/components/user-input.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index 3225332..d35ea25 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -24,7 +24,7 @@ export default function UserInput({ disabled = false, onCancel, }: ChatProps) { - const {isFocused} = useFocus({autoFocus: !disabled}); + const {isFocused, focus} = useFocus({autoFocus: !disabled, id: 'user-input'}); const {colors} = useTheme(); const inputState = useInputState(); const uiState = useUIStateContext(); @@ -109,10 +109,11 @@ export default function UserInput({ if (showClearMessage) { resetInput(); resetUIState(); + focus('user-input'); } else { setShowClearMessage(true); } - }, [showClearMessage, resetInput, resetUIState, setShowClearMessage]); + }, [showClearMessage, resetInput, resetUIState, setShowClearMessage, focus]); // History navigation const handleHistoryNavigation = useCallback( From e5ef27718ce8b6fb6345181ac0329b5329532017 Mon Sep 17 00:00:00 2001 From: Alex Spinu Date: Fri, 19 Sep 2025 16:24:02 +0300 Subject: [PATCH 3/4] fix: resume typing on clear message --- source/components/user-input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index d35ea25..5685261 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -197,6 +197,7 @@ export default function UserInput({ } if (showClearMessage) { setShowClearMessage(false); + focus('user-input'); } // Handle return keys From 94051d1444bb91399c7b8f2cd8162ff7a8bd7c41 Mon Sep 17 00:00:00 2001 From: Alex Spinu Date: Fri, 19 Sep 2025 16:27:29 +0300 Subject: [PATCH 4/4] fix: remove redundant line count update and use nodejs timeout type --- source/hooks/useInputState.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/source/hooks/useInputState.ts b/source/hooks/useInputState.ts index 6ff4a95..a8d7b69 100644 --- a/source/hooks/useInputState.ts +++ b/source/hooks/useInputState.ts @@ -5,7 +5,7 @@ export function useInputState() { const [hasLargeContent, setHasLargeContent] = useState(false); const [originalInput, setOriginalInput] = useState(''); const [historyIndex, setHistoryIndex] = useState(-1); - const debounceTimerRef = useRef | null>(null); + const debounceTimerRef = useRef(null); const [cachedLineCount, setCachedLineCount] = useState(1); @@ -24,9 +24,6 @@ export function useInputState() { debounceTimerRef.current = setTimeout(() => { setHasLargeContent(newInput.length > 150); - // Also refresh cached line count in case something changed quickly - const lineCount = Math.max(1, newInput.split(/\r\n|\r|\n/).length); - setCachedLineCount(lineCount); }, 50); }, []);