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..5685261 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.',
@@ -102,10 +24,10 @@ 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 = useUIState();
+ const uiState = useUIStateContext();
const boxWidth = useTerminalWidth();
const {
@@ -117,6 +39,7 @@ export default function UserInput({
setHistoryIndex,
updateInput,
resetInput,
+ cachedLineCount,
} = inputState;
const {
@@ -186,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(
@@ -273,6 +197,7 @@ export default function UserInput({
}
if (showClearMessage) {
setShowClearMessage(false);
+ focus('user-input');
}
// Handle return keys
@@ -281,11 +206,6 @@ export default function UserInput({
return;
}
- if (key.return) {
- handleSubmit();
- return;
- }
-
// Handle navigation
if (key.upArrow) {
handleHistoryNavigation('up');
@@ -296,25 +216,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 +223,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 +235,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..a8d7b69
--- /dev/null
+++ b/source/hooks/useInputState.ts
@@ -0,0 +1,77 @@
+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);
+
+ 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);
+ }, 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;
+}