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
81 changes: 81 additions & 0 deletions source/components/CommandSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Command Suggestions Component
*
* Displays filtered command suggestions with keyboard navigation support
* and real-time filtering based on user input.
*/

import { Box, Text } from 'ink';
import { CommandSuggestion } from '../utils/commandRegistry.js';

interface CommandSuggestionsProps {
suggestions: CommandSuggestion[];
selectedIndex: number;
isVisible: boolean;
maxSuggestions?: number;
}

export default function CommandSuggestions({
suggestions,
selectedIndex,
isVisible,
maxSuggestions = 5,
}: CommandSuggestionsProps) {
if (!isVisible || suggestions.length === 0) {
return null;
}

const displaySuggestions = suggestions.slice(0, maxSuggestions);

return (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color="gray" dimColor>
Commands:
</Text>
{displaySuggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const { command, matchReason } = suggestion;

return (
<Box key={command.name} marginLeft={1}>
<Text
color={isSelected ? 'cyan' : 'white'}
backgroundColor={isSelected ? 'blue' : undefined}
bold={isSelected}
>
{isSelected ? '> ' : ' '}
{command.icon ? `${command.icon} ` : ''}
{command.name}
</Text>
<Box marginLeft={1}>
<Text color="gray" dimColor>
{command.description}
</Text>
</Box>
{matchReason === 'alias' && (
<Box marginLeft={1}>
<Text color="yellow" dimColor>
(alias)
</Text>
</Box>
)}
</Box>
);
})}

{suggestions.length > maxSuggestions && (
<Box marginLeft={1}>
<Text color="gray" dimColor>
... and {suggestions.length - maxSuggestions} more
</Text>
</Box>
)}

<Box marginTop={1} marginLeft={1}>
<Text color="gray" dimColor>
Use ↑↓ to navigate • Tab to complete • Enter to execute
</Text>
</Box>
</Box>
);
}
77 changes: 77 additions & 0 deletions source/components/InputHints.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Input Hints Component
*
* Displays helpful hints about available commands and shortcuts
* when the user is not actively using command suggestions.
*/

import { Box, Text } from 'ink';

interface InputHintsProps {
isVisible: boolean;
currentView: string;
}

export default function InputHints({ isVisible, currentView }: InputHintsProps) {
if (!isVisible) {
return null;
}

const getContextualHints = () => {
switch (currentView) {
case 'home':
return [
'Type "/" for commands',
'Type a message to chat',
'Common: /chat, /help, /models',
];
case 'chat':
return [
'Chat mode active',
'Type "/" for commands',
'Message goes directly to AI',
];
default:
return [
'Type "/" for commands',
'Available: /help, /home, /chat',
];
}
};

const shortcuts = [
'Ctrl+C: Exit',
'Esc: Home',
'Tab: Complete',
];

const hints = getContextualHints();

return (
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
<Box flexDirection="row" gap={3}>
<Box flexDirection="column">
<Text color="blue" dimColor>
💡 Quick Tips:
</Text>
{hints.map((hint, index) => (
<Text key={index} color="gray" dimColor>
• {hint}
</Text>
))}
</Box>

<Box flexDirection="column">
<Text color="blue" dimColor>
⌨️ Shortcuts:
</Text>
{shortcuts.map((shortcut, index) => (
<Text key={index} color="gray" dimColor>
• {shortcut}
</Text>
))}
</Box>
</Box>
</Box>
);
}
26 changes: 25 additions & 1 deletion source/components/InteractiveApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Box, Text} from 'ink';
import {getConfigManager} from '../utils/config.js';
import {InteractiveAppProps} from '../entities/state.js';
import {useAppContext} from '../providers/AppProvider/index.js';
import {CommandSuggestions, InputHints} from './index.js';
import {
HomePage,
ModelSelection,
Expand All @@ -13,7 +14,7 @@ import {
} from '../views/index.js';

export default function InteractiveApp({name, version}: InteractiveAppProps) {
const {state, handleModelSelect, handleBackToHome} = useAppContext();
const {state, handleModelSelect, handleBackToHome, inputState} = useAppContext();

// Update user name in config if provided
useEffect(() => {
Expand Down Expand Up @@ -80,12 +81,35 @@ export default function InteractiveApp({name, version}: InteractiveAppProps) {
</Box>
)}

{/* Input Hints */}
{inputState && (
<InputHints
isVisible={inputState.showHints}
currentView={state.currentView}
/>
)}

{/* Input Section */}
<Box borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
<Text color="cyan">$ </Text>
<Text>{state.input}</Text>
{inputState?.autocompletePreview && state.input.startsWith('/') && (
<Text color="gray" dimColor>
{inputState.autocompletePreview.slice(state.input.length)}
</Text>
)}
<Text color="gray">_</Text>
</Box>

{/* Command Suggestions */}
{inputState && (
<CommandSuggestions
suggestions={inputState.suggestions}
selectedIndex={inputState.selectedSuggestionIndex}
isVisible={inputState.showSuggestions}
maxSuggestions={5}
/>
)}
</>
)}

Expand Down
2 changes: 2 additions & 0 deletions source/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export {default as Header} from './Header.js';
export {default as Navigation} from './Navigation.js';
export {default as StatusBar} from './StatusBar.js';
export {default as InteractiveApp} from './InteractiveApp.js';
export {default as CommandSuggestions} from './CommandSuggestions.js';
export {default as InputHints} from './InputHints.js';
65 changes: 53 additions & 12 deletions source/providers/AppProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useState,
useCallback,
useEffect,
useRef,
} from 'react';
import {useInput, useApp} from 'ink';
import {AppState} from '../../entities/state.js';
Expand All @@ -15,6 +16,7 @@ import {
AgentResponse,
} from '../../utils/agent.js';
import {promises as fs} from 'fs';
import {InputHandler} from '../../utils/inputHandler.js';

const configManager = getConfigManager();
export const AppContext = createContext({});
Expand All @@ -33,6 +35,24 @@ export default function AppProvider({children}: {children: React.ReactNode}) {
processedEventCount: 0,
});

// Enhanced input handling
const inputHandlerRef = useRef<InputHandler>(new InputHandler());
const [inputState, setInputState] = useState(inputHandlerRef.current.getState());

// Subscribe to input handler changes
useEffect(() => {
const unsubscribe = inputHandlerRef.current.subscribe(setInputState);
return unsubscribe;
}, []);

// Sync input state with app state
useEffect(() => {
setState(prev => ({
...prev,
input: inputState.text,
}));
}, [inputState.text]);

// Load configuration on startup
useEffect(() => {
const loadConfig = async () => {
Expand Down Expand Up @@ -430,7 +450,7 @@ Agent is ready for interaction!
[exit, state.agentState, handleChatInput],
);

// Centralized input handling
// Enhanced input handling with command suggestions
useInput((input, key) => {
// Only handle input when not in specialized views
if (state.currentView === 'models' || state.currentView === 'api-config') {
Expand All @@ -447,22 +467,45 @@ Agent is ready for interaction!
return;
}

// Handle arrow keys for command navigation
if (key.upArrow || key.downArrow) {
const handled = inputHandlerRef.current.handleArrowKey(
key.upArrow ? 'up' : 'down'
);
if (handled) {
return; // Don't process further if arrow key was handled by suggestions
}
}

// Handle tab completion
if (key.tab) {
const completed = inputHandlerRef.current.handleTabCompletion();
if (completed) {
return; // Tab was handled by autocomplete
}
}

if (key.return) {
if (state.input.trim()) {
handleCommand(state.input).catch(console.error);
const currentInput = inputHandlerRef.current.getState().text.trim();

if (currentInput) {
// Check if user has a command selected from suggestions
const selectedCommand = inputHandlerRef.current.getSelectedCommand();
const commandToExecute = selectedCommand || currentInput;

inputHandlerRef.current.clear();
handleCommand(commandToExecute).catch(console.error);
}
return;
}

if (key.backspace || key.delete) {
setState(prev => ({
...prev,
input: prev.input.slice(0, -1),
}));
inputHandlerRef.current.handleBackspace();
return;
}

if (key.escape) {
inputHandlerRef.current.clear();
setState(prev => ({
...prev,
currentView: 'home',
Expand All @@ -472,11 +515,8 @@ Agent is ready for interaction!
}

// Regular character input (but not during processing)
if (!state.isProcessing) {
setState(prev => ({
...prev,
input: prev.input + input,
}));
if (!state.isProcessing && input) {
inputHandlerRef.current.handleCharacterInput(input);
}
});

Expand All @@ -488,6 +528,7 @@ Agent is ready for interaction!
handleBackToHome,
handleModelSelect,
handleCommand,
inputState,
}}
>
{children}
Expand Down
13 changes: 11 additions & 2 deletions source/utils/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,19 @@ export async function executeTools(
return state;
}

export function collectUsage(state: ThreadState) {
return {
prompt_tokens: state.thread.usage.prompt_tokens,
completion_tokens: state.thread.usage.completion_tokens,
total_tokens: state.thread.usage.total_tokens,
};
}

export async function agentLoop(
query: string,
state: ThreadState,
model: ChatModels = ChatModels.OPENAI_GPT_4_1_NANO,
ctxParser: (state: ThreadState) => string = convertStateToXML,
): Promise<AgentResponse> {
const configManager = getConfigManager();
const config = await configManager.load();
Expand All @@ -185,11 +194,11 @@ export async function agentLoop(

// Generate LLM response
const systemMessage = getSystemMessage(state);
const conversationHistory = convertStateToXML(state);
const ctxWindow = ctxParser(state);

try {
const llmResponse = await callModel(
conversationHistory,
ctxWindow,
systemMessage,
selectedModel,
);
Expand Down
Loading