""" Text User Interface for LangCode Agent configuration management Enhanced with search/filter, export/import, and connection testing """ import curses import sys import json import yaml from pathlib import Path from typing import Optional, List, Dict, Any from config_manager import config_manager def draw_menu(stdscr, selected_row_idx, options, title="LangCode Agent Configuration TUI"): """Draw a menu with options""" stdscr.clear() h, w = stdscr.getmaxyx() # Title stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # Instructions instructions = "Use arrow keys to navigate, Enter to select, 'q' to quit" stdscr.addstr(2, (w - len(instructions)) // 2, instructions) # Menu options for idx, option in enumerate(options): x = w // 2 - len(option) // 2 y = h // 2 - len(options) // 2 + idx if idx == selected_row_idx: stdscr.attron(curses.A_REVERSE) stdscr.addstr(y, x, option) stdscr.attroff(curses.A_REVERSE) else: stdscr.addstr(y, x, option) stdscr.refresh() def get_string_input(stdscr, prompt, y, x, max_length=50): """Get string input from user""" curses.echo() stdscr.addstr(y, x, prompt) stdscr.refresh() input_win = curses.newwin(1, max_length, y, x + len(prompt)) input_win.box() input_win.refresh() curses.curs_set(1) result = input_win.getstr(0, 0, max_length).decode('utf-8') curses.curs_set(0) curses.noecho() return result def get_password_input(stdscr, prompt, y, x, max_length=50): """Get password input from user (hidden)""" curses.noecho() stdscr.addstr(y, x, prompt) stdscr.refresh() input_win = curses.newwin(1, max_length, y, x + len(prompt)) input_win.box() input_win.refresh() curses.curs_set(1) result = "" while True: ch = input_win.getch() if ch in [curses.KEY_ENTER, 10, 13]: # Enter key break elif ch in [curses.KEY_BACKSPACE, 127, 8]: # Backspace if len(result) > 0: result = result[:-1] input_win.addstr(0, len(result), " ") input_win.move(0, len(result)) elif ch >= 32 and ch <= 126 and len(result) < max_length: # Printable characters result += chr(ch) input_win.addstr(0, len(result)-1, "*") input_win.refresh() curses.curs_set(0) return result def show_message(stdscr, message, wait_for_key=True): """Show a message to the user""" h, w = stdscr.getmaxyx() stdscr.addstr(h//2, (w - len(message)) // 2, message) stdscr.refresh() if wait_for_key: stdscr.getch() def filter_configurations(configs: Dict[str, Any], search_term: str) -> Dict[str, Any]: """Filter configurations based on search term""" if not search_term: return configs search_term = search_term.lower() filtered = {} for name, config in configs.items(): # Search in name if search_term in name.lower(): filtered[name] = config continue # Search in provider provider = config.get("provider", "").lower() if search_term in provider: filtered[name] = config continue # Search in model name model_name = config.get("model_name", "").lower() if search_term in model_name: filtered[name] = config continue # Search in base URL base_url = config.get("base_url", "").lower() if search_term in base_url: filtered[name] = config continue return filtered def main_tui(stdscr): """Main TUI function""" # Initialize colors if available if curses.has_colors(): curses.start_color() curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) # Main menu options main_options = [ "View Configurations", "Search Configurations", "Add Configuration", "Remove Configuration", "Set Active Configuration", "View Active Configuration", "Test Configuration", "Export Configurations", "Import Configurations", "Exit" ] current_selection = 0 while True: draw_menu(stdscr, current_selection, main_options) key = stdscr.getch() if key == curses.KEY_UP and current_selection > 0: current_selection -= 1 elif key == curses.KEY_DOWN and current_selection < len(main_options) - 1: current_selection += 1 elif key == curses.KEY_ENTER or key in [10, 13]: # Enter key pressed if current_selection == 0: # View Configurations view_configurations_tui(stdscr) elif current_selection == 1: # Search Configurations search_configurations_tui(stdscr) elif current_selection == 2: # Add Configuration add_configuration_tui(stdscr) elif current_selection == 3: # Remove Configuration remove_configuration_tui(stdscr) elif current_selection == 4: # Set Active Configuration set_active_configuration_tui(stdscr) elif current_selection == 5: # View Active Configuration view_active_configuration_tui(stdscr) elif current_selection == 6: # Test Configuration test_configuration_tui(stdscr) elif current_selection == 7: # Export Configurations export_configurations_tui(stdscr) elif current_selection == 8: # Import Configurations import_configurations_tui(stdscr) elif current_selection == 9: # Exit break elif key == ord('q') or key == ord('Q'): break def view_configurations_tui(stdscr, configs=None, title="Configurations"): """View all configurations in TUI""" if configs is None: configs = config_manager.get_configurations() active = config_manager.get_active_configuration() stdscr.clear() h, w = stdscr.getmaxyx() # Title stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) if not configs: stdscr.addstr(2, 0, "No configurations found.") else: # Header header = f"{'Name':<20} {'Provider':<12} {'Model':<20} {'Active'}" stdscr.addstr(2, 0, header) stdscr.addstr(3, 0, "-" * len(header)) # Configurations for idx, (name, config) in enumerate(configs.items()): y = 4 + idx if y >= h - 2: # Prevent overflow break marker = "*" if name == active else " " provider = config.get("provider", "unknown")[:11] model = config.get("model_name", "unknown")[:19] line = f"{marker} {name:<19} {provider:<12} {model:<20}" stdscr.addstr(y, 0, line) # Instructions instructions = "Press any key to return to main menu" stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions) stdscr.refresh() stdscr.getch() def search_configurations_tui(stdscr): """Search/filter configurations""" stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Search Configurations" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # Get search term search_term = get_string_input(stdscr, "Search term (name, provider, model, URL): ", 2, 0, 50) if not search_term: show_message(stdscr, "Search cancelled.") return # Get all configurations and filter all_configs = config_manager.get_configurations() filtered_configs = filter_configurations(all_configs, search_term) # Show filtered results if not filtered_configs: show_message(stdscr, f"No configurations found matching '{search_term}'.") else: view_configurations_tui(stdscr, filtered_configs, f"Search Results for '{search_term}'") def add_configuration_tui(stdscr): """Add a new configuration via TUI""" stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Add New Configuration" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # Get configuration name name = get_string_input(stdscr, "Configuration name: ", 2, 0) if not name: show_message(stdscr, "Error: Configuration name cannot be empty") return if config_manager.configuration_exists(name): overwrite_prompt = f"Configuration '{name}' already exists. Overwrite? (y/n): " overwrite = get_string_input(stdscr, overwrite_prompt, 3, 0, 1) if overwrite.lower() != 'y': show_message(stdscr, "Cancelled.") return # Select provider providers = ["OpenAI", "Ollama", "OpenRouter"] provider_selection = 0 while True: stdscr.clear() stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) stdscr.addstr(2, 0, f"Name: {name}") stdscr.addstr(3, 0, "Select provider:") for idx, provider in enumerate(providers): y = 4 + idx if idx == provider_selection: stdscr.attron(curses.A_REVERSE) stdscr.addstr(y, 2, f"> {provider}") stdscr.attroff(curses.A_REVERSE) else: stdscr.addstr(y, 2, f" {provider}") stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm") stdscr.refresh() key = stdscr.getch() if key == curses.KEY_UP and provider_selection > 0: provider_selection -= 1 elif key == curses.KEY_DOWN and provider_selection < len(providers) - 1: provider_selection += 1 elif key == curses.KEY_ENTER or key in [10, 13]: break provider_map = {"OpenAI": "openai", "Ollama": "ollama", "OpenRouter": "openrouter"} provider = provider_map[providers[provider_selection]] config = {"provider": provider} # Get model name model_name = get_string_input(stdscr, f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ", 5, 0) if not model_name: show_message(stdscr, "Error: Model name cannot be empty") return config["model_name"] = model_name # Get provider-specific settings y_pos = 6 if provider == "openai": api_key = get_password_input(stdscr, "OpenAI API key: ", y_pos, 0) config["api_key"] = api_key y_pos += 1 elif provider == "ollama": base_url = get_string_input(stdscr, f"Ollama base URL (default: http://localhost:11434): ", y_pos, 0) if not base_url: base_url = "http://localhost:11434" config["base_url"] = base_url y_pos += 1 elif provider == "openrouter": api_key = get_password_input(stdscr, "OpenRouter API key: ", y_pos, 0) config["api_key"] = api_key y_pos += 1 base_url = get_string_input(stdscr, f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ", y_pos, 0) if not base_url: base_url = "https://openrouter.ai/api/v1" config["base_url"] = base_url y_pos += 1 # Ask if this should be the active configuration set_active_prompt = "Set as active configuration? (y/n): " set_active = get_string_input(stdscr, set_active_prompt, y_pos, 0, 1) # Save the configuration try: config_manager.add_configuration(name, config) message = f"Configuration '{name}' saved successfully." if set_active.lower() == 'y': config_manager.set_active_configuration(name) message += f" Configuration '{name}' set as active." show_message(stdscr, message) except Exception as e: show_message(stdscr, f"Error saving configuration: {e}") def remove_configuration_tui(stdscr): """Remove a configuration via TUI""" configs = config_manager.get_configurations() if not configs: show_message(stdscr, "No configurations to remove.") return stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Remove Configuration" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # List configurations config_names = list(configs.keys()) selection = 0 while True: stdscr.clear() stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) stdscr.addstr(2, 0, "Select configuration to remove:") for idx, name in enumerate(config_names): y = 3 + idx if y >= h - 4: # Prevent overflow break active_marker = " *" if name == config_manager.get_active_configuration(): active_marker = " (active)" if idx == selection: stdscr.attron(curses.A_REVERSE) stdscr.addstr(y, 2, f"> {name}{active_marker}") stdscr.attroff(curses.A_REVERSE) else: stdscr.addstr(y, 2, f" {name}{active_marker}") stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel") stdscr.refresh() key = stdscr.getch() if key == curses.KEY_UP and selection > 0: selection -= 1 elif key == curses.KEY_DOWN and selection < len(config_names) - 1: selection += 1 elif key == curses.KEY_ENTER or key in [10, 13]: # Confirm removal name_to_remove = config_names[selection] active = config_manager.get_active_configuration() if name_to_remove == active: confirm_prompt = f"'{name_to_remove}' is the active configuration. Remove it anyway? (y/n): " confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1) if confirm.lower() != 'y': show_message(stdscr, "Cancelled.") return confirm_prompt = f"Are you sure you want to remove configuration '{name_to_remove}'? (y/n): " confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1) if confirm.lower() == 'y': if config_manager.remove_configuration(name_to_remove): show_message(stdscr, f"Configuration '{name_to_remove}' removed successfully.") if name_to_remove == active: show_message(stdscr, "Active configuration has been unset.") else: show_message(stdscr, f"Error: Failed to remove configuration '{name_to_remove}'.") else: show_message(stdscr, "Cancelled.") return elif key == ord('c') or key == ord('C'): show_message(stdscr, "Cancelled.") return def set_active_configuration_tui(stdscr): """Set active configuration via TUI""" configs = config_manager.get_configurations() if not configs: show_message(stdscr, "No configurations available.") return stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Set Active Configuration" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # List configurations config_names = list(configs.keys()) selection = 0 while True: stdscr.clear() stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) stdscr.addstr(2, 0, "Select configuration to set as active:") for idx, name in enumerate(config_names): y = 3 + idx if y >= h - 4: # Prevent overflow break current_active = config_manager.get_active_configuration() active_marker = " (current)" if name == current_active else "" if idx == selection: stdscr.attron(curses.A_REVERSE) stdscr.addstr(y, 2, f"> {name}{active_marker}") stdscr.attroff(curses.A_REVERSE) else: stdscr.addstr(y, 2, f" {name}{active_marker}") stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel") stdscr.refresh() key = stdscr.getch() if key == curses.KEY_UP and selection > 0: selection -= 1 elif key == curses.KEY_DOWN and selection < len(config_names) - 1: selection += 1 elif key == curses.KEY_ENTER or key in [10, 13]: # Set as active name_to_set = config_names[selection] if config_manager.set_active_configuration(name_to_set): show_message(stdscr, f"Configuration '{name_to_set}' set as active.") else: show_message(stdscr, f"Error: Failed to set configuration '{name_to_set}' as active.") return elif key == ord('c') or key == ord('C'): show_message(stdscr, "Cancelled.") return def view_active_configuration_tui(stdscr): """View active configuration via TUI""" active_name = config_manager.get_active_configuration() if not active_name: show_message(stdscr, "No active configuration set.") return config = config_manager.get_configuration(active_name) if not config: show_message(stdscr, f"Error: Active configuration '{active_name}' not found.") return stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Active Configuration" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # Display configuration details details = [ f"Name: {active_name}", f"Provider: {config.get('provider', 'unknown')}", f"Model: {config.get('model_name', 'unknown')}" ] # Add provider-specific details provider = config.get('provider') if provider == "openai": details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}") elif provider == "ollama": base_url = config.get('base_url', 'http://localhost:11434') details.append(f"Base URL: {base_url}") elif provider == "openrouter": details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}") base_url = config.get('base_url', 'https://openrouter.ai/api/v1') details.append(f"Base URL: {base_url}") # Display details for idx, detail in enumerate(details): y = 2 + idx if y >= h - 2: # Prevent overflow break stdscr.addstr(y, 0, detail) # Instructions instructions = "Press any key to return to main menu" stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions) stdscr.refresh() stdscr.getch() def test_configuration_tui(stdscr): """Test connection to a configuration""" configs = config_manager.get_configurations() if not configs: show_message(stdscr, "No configurations to test.") return stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Test Configuration Connection" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # List configurations config_names = list(configs.keys()) selection = 0 while True: stdscr.clear() stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) stdscr.addstr(2, 0, "Select configuration to test:") for idx, name in enumerate(config_names): y = 3 + idx if y >= h - 4: # Prevent overflow break if idx == selection: stdscr.attron(curses.A_REVERSE) stdscr.addstr(y, 2, f"> {name}") stdscr.attroff(curses.A_REVERSE) else: stdscr.addstr(y, 2, f" {name}") stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to test, 'c' to cancel") stdscr.refresh() key = stdscr.getch() if key == curses.KEY_UP and selection > 0: selection -= 1 elif key == curses.KEY_DOWN and selection < len(config_names) - 1: selection += 1 elif key == curses.KEY_ENTER or key in [10, 13]: # Test configuration name_to_test = config_names[selection] show_message(stdscr, f"Testing connection to '{name_to_test}'...", False) success, message = config_manager.test_configuration_connection(name_to_test) if success: show_message(stdscr, f"✅ {message}") else: show_message(stdscr, f"❌ {message}") return elif key == ord('c') or key == ord('C'): show_message(stdscr, "Cancelled.") return def export_configurations_tui(stdscr): """Export configurations to JSON or YAML""" configs = config_manager.get_configurations() if not configs: show_message(stdscr, "No configurations to export.") return stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Export Configurations" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # Select format formats = ["JSON", "YAML"] format_selection = 0 while True: stdscr.clear() stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) stdscr.addstr(2, 0, "Select export format:") for idx, fmt in enumerate(formats): y = 3 + idx if idx == format_selection: stdscr.attron(curses.A_REVERSE) stdscr.addstr(y, 2, f"> {fmt}") stdscr.attroff(curses.A_REVERSE) else: stdscr.addstr(y, 2, f" {fmt}") stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel") stdscr.refresh() key = stdscr.getch() if key == curses.KEY_UP and format_selection > 0: format_selection -= 1 elif key == curses.KEY_DOWN and format_selection < len(formats) - 1: format_selection += 1 elif key == curses.KEY_ENTER or key in [10, 13]: break elif key == ord('c') or key == ord('C'): show_message(stdscr, "Cancelled.") return # Get filename filename = get_string_input(stdscr, "Filename (without extension): ", 5, 0, 30) if not filename: show_message(stdscr, "Cancelled.") return # Export configurations try: if format_selection == 0: # JSON export_file = Path(f"{filename}.json") with open(export_file, 'w') as f: json.dump(configs, f, indent=2) else: # YAML export_file = Path(f"{filename}.yaml") with open(export_file, 'w') as f: yaml.dump(configs, f, default_flow_style=False) show_message(stdscr, f"Configurations exported to {export_file}") except Exception as e: show_message(stdscr, f"Error exporting configurations: {e}") def import_configurations_tui(stdscr): """Import configurations from JSON or YAML""" stdscr.clear() h, w = stdscr.getmaxyx() # Title title = "Import Configurations" stdscr.attron(curses.A_BOLD) stdscr.addstr(0, (w - len(title)) // 2, title) stdscr.attroff(curses.A_BOLD) # Get filename filename = get_string_input(stdscr, "Filename (with extension .json or .yaml): ", 2, 0, 50) if not filename: show_message(stdscr, "Cancelled.") return # Check if file exists file_path = Path(filename) if not file_path.exists(): show_message(stdscr, f"Error: File '{filename}' not found.") return # Import configurations try: with open(file_path, 'r') as f: if filename.endswith('.json'): imported_configs = json.load(f) elif filename.endswith(('.yaml', '.yml')): imported_configs = yaml.safe_load(f) else: show_message(stdscr, "Error: Unsupported file format. Use .json or .yaml") return # Validate imported configurations if not isinstance(imported_configs, dict): show_message(stdscr, "Error: Invalid configuration format.") return # Ask for confirmation confirm_prompt = f"Import {len(imported_configs)} configurations? (y/n): " confirm = get_string_input(stdscr, confirm_prompt, 4, 0, 1) if confirm.lower() != 'y': show_message(stdscr, "Cancelled.") return # Import configurations imported_count = 0 for name, config in imported_configs.items(): try: config_manager.add_configuration(name, config) imported_count += 1 except Exception as e: show_message(stdscr, f"Warning: Could not import '{name}': {e}") show_message(stdscr, f"Successfully imported {imported_count} configurations.") except Exception as e: show_message(stdscr, f"Error importing configurations: {e}") def main(): """Main entry point for the TUI""" curses.wrapper(main_tui) if __name__ == "__main__": main()