diff --git a/src/codegen/cli/auth/login.py b/src/codegen/cli/auth/login.py index 4205cd8d2..5df4bf252 100644 --- a/src/codegen/cli/auth/login.py +++ b/src/codegen/cli/auth/login.py @@ -4,9 +4,10 @@ import typer from codegen.cli.api.webapp_routes import USER_SECRETS_ROUTE -from codegen.cli.auth.token_manager import TokenManager +from codegen.cli.auth.token_manager import TokenManager, get_cached_organizations, set_default_organization from codegen.cli.env.global_env import global_env from codegen.cli.errors import AuthError +from codegen.cli.utils.simple_selector import simple_org_selector def login_routine(token: str | None = None) -> str: @@ -27,9 +28,8 @@ def login_routine(token: str | None = None) -> str: # If no token provided, guide user through browser flow if not token: - rich.print(f"Opening {USER_SECRETS_ROUTE} to get your authentication token...") webbrowser.open_new(USER_SECRETS_ROUTE) - token = typer.prompt("Please enter your authentication token from the browser", hide_input=False) + token = typer.prompt(f"Enter your token from {USER_SECRETS_ROUTE}", hide_input=False) if not token: rich.print("[red]Error:[/red] Token must be provided via CODEGEN_USER_ACCESS_TOKEN environment variable or manual input") @@ -37,10 +37,38 @@ def login_routine(token: str | None = None) -> str: # Validate and store token try: - rich.print("[blue]Validating token and fetching account info...[/blue]") token_manager = TokenManager() token_manager.authenticate_token(token) rich.print(f"[green]✓ Stored token and profile to:[/green] {token_manager.token_file}") + + # Show organization selector if multiple organizations available + organizations = get_cached_organizations() + if organizations and len(organizations) > 1: + rich.print("\n[blue]Multiple organizations found. Please select your default:[/blue]") + selected_org = simple_org_selector(organizations, title="🏢 Select Default Organization") + + if selected_org: + org_id = selected_org.get("id") + org_name = selected_org.get("name") + try: + set_default_organization(org_id, org_name) + rich.print(f"[green]✓ Set default organization:[/green] {org_name}") + except Exception as e: + rich.print(f"[yellow]Warning: Could not set default organization: {e}[/yellow]") + rich.print("[yellow]You can set it later with 'codegen profile'[/yellow]") + else: + rich.print("[yellow]No organization selected. You can set it later with 'codegen profile'[/yellow]") + elif organizations and len(organizations) == 1: + # Single organization - set it automatically + org = organizations[0] + org_id = org.get("id") + org_name = org.get("name") + try: + set_default_organization(org_id, org_name) + rich.print(f"[green]✓ Set default organization:[/green] {org_name}") + except Exception as e: + rich.print(f"[yellow]Warning: Could not set default organization: {e}[/yellow]") + return token except AuthError as e: rich.print(f"[red]Error:[/red] {e!s}") diff --git a/src/codegen/cli/auth/token_manager.py b/src/codegen/cli/auth/token_manager.py index 2154032c2..3b8bf4418 100644 --- a/src/codegen/cli/auth/token_manager.py +++ b/src/codegen/cli/auth/token_manager.py @@ -61,11 +61,7 @@ def save_token_with_org_info(self, token: str) -> None: # Store ALL organizations in cache for local resolution all_orgs = [{"id": org.get("id"), "name": org.get("name")} for org in orgs] primary_org = orgs[0] # Use first org as primary/default - auth_data["organization"] = { - "id": primary_org.get("id"), - "name": primary_org.get("name"), - "all_orgs": all_orgs - } + auth_data["organization"] = {"id": primary_org.get("id"), "name": primary_org.get("name"), "all_orgs": all_orgs} auth_data["organizations_cache"] = all_orgs # Separate cache for easy access except requests.RequestException as e: @@ -180,7 +176,7 @@ def get_user_info(self) -> dict | None: def get_cached_organizations(self) -> list[dict] | None: """Get all cached organizations. - + Returns: List of organization dictionaries with 'id' and 'name' keys, or None if no cache. """ @@ -194,37 +190,75 @@ def get_cached_organizations(self) -> list[dict] | None: def is_org_id_in_cache(self, org_id: int) -> bool: """Check if an organization ID exists in the local cache. - + Args: org_id: The organization ID to check - + Returns: True if the organization ID is found in cache, False otherwise. """ cached_orgs = self.get_cached_organizations() if not cached_orgs: return False - + return any(org.get("id") == org_id for org in cached_orgs) def get_org_name_from_cache(self, org_id: int) -> str | None: """Get organization name from cache by ID. - + Args: org_id: The organization ID to look up - + Returns: Organization name if found in cache, None otherwise. """ cached_orgs = self.get_cached_organizations() if not cached_orgs: return None - + for org in cached_orgs: if org.get("id") == org_id: return org.get("name") return None + def set_default_organization(self, org_id: int, org_name: str) -> None: + """Set the default organization in auth.json. + + Args: + org_id: The organization ID to set as default + org_name: The organization name + """ + auth_data = self.get_auth_data() + if not auth_data: + msg = "No authentication data found. Please run 'codegen login' first." + raise ValueError(msg) + + # Verify the org exists in cache + if not self.is_org_id_in_cache(org_id): + msg = f"Organization {org_id} not found in cache. Please run 'codegen login' to refresh." + raise ValueError(msg) + + # Update the organization info + auth_data["organization"] = {"id": org_id, "name": org_name, "all_orgs": auth_data.get("organization", {}).get("all_orgs", [])} + + # Save to file + try: + import json + + with open(self.token_file, "w") as f: + json.dump(auth_data, f, indent=2) + + # Secure the file permissions (read/write for owner only) + os.chmod(self.token_file, 0o600) + + # Invalidate cache + global _token_cache, _cache_mtime + _token_cache = None + _cache_mtime = None + except Exception as e: + msg = f"Error saving default organization: {e}" + raise ValueError(msg) + def get_current_token() -> str | None: """Get the current authentication token if one exists. @@ -289,7 +323,7 @@ def get_current_org_name() -> str | None: def get_cached_organizations() -> list[dict] | None: """Get all cached organizations. - + Returns: List of organization dictionaries with 'id' and 'name' keys, or None if no cache. """ @@ -299,10 +333,10 @@ def get_cached_organizations() -> list[dict] | None: def is_org_id_cached(org_id: int) -> bool: """Check if an organization ID exists in the local cache. - + Args: org_id: The organization ID to check - + Returns: True if the organization ID is found in cache, False otherwise. """ @@ -312,10 +346,10 @@ def is_org_id_cached(org_id: int) -> bool: def get_org_name_from_cache(org_id: int) -> str | None: """Get organization name from cache by ID. - + Args: org_id: The organization ID to look up - + Returns: Organization name if found in cache, None otherwise. """ @@ -335,9 +369,10 @@ def get_current_user_info() -> dict | None: # Repository caching functions (similar to organization caching) + def get_cached_repositories() -> list[dict] | None: """Get all cached repositories. - + Returns: List of repository dictionaries with 'id' and 'name' keys, or None if no cache. """ @@ -350,7 +385,7 @@ def get_cached_repositories() -> list[dict] | None: def cache_repositories(repositories: list[dict]) -> None: """Cache repositories to local storage. - + Args: repositories: List of repository dictionaries to cache """ @@ -361,7 +396,8 @@ def cache_repositories(repositories: list[dict]) -> None: # Save back to file try: import json - with open(token_manager.token_file, 'w') as f: + + with open(token_manager.token_file, "w") as f: json.dump(auth_data, f, indent=2) except Exception: pass # Fail silently @@ -369,45 +405,56 @@ def cache_repositories(repositories: list[dict]) -> None: def is_repo_id_cached(repo_id: int) -> bool: """Check if a repository ID exists in the local cache. - + Args: repo_id: The repository ID to check - + Returns: True if the repository ID is found in cache, False otherwise. """ cached_repos = get_cached_repositories() if not cached_repos: return False - + return any(repo.get("id") == repo_id for repo in cached_repos) def get_repo_name_from_cache(repo_id: int) -> str | None: """Get repository name from cache by ID. - + Args: repo_id: The repository ID to look up - + Returns: Repository name if found in cache, None otherwise. """ cached_repos = get_cached_repositories() if not cached_repos: return None - + for repo in cached_repos: if repo.get("id") == repo_id: return repo.get("name") - + return None def get_current_repo_name() -> str | None: """Get the current repository name from environment or cache.""" from codegen.cli.utils.repo import get_current_repo_id - + repo_id = get_current_repo_id() if repo_id: return get_repo_name_from_cache(repo_id) return None + + +def set_default_organization(org_id: int, org_name: str) -> None: + """Set the default organization in auth.json. + + Args: + org_id: The organization ID to set as default + org_name: The organization name + """ + token_manager = TokenManager() + return token_manager.set_default_organization(org_id, org_name) diff --git a/src/codegen/cli/cli.py b/src/codegen/cli/cli.py index 768177221..c7a49ae24 100644 --- a/src/codegen/cli/cli.py +++ b/src/codegen/cli/cli.py @@ -15,7 +15,7 @@ from codegen.cli.commands.login.main import login from codegen.cli.commands.logout.main import logout from codegen.cli.commands.org.main import org -from codegen.cli.commands.profile.main import profile +from codegen.cli.commands.profile.main import profile_app from codegen.cli.commands.repo.main import repo from codegen.cli.commands.style_debug.main import style_debug from codegen.cli.commands.tools.main import tools @@ -42,7 +42,7 @@ def version_callback(value: bool): main.command("login", help="Store authentication token.")(login) main.command("logout", help="Clear stored authentication token.")(logout) main.command("org", help="Manage and switch between organizations.")(org) -main.command("profile", help="Display information about the currently authenticated user.")(profile) +# Profile is now a Typer app main.command("repo", help="Manage repository configuration and environment variables.")(repo) main.command("style-debug", help="Debug command to visualize CLI styling (spinners, etc).")(style_debug) main.command("tools", help="List available tools from the Codegen API.")(tools) @@ -53,6 +53,7 @@ def version_callback(value: bool): main.add_typer(agents_app, name="agents") main.add_typer(config_command, name="config") main.add_typer(integrations_app, name="integrations") +main.add_typer(profile_app, name="profile") @main.callback(invoke_without_command=True) diff --git a/src/codegen/cli/commands/login/main.py b/src/codegen/cli/commands/login/main.py index 3c6137539..688642ac6 100644 --- a/src/codegen/cli/commands/login/main.py +++ b/src/codegen/cli/commands/login/main.py @@ -1,4 +1,3 @@ -import rich import typer from codegen.cli.auth.login import login_routine @@ -9,6 +8,6 @@ def login(token: str | None = typer.Option(None, help="API token for authenticat """Store authentication token.""" # Check if already authenticated if get_current_token(): - rich.print("[yellow]Info:[/yellow] You already have a token stored. Proceeding with re-authentication...") + pass # Just proceed silently with re-authentication login_routine(token) diff --git a/src/codegen/cli/commands/profile/main.py b/src/codegen/cli/commands/profile/main.py index ae49c47a3..a35be06b8 100644 --- a/src/codegen/cli/commands/profile/main.py +++ b/src/codegen/cli/commands/profile/main.py @@ -2,9 +2,7 @@ import requests import typer -from rich import box from rich.console import Console -from rich.panel import Panel from codegen.cli.api.endpoints import API_ENDPOINT from codegen.cli.auth.token_manager import ( @@ -12,15 +10,20 @@ get_current_org_name, get_current_token, get_current_user_info, + set_default_organization, ) from codegen.cli.rich.spinners import create_spinner from codegen.cli.utils.org import resolve_org_id +from codegen.cli.utils.simple_selector import simple_org_selector console = Console() +# Create the profile Typer app +profile_app = typer.Typer(name="profile", help="Manage user profile and organization settings.") -def profile(): - """Display information about the currently authenticated user.""" + +def _get_profile_data() -> dict: + """Get profile data (shared between commands).""" # Get the current token token = get_current_token() if not token: @@ -83,39 +86,107 @@ def profile(): finally: spinner.stop() + return { + "user_id": user_id, + "full_name": full_name, + "email": email, + "github_username": github_username, + "role": role, + "org_name": org_name, + "org_id": org_id, + } + + +@profile_app.callback(invoke_without_command=True) +def profile_main(ctx: typer.Context): + """Display organization selection dropdown or profile info.""" + if ctx.invoked_subcommand is None: + # No subcommand - show organization selector + _show_org_selector() + + +@profile_app.command("list") +def profile_list(): + """List all available organizations.""" + data = _get_profile_data() + cached_orgs = get_cached_organizations() + + if not cached_orgs: + console.print("[yellow]No organizations found. Please run 'codegen login' first.[/yellow]") + return + # Build profile information - profile_info = [] - if user_id != "Unknown": - profile_info.append(f"[cyan]User ID:[/cyan] {user_id}") - if full_name: - profile_info.append(f"[cyan]Name:[/cyan] {full_name}") - if email: - profile_info.append(f"[cyan]Email:[/cyan] {email}") - if github_username: - profile_info.append(f"[cyan]GitHub:[/cyan] {github_username}") - if org_name: - profile_info.append(f"[cyan]Organization:[/cyan] {org_name}") - elif org_id: - profile_info.append(f"[cyan]Organization:[/cyan] Organization {org_id}") + if data["user_id"] != "Unknown": + console.print(f"[dim]User ID:[/dim] [blue]{data['user_id']}[/blue]") + if data["full_name"]: + console.print(f"[dim]Name:[/dim] [blue]{data['full_name']}[/blue]") + if data["email"]: + console.print(f"[dim]Email:[/dim] [blue]{data['email']}[/blue]") + if data["github_username"]: + console.print(f"[dim]GitHub:[/dim] [blue]{data['github_username']}[/blue]") + if data["role"]: + console.print(f"[dim]Role:[/dim] [blue]{data['role']}[/blue]") + + # Current organization + if data["org_name"]: + console.print(f"[dim]Current Org:[/dim] [blue]{data['org_name']} ({data['org_id']})[/blue]") + elif data["org_id"]: + console.print(f"[dim]Current Org:[/dim] [blue]Organization {data['org_id']}[/blue]") else: - profile_info.append("[cyan]Organization:[/cyan] [yellow]Not configured[/yellow] (set CODEGEN_ORG_ID or REPOSITORY_ORG_ID)") - if role: - profile_info.append(f"[cyan]Role:[/cyan] {role}") + console.print("[dim]Current Org:[/dim] [yellow]Not configured[/yellow]") + + console.print() + console.print("[dim]Available Organizations:[/dim]") - # Add available organizations from cache + for org in cached_orgs: + org_id = org.get("id") + org_name = org.get("name") + is_current = " [green](current)[/green]" if org_id == data["org_id"] else "" + console.print(f" • [blue]{org_name}[/blue] [dim](ID: {org_id})[/dim]{is_current}") + + +def _show_org_selector(): + """Show the organization selector.""" cached_orgs = get_cached_organizations() - if cached_orgs and len(cached_orgs) > 1: - org_names = [f"{org['name']} ({org['id']})" for org in cached_orgs] - profile_info.append(f"[cyan]Available Orgs:[/cyan] {', '.join(org_names)}") - - profile_text = "\n".join(profile_info) if profile_info else "No profile information available" - - console.print( - Panel( - profile_text, - title="👤 [bold]User Profile[/bold]", - border_style="cyan", - box=box.ROUNDED, - padding=(1, 2), - ) - ) + + if not cached_orgs: + console.print("[red]Error:[/red] No organizations found. Please run 'codegen login' first.") + raise typer.Exit(1) + + if len(cached_orgs) == 1: + # Only one org, set it as default + org = cached_orgs[0] + org_id = org.get("id") + org_name = org.get("name") + try: + set_default_organization(org_id, org_name) + console.print(f"[green]✓[/green] Set default organization: {org_name} (ID: {org_id})") + except Exception as e: + console.print(f"[red]Error:[/red] Failed to set default organization: {e}") + raise typer.Exit(1) + return + + # Multiple orgs - show simple selector + current_org_id = resolve_org_id() + console.print("[blue]Select your default organization:[/blue]") + + selected_org = simple_org_selector(organizations=cached_orgs, current_org_id=current_org_id, title="👤 Select Default Organization") + + if selected_org: + org_id = selected_org.get("id") + org_name = selected_org.get("name") + try: + set_default_organization(org_id, org_name) + console.print(f"\n[green]✓ Set default organization:[/green] {org_name} (ID: {org_id})") + console.print("[green]✓ Updated ~/.codegen/auth.json[/green]") + except Exception as e: + console.print(f"\n[red]Error:[/red] Failed to set default organization: {e}") + raise typer.Exit(1) + else: + console.print("\n[yellow]No organization selected.[/yellow]") + + +# For backward compatibility, export the profile function +def profile(): + """Display organization selector (legacy function).""" + _show_org_selector() diff --git a/src/codegen/cli/tui/app.py b/src/codegen/cli/tui/app.py index 5b9f6abe1..00dd9af7d 100644 --- a/src/codegen/cli/tui/app.py +++ b/src/codegen/cli/tui/app.py @@ -11,9 +11,10 @@ import typer from codegen.cli.api.endpoints import API_ENDPOINT -from codegen.cli.auth.token_manager import get_current_token +from codegen.cli.auth.token_manager import get_current_org_name, get_current_token from codegen.cli.commands.agent.main import pull from codegen.cli.utils.org import resolve_org_id +from codegen.cli.utils.url import generate_webapp_url, get_domain class MinimalTUI: @@ -44,23 +45,11 @@ def __init__(self): def _get_webapp_domain(self) -> str: """Get the webapp domain based on environment.""" - # Simple environment detection - can be expanded later - import os - - env = os.getenv("ENV", "staging").lower() - - if env == "production": - return "codegen.com" - elif env == "local": - return "localhost:3000" - else: # staging or default - return "chadcode.sh" + return get_domain() def _generate_agent_url(self, agent_id: str) -> str: """Generate the complete agent URL.""" - domain = self._get_webapp_domain() - protocol = "http" if "localhost" in domain else "https" - return f"{protocol}://{domain}/x/{agent_id}" + return generate_webapp_url(f"x/{agent_id}") def _signal_handler(self, signum, frame): """Handle Ctrl+C gracefully without clearing screen.""" @@ -68,6 +57,23 @@ def _signal_handler(self, signum, frame): print("\n") # Just add a newline and exit sys.exit(0) + def _format_status_line(self, left_text: str) -> str: + """Format status line with instructions and org info on a new line below.""" + # Get organization name + org_name = get_current_org_name() + if not org_name: + org_name = f"Org {self.org_id}" if hasattr(self, "org_id") and self.org_id else "No Org" + + # Use the same purple color as the Codegen logo + purple_color = "\033[38;2;82;19;217m" + reset_color = "\033[0m" + + # Return instructions on first line, org on second line (bottom left) + instructions_line = f"\033[90m{left_text}\033[0m" + org_line = f"{purple_color}• {org_name}{reset_color}" + + return f"{instructions_line}\n{org_line}" + def _load_agent_runs(self) -> bool: """Load the last 10 agent runs.""" if not self.token or not self.org_id: @@ -338,9 +344,13 @@ def _show_post_creation_menu(self, web_url: str): def _display_web_tab(self): """Display the web interface access tab.""" + # Generate the proper domain-based URL for display + me_url = generate_webapp_url("me") + display_url = me_url.replace("https://", "").replace("http://", "") + print("Open Web Interface:") print() - print(" \033[34m→ Open Web (localhost:3000/me)\033[0m") + print(f" \033[34m→ Open Web ({display_url})\033[0m") print() print("Press Enter to open the web interface in your browser.") @@ -570,7 +580,8 @@ def _handle_web_tab_keypress(self, key: str): try: import webbrowser - webbrowser.open("http://localhost:3000/me") + me_url = generate_webapp_url("me") + webbrowser.open(me_url) print("\n✅ Opening web interface in browser...") except Exception as e: print(f"\n❌ Failed to open browser: {e}") @@ -641,17 +652,17 @@ def _clear_and_redraw(self): # Show appropriate instructions based on context if self.input_mode and self.current_tab == 1: # new tab input mode - print("\n\033[90mType your prompt • [Enter] create • [Esc] cancel • [Tab] switch tabs • [Ctrl+C] quit\033[0m") + print(f"\n{self._format_status_line('Type your prompt • [Enter] create • [Esc] cancel • [Tab] switch tabs • [Ctrl+C] quit')}") elif self.input_mode: # other input modes - print("\n\033[90mType your prompt • [Enter] create • [Esc] cancel • [Ctrl+C] quit\033[0m") + print(f"\n{self._format_status_line('Type your prompt • [Enter] create • [Esc] cancel • [Ctrl+C] quit')}") elif self.show_action_menu: - print("\n\033[90m[Enter] select • [↑↓] navigate • [C] close • [Q] quit\033[0m") + print(f"\n{self._format_status_line('[Enter] select • [↑↓] navigate • [C] close • [Q] quit')}") elif self.current_tab == 0: # recents - print("\n\033[90m[Tab] switch tabs • (↑↓) navigate • (←→) open/close • [Enter] actions • [R] refresh • [Q] quit\033[0m") + print(f"\n{self._format_status_line('[Tab] switch tabs • (↑↓) navigate • (←→) open/close • [Enter] actions • [R] refresh • [Q] quit')}") elif self.current_tab == 1: # new - print("\n\033[90m[Tab] switch tabs • [Enter] start typing • [Q] quit\033[0m") + print(f"\n{self._format_status_line('[Tab] switch tabs • [Enter] start typing • [Q] quit')}") elif self.current_tab == 2: # web - print("\n\033[90m[Tab] switch tabs • [Enter] open web • [Q] quit\033[0m") + print(f"\n{self._format_status_line('[Tab] switch tabs • [Enter] open web • [Q] quit')}") def run(self): """Run the minimal TUI.""" diff --git a/src/codegen/cli/utils/simple_selector.py b/src/codegen/cli/utils/simple_selector.py new file mode 100644 index 000000000..7a15e71db --- /dev/null +++ b/src/codegen/cli/utils/simple_selector.py @@ -0,0 +1,176 @@ +"""Simple terminal-based selector utility.""" + +import signal +import sys +import termios +import tty +from typing import Any + + +def _get_char(): + """Get a single character from stdin, handling arrow keys.""" + try: + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + ch = sys.stdin.read(1) + + # Handle escape sequences (arrow keys) + if ch == "\x1b": # ESC + ch2 = sys.stdin.read(1) + if ch2 == "[": + ch3 = sys.stdin.read(1) + return f"\x1b[{ch3}" + else: + return ch + ch2 + return ch + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + except (ImportError, OSError, termios.error): + # Fallback for systems where tty manipulation doesn't work + print("\nUse: ↑(w)/↓(s) navigate, Enter select, q quit") + try: + return input("> ").strip()[:1].lower() or "\n" + except KeyboardInterrupt: + return "q" + + +def simple_select(title: str, options: list[dict[str, Any]], display_key: str = "name", show_help: bool = True, allow_cancel: bool = True) -> dict[str, Any] | None: + """Show a simple up/down selector for choosing from options. + + Args: + title: Title to display above the options + options: List of option dictionaries + display_key: Key to use for displaying option text + show_help: Whether to show navigation help text + allow_cancel: Whether to allow canceling with Esc/q + + Returns: + Selected option dictionary or None if canceled + """ + if not options: + print("No options available.") + return None + + if len(options) == 1: + # Only one option, select it automatically + return options[0] + + selected = 0 + running = True + + # Set up signal handler for Ctrl+C + def signal_handler(signum, frame): + nonlocal running + running = False + print("\n") + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + try: + print(f"\n{title}") + print() + + # Initial display + for i, option in enumerate(options): + display_text = str(option.get(display_key, f"Option {i + 1}")) + if i == selected: + print(f" \033[34m→ {display_text}\033[0m") + else: + print(f" \033[90m {display_text}\033[0m") + + if show_help: + print() + help_text = "[Enter] select • [↑↓] navigate" + if allow_cancel: + help_text += " • [q/Esc] cancel" + print(f"\033[90m{help_text}\033[0m") + + while running: + # Get input + key = _get_char() + + if key == "\x1b[A" or key.lower() == "w": # Up arrow or W + selected = max(0, selected - 1) + # Redraw options only + lines_to_move = len(options) + (2 if show_help else 0) + print(f"\033[{lines_to_move}A", end="") # Move cursor up to start of options + for i, option in enumerate(options): + display_text = str(option.get(display_key, f"Option {i + 1}")) + if i == selected: + print(f" \033[34m→ {display_text}\033[0m\033[K") # Clear to end of line + else: + print(f" \033[90m {display_text}\033[0m\033[K") # Clear to end of line + if show_help: + print("\033[K") # Clear help line + print(f"\033[90m{help_text}\033[0m\033[K") # Redraw help + + elif key == "\x1b[B" or key.lower() == "s": # Down arrow or S + selected = min(len(options) - 1, selected + 1) + # Redraw options only + lines_to_move = len(options) + (2 if show_help else 0) + print(f"\033[{lines_to_move}A", end="") # Move cursor up to start of options + for i, option in enumerate(options): + display_text = str(option.get(display_key, f"Option {i + 1}")) + if i == selected: + print(f" \033[34m→ {display_text}\033[0m\033[K") # Clear to end of line + else: + print(f" \033[90m {display_text}\033[0m\033[K") # Clear to end of line + if show_help: + print("\033[K") # Clear help line + print(f"\033[90m{help_text}\033[0m\033[K") # Redraw help + + elif key == "\r" or key == "\n": # Enter - select option + return options[selected] + elif allow_cancel and (key.lower() == "q" or key == "\x1b"): # q or Esc - cancel + return None + elif key == "\x03": # Ctrl+C + running = False + break + + except KeyboardInterrupt: + return None + finally: + # Restore signal handler + signal.signal(signal.SIGINT, signal.SIG_DFL) + + return None + + +def simple_org_selector(organizations: list[dict], current_org_id: int | None = None, title: str = "Select Organization") -> dict | None: + """Show a simple organization selector. + + Args: + organizations: List of organization dictionaries with 'id' and 'name' + current_org_id: Currently selected organization ID (for display) + title: Title to show above selector + + Returns: + Selected organization dictionary or None if canceled + """ + if not organizations: + print("No organizations available.") + return None + + # Format organizations for display with current indicator + display_orgs = [] + for org in organizations: + org_id = org.get("id") + org_name = org.get("name", f"Organization {org_id}") + + # Add current indicator + if org_id == current_org_id: + display_name = f"{org_name} (current)" + else: + display_name = org_name + + display_orgs.append( + { + **org, # Keep original org data + "display_name": display_name, + } + ) + + return simple_select(title=title, options=display_orgs, display_key="display_name", show_help=True, allow_cancel=True)