From 7038120b288b2c3c41cca4a30278e0d8869e46c9 Mon Sep 17 00:00:00 2001 From: Mike Morgan Date: Mon, 12 Jan 2026 05:56:13 -0700 Subject: [PATCH] feat(ui): Add Rich-based terminal UI module Complete UI overhaul with modern CLI patterns: - Console: Themed singleton with semantic message methods (success, error, warning, info, command, step) - Prompts: 6-option command menu (run/trust/dry-run/explain/edit/cancel) with keyboard navigation and Tab to add instructions - Progress: Spinners, progress bars, and step counters - Errors: Structured error panels with suggested fixes Auto-detection for common errors (container conflict, port in use, etc.) - Explain: Command breakdown with explanations for docker, apt, pip, git - Trust: Session and global trust levels to reduce prompt friction - Panels: AI response panels, status panels, code panels, diff panels Patterns based on Claude Code, Cursor, and GitHub Copilot CLI. Closes #[UX issues] --- cortex/ui/__init__.py | 21 ++++++ cortex/ui/console.py | 78 ++++++++++++++++++++++ cortex/ui/errors.py | 97 +++++++++++++++++++++++++++ cortex/ui/explain.py | 103 +++++++++++++++++++++++++++++ cortex/ui/panels.py | 67 +++++++++++++++++++ cortex/ui/progress.py | 66 +++++++++++++++++++ cortex/ui/prompts.py | 149 ++++++++++++++++++++++++++++++++++++++++++ cortex/ui/theme.py | 60 +++++++++++++++++ cortex/ui/trust.py | 112 +++++++++++++++++++++++++++++++ tests/test_ui.py | 64 ++++++++++++++++++ 10 files changed, 817 insertions(+) create mode 100644 cortex/ui/__init__.py create mode 100644 cortex/ui/console.py create mode 100644 cortex/ui/errors.py create mode 100644 cortex/ui/explain.py create mode 100644 cortex/ui/panels.py create mode 100644 cortex/ui/progress.py create mode 100644 cortex/ui/prompts.py create mode 100644 cortex/ui/theme.py create mode 100644 cortex/ui/trust.py create mode 100644 tests/test_ui.py diff --git a/cortex/ui/__init__.py b/cortex/ui/__init__.py new file mode 100644 index 00000000..66626bb1 --- /dev/null +++ b/cortex/ui/__init__.py @@ -0,0 +1,21 @@ +"""Cortex UI - Modern terminal interface components.""" + +from .console import console, CortexConsole +from .theme import COLORS, SYMBOLS, CORTEX_THEME, PANEL_STYLES +from .prompts import CommandPrompt, MenuAction, PromptResult, confirm, select +from .progress import spinner, progress_bar, steps, indeterminate_progress, download_progress +from .errors import show_error, show_conflict, show_warning, auto_suggest_fix, SuggestedFix +from .explain import explain_command, explain_pipeline, CommandPart +from .trust import TrustManager, TrustScope, trust_manager +from .panels import ai_thinking, ai_response, status_panel, code_panel, diff_panel, summary_panel, welcome_banner, help_footer + +__all__ = [ + "console", "CortexConsole", "COLORS", "SYMBOLS", "CORTEX_THEME", "PANEL_STYLES", + "CommandPrompt", "MenuAction", "PromptResult", "confirm", "select", + "spinner", "progress_bar", "steps", "indeterminate_progress", "download_progress", + "show_error", "show_conflict", "show_warning", "auto_suggest_fix", "SuggestedFix", + "explain_command", "explain_pipeline", "CommandPart", + "TrustManager", "TrustScope", "trust_manager", + "ai_thinking", "ai_response", "status_panel", "code_panel", "diff_panel", "summary_panel", "welcome_banner", "help_footer", +] +__version__ = "0.1.0" diff --git a/cortex/ui/console.py b/cortex/ui/console.py new file mode 100644 index 00000000..b7a2b751 --- /dev/null +++ b/cortex/ui/console.py @@ -0,0 +1,78 @@ +"""Cortex Console - Themed console singleton with semantic message methods.""" + +from typing import Optional +from rich.console import Console as RichConsole +from rich.panel import Panel +from rich.syntax import Syntax +from rich.markdown import Markdown +from .theme import CORTEX_THEME, SYMBOLS, PANEL_STYLES + + +class CortexConsole: + """Themed console with semantic message methods.""" + + _instance: Optional['CortexConsole'] = None + + def __new__(cls) -> 'CortexConsole': + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._console = RichConsole(theme=CORTEX_THEME) + return cls._instance + + @property + def rich(self) -> RichConsole: + return self._console + + def print(self, *args, **kwargs) -> None: + self._console.print(*args, **kwargs) + + def success(self, message: str) -> None: + self._console.print(f"[success_symbol]{SYMBOLS['success']}[/] [success]{message}[/]") + + def error(self, message: str, details: Optional[str] = None) -> None: + self._console.print(f"[error_symbol]{SYMBOLS['error']}[/] [error]{message}[/]") + if details: + self._console.print(f" [secondary]{details}[/]") + + def warning(self, message: str) -> None: + self._console.print(f"[warning_symbol]{SYMBOLS['warning']}[/] [warning]{message}[/]") + + def info(self, message: str) -> None: + self._console.print(f"[info_symbol]{SYMBOLS['info']}[/] [info]{message}[/]") + + def command(self, cmd: str) -> None: + self._console.print(f"[command]{SYMBOLS['command']} {cmd}[/]") + + def step(self, message: str, current: int, total: int) -> None: + self._console.print(f"[info_symbol]{SYMBOLS['step']}[/] [info]{message}[/] [secondary]({current}/{total})[/]") + + def thinking(self, message: str) -> None: + self._console.print(f"[cortex]{SYMBOLS['thinking']} {message}[/]") + + def secondary(self, message: str) -> None: + self._console.print(f" [secondary]{message}[/]") + + def blank(self) -> None: + self._console.print() + + def rule(self, title: str = "") -> None: + self._console.rule(title, style="panel_border") + + def code(self, code: str, language: str = "bash") -> None: + syntax = Syntax(code, language, theme="monokai", line_numbers=False) + self._console.print(syntax) + + def markdown(self, text: str) -> None: + self._console.print(Markdown(text)) + + def cortex_panel(self, content: str, title: str = "CORTEX") -> None: + panel = Panel(content, title=f"[cortex]─ {title} [/]", **PANEL_STYLES["cortex"]) + self._console.print(panel) + + def panel(self, content: str, title: str = "", style: str = "default") -> None: + panel_style = PANEL_STYLES.get(style, PANEL_STYLES["default"]) + panel = Panel(content, title=f"─ {title} " if title else None, **panel_style) + self._console.print(panel) + + +console = CortexConsole() diff --git a/cortex/ui/errors.py b/cortex/ui/errors.py new file mode 100644 index 00000000..405ac7c4 --- /dev/null +++ b/cortex/ui/errors.py @@ -0,0 +1,97 @@ +"""Cortex Errors - Structured error display with context and fixes.""" + +from typing import Optional, Dict, List +from dataclasses import dataclass +import re +from rich.panel import Panel +from .theme import SYMBOLS +from .console import console + + +@dataclass +class SuggestedFix: + command: str + description: Optional[str] = None + + +COMMON_ERRORS = { + "container_conflict": {"pattern": "container name .* already in use", "title": "Container Name Conflict", "fix_template": "docker rm -f {container_name}", "fix_description": "Remove the existing container"}, + "port_in_use": {"pattern": "port .* already in use", "title": "Port Already In Use", "fix_template": "lsof -ti:{port} | xargs kill -9", "fix_description": "Kill the process using the port"}, + "permission_denied": {"pattern": "permission denied", "title": "Permission Denied", "fix_template": "sudo {original_command}", "fix_description": "Run with elevated privileges"}, + "not_found": {"pattern": "command not found|not found", "title": "Command Not Found", "fix_template": "apt install {package}", "fix_description": "Install the missing package"}, +} + + +def show_error(title: str, message: str, context: Optional[Dict[str, str]] = None, suggested_fix: Optional[str] = None, fix_description: Optional[str] = None, actions: Optional[List[str]] = None) -> Optional[str]: + lines = [f"[error]{SYMBOLS['error']} FAILED:[/] [primary]{title}[/]", "", f" [error]Error:[/] {message}"] + if context: + lines.append("") + for key, value in context.items(): + display_value = value if len(value) < 50 else value[:47] + "..." + lines.append(f" [secondary]{key}:[/] {display_value}") + + if suggested_fix: + fix_content = [] + if fix_description: + fix_content.append(f"[muted]{fix_description}[/]") + fix_content.append("") + fix_content.append(f"[command]{suggested_fix}[/]") + console.print(Panel("\n".join(lines), border_style="error", padding=(1, 2))) + console.print(Panel("\n".join(fix_content), title="[success]─ SUGGESTED FIX [/]", border_style="success", padding=(0, 2))) + else: + console.print(Panel("\n".join(lines), border_style="error", padding=(1, 2))) + + if actions: + action_text = [f"[highlight][{a[0].upper()}][/][muted]{a[1:]}[/]" for a in actions] + console.print(" " + " ".join(action_text)) + console.print() + while True: + response = input(" > ").strip().lower() + for action in actions: + if response == action[0].lower() or response == action.lower(): + return action + console.warning(f"Please enter one of: {', '.join(a[0] for a in actions)}") + return None + + +def show_conflict(title: str, description: str, options: List[Dict[str, str]]) -> str: + lines = [f"[warning]{SYMBOLS['warning']} CONFLICT DETECTED[/]", "", f"[primary]{description}[/]", "", "[muted]Options:[/]"] + for opt in options: + lines.append(f" [highlight][{opt['key']}][/] {opt['label']}") + if opt.get('description'): + lines.append(f" [secondary]{opt['description']}[/]") + console.print(Panel("\n".join(lines), title=f"[warning]─ {title} [/]", border_style="warning", padding=(1, 2))) + valid_keys = [opt['key'].lower() for opt in options] + while True: + response = input(f"\n {SYMBOLS['prompt']} What would you like to do? ").strip().lower() + if response in valid_keys: + return response + console.warning(f"Please enter one of: {', '.join(valid_keys)}") + + +def show_warning(title: str, message: str, details: Optional[List[str]] = None) -> None: + lines = [f"[warning]{SYMBOLS['warning']} {title}[/]", "", f"[primary]{message}[/]"] + if details: + lines.append("") + for detail in details: + lines.append(f" [secondary]• {detail}[/]") + console.print(Panel("\n".join(lines), border_style="warning", padding=(1, 2))) + + +def auto_suggest_fix(error_message: str, command: str = "") -> Optional[SuggestedFix]: + error_lower = error_message.lower() + for error_type, info in COMMON_ERRORS.items(): + if re.search(info["pattern"], error_lower): + fix_cmd = info["fix_template"] + if error_type == "container_conflict": + match = re.search(r'name ["\']?/?(\w+)', error_lower) + if match: + fix_cmd = fix_cmd.format(container_name=match.group(1)) + elif error_type == "port_in_use": + match = re.search(r'port (\d+)', error_lower) + if match: + fix_cmd = fix_cmd.format(port=match.group(1)) + elif error_type == "permission_denied": + fix_cmd = fix_cmd.format(original_command=command) + return SuggestedFix(command=fix_cmd, description=info["fix_description"]) + return None diff --git a/cortex/ui/explain.py b/cortex/ui/explain.py new file mode 100644 index 00000000..625558c6 --- /dev/null +++ b/cortex/ui/explain.py @@ -0,0 +1,103 @@ +"""Cortex Explain - Command explanation and breakdown.""" + +from typing import List, Tuple +from dataclasses import dataclass +import shlex +from rich.panel import Panel +from .console import console + + +@dataclass +class CommandPart: + text: str + explanation: str + is_flag: bool = False + + +COMMAND_EXPLANATIONS = { + "docker": { + "run": "Creates and starts a new container", "-d": "Runs in background (detached)", "--detach": "Runs in background", + "-v": "Mounts a volume (host:container)", "--volume": "Mounts a volume", "-p": "Maps a port (host:container)", + "--publish": "Maps a port", "--name": "Names the container", "--gpus": "Grants GPU access", + "--rm": "Auto-removes when exits", "-it": "Interactive with TTY", "-e": "Sets environment variable", + "pull": "Downloads an image", "stop": "Stops container", "rm": "Removes container", "-f": "Force operation", + }, + "apt": { + "install": "Installs packages", "update": "Refreshes package index", "upgrade": "Upgrades all packages", + "remove": "Removes package", "purge": "Removes package + config", "-y": "Auto-yes to prompts", + }, + "pip": { + "install": "Installs Python packages", "-e": "Editable/dev mode", "-r": "From requirements file", + "--upgrade": "Upgrades to latest", "-U": "Upgrades to latest", "--break-system-packages": "Allows system Python changes", + }, + "git": { + "clone": "Downloads repository", "pull": "Fetches and merges", "push": "Uploads commits", + "add": "Stages files", "commit": "Records changes", "-m": "Commit message", "checkout": "Switches branches", "-b": "Creates new branch", + }, +} + + +def parse_command(cmd: str) -> Tuple[str, List[str]]: + try: + parts = shlex.split(cmd) + except ValueError: + parts = cmd.split() + return (parts[0], parts[1:]) if parts else ("", []) + + +def get_explanation(base_cmd: str, part: str) -> str: + cmd_exp = COMMAND_EXPLANATIONS.get(base_cmd, {}) + if part in cmd_exp: + return cmd_exp[part] + if part.lstrip('-') in cmd_exp: + return cmd_exp[part.lstrip('-')] + if part.startswith('-'): + return "Option flag" + if ':' in part: + return "Mapping (source:destination)" + if '/' in part and not part.startswith('/'): + return "Image or path reference" + return "Argument or value" + + +def explain_command(cmd: str, show_panel: bool = True) -> List[CommandPart]: + base_cmd, args = parse_command(cmd) + parts = [CommandPart(text=base_cmd, explanation=f"The {base_cmd} command")] + + i = 0 + while i < len(args): + arg = args[i] + exp = get_explanation(base_cmd, arg) + if arg.startswith('-') and i + 1 < len(args) and not args[i + 1].startswith('-'): + parts.append(CommandPart(text=f"{arg} {args[i+1]}", explanation=f"{exp}: {args[i+1]}", is_flag=True)) + i += 2 + else: + parts.append(CommandPart(text=arg, explanation=exp, is_flag=arg.startswith('-'))) + i += 1 + + if show_panel: + lines = [f"[command]{cmd}[/]", ""] + for j, part in enumerate(parts): + lines.append(f"[{'cortex' if j == 0 else 'info'}]{part.text}[/]") + lines.append(f"└─ [secondary]{part.explanation}[/]") + if j < len(parts) - 1: + lines.append("") + console.print(Panel("\n".join(lines), title="[cortex]─ EXPLANATION [/]", border_style="cortex", padding=(1, 2))) + console.print("\n [secondary]Press Enter to return to options[/]") + input() + return parts + + +def explain_pipeline(cmd: str) -> None: + import re + parts = re.split(r'\s*(\||\&\&|\|\|)\s*', cmd) + lines = ["[muted]Pipeline breakdown:[/]", ""] + step = 1 + for part in parts: + if part in ('|', '&&', '||'): + ops = {'|': 'pipes output to', '&&': 'then (if successful)', '||': 'or (if failed)'} + lines.append(f" [warning]{ops.get(part, part)}[/]") + elif part.strip(): + lines.append(f"[info]{step}.[/] [command]{part.strip()}[/]") + step += 1 + console.print(Panel("\n".join(lines), title="[cortex]─ PIPELINE [/]", border_style="cortex", padding=(1, 2))) diff --git a/cortex/ui/panels.py b/cortex/ui/panels.py new file mode 100644 index 00000000..b021793e --- /dev/null +++ b/cortex/ui/panels.py @@ -0,0 +1,67 @@ +"""Cortex Panels - AI response and status panels.""" + +from typing import Optional, List +from rich.panel import Panel +from rich.syntax import Syntax +from rich.text import Text +from rich.console import Group +from .console import console +from .theme import SYMBOLS + + +def ai_thinking(message: str, animated: bool = False) -> None: + content = f"[cortex]{SYMBOLS['thinking']} {message}[/]" + console.print(Panel(content, title="[cortex]─ CORTEX [/]", border_style="cortex", padding=(1, 2))) + + +def ai_response(message: str, title: str = "CORTEX", show_commands: Optional[List[str]] = None, show_code: Optional[str] = None, code_language: str = "bash") -> None: + content_parts = [Text(message)] + if show_commands: + content_parts.append(Text()) + content_parts.append(Text("Commands to execute:", style="muted")) + for cmd in show_commands: + content_parts.append(Text(f" {cmd}", style="command")) + if show_code: + content_parts.append(Text()) + content_parts.append(Syntax(show_code, code_language, theme="monokai", line_numbers=False)) + content = Group(*content_parts) if len(content_parts) > 1 else content_parts[0] + console.print(Panel(content, title=f"[cortex]─ {title} [/]", border_style="cortex", padding=(1, 2))) + + +def status_panel(title: str, items: List[str], style: str = "default") -> None: + content = "\n".join(f" {item}" for item in items) + border = {"default": "panel_border", "success": "success", "warning": "warning", "error": "error"}.get(style, "panel_border") + console.print(Panel(content, title=f"─ {title} ", border_style=border, padding=(1, 2))) + + +def code_panel(code: str, language: str = "bash", title: str = "Code") -> None: + console.print(Panel(Syntax(code, language, theme="monokai", line_numbers=True), title=f"─ {title} ", border_style="panel_border", padding=(0, 1))) + + +def diff_panel(additions: List[str], deletions: List[str], title: str = "Changes") -> None: + lines = [f"[error]- {line}[/]" for line in deletions] + [f"[success]+ {line}[/]" for line in additions] + console.print(Panel("\n".join(lines), title=f"─ {title} ", border_style="panel_border", padding=(0, 2))) + + +def summary_panel(title: str, summary: str, details: Optional[dict] = None) -> None: + lines = [summary] + if details: + lines.append("") + for key, value in details.items(): + lines.append(f"[muted]{key}:[/] {value}") + console.print(Panel("\n".join(lines), title=f"[cortex]─ {title} [/]", border_style="cortex", padding=(1, 2))) + + +def welcome_banner() -> None: + banner = """[cortex] ____ _ + / ___|___ _ __| |_ _____ __ + | | / _ \\| '__| __/ _ \\ \\/ / + | |__| (_) | | | || __/> < + \\____\\___/|_| \\__\\___/_/\\_\\[/] + +[muted]The AI Layer for Linux[/]""" + console.print(banner) + + +def help_footer() -> None: + console.print("\n [secondary]esc[/] [muted]Cancel[/] · [secondary]tab[/] [muted]Add instructions[/] · [secondary]?[/] [muted]Help[/]") diff --git a/cortex/ui/progress.py b/cortex/ui/progress.py new file mode 100644 index 00000000..10f47484 --- /dev/null +++ b/cortex/ui/progress.py @@ -0,0 +1,66 @@ +"""Cortex Progress - Progress indicators and spinners.""" + +from typing import Optional +from contextlib import contextmanager +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn +from .console import console + + +class StepsProgress: + def __init__(self, title: str, total: int): + self.title = title + self.total = total + self.current = 0 + + def step(self, name: str) -> None: + self.current += 1 + console.step(name, self.current, self.total) + + def complete(self) -> None: + console.success(f"{self.title} complete") + + +@contextmanager +def spinner(message: str): + progress = Progress(SpinnerColumn(style="cortex"), TextColumn("[info]{task.description}[/]"), console=console.rich, transient=True) + with progress: + progress.add_task(message, total=None) + yield progress + + +@contextmanager +def progress_bar(description: str, total: float, show_eta: bool = True): + columns = [SpinnerColumn(style="cortex"), TextColumn("[info]{task.description}[/]"), BarColumn(bar_width=30, style="secondary", complete_style="cortex", finished_style="success"), TaskProgressColumn()] + if show_eta: + columns.append(TimeRemainingColumn()) + progress = Progress(*columns, console=console.rich, transient=True) + + with progress: + task_id = progress.add_task(description, total=total) + + class ProgressWrapper: + def advance(self, amount: float = 1): + progress.update(task_id, advance=amount) + def update(self, completed: float): + progress.update(task_id, completed=completed) + + yield ProgressWrapper() + + +@contextmanager +def steps(title: str, total: int): + tracker = StepsProgress(title, total) + console.info(f"{title} ({total} steps)") + try: + yield tracker + finally: + if tracker.current >= tracker.total: + tracker.complete() + + +def indeterminate_progress(message: str) -> Progress: + return Progress(SpinnerColumn(style="cortex"), TextColumn("[info]{task.description}[/]"), console=console.rich, transient=True) + + +def download_progress(description: str, total: float) -> Progress: + return Progress(SpinnerColumn(style="cortex"), TextColumn("[info]{task.description}[/]"), BarColumn(bar_width=30, style="secondary", complete_style="cortex", finished_style="success"), TaskProgressColumn(), "•", TimeRemainingColumn(), console=console.rich, transient=True) diff --git a/cortex/ui/prompts.py b/cortex/ui/prompts.py new file mode 100644 index 00000000..db31e14a --- /dev/null +++ b/cortex/ui/prompts.py @@ -0,0 +1,149 @@ +"""Cortex Prompts - Interactive prompt system with modern UX patterns.""" + +import sys +from enum import Enum +from typing import Optional, List +from dataclasses import dataclass +from rich.panel import Panel +from .theme import SYMBOLS, PANEL_STYLES +from .console import console + + +class MenuAction(Enum): + RUN = "run" + TRUST = "trust" + DRY_RUN = "dry_run" + EXPLAIN = "explain" + EDIT = "edit" + CANCEL = "cancel" + ADD_INSTRUCTIONS = "add_instructions" + + +@dataclass +class MenuOption: + key: str + label: str + action: MenuAction + description: str = "" + + +@dataclass +class PromptResult: + action: MenuAction + command: str + additional_instructions: Optional[str] = None + trust_scope: Optional[str] = None + + +COMMAND_MENU_OPTIONS = [ + MenuOption("1", "Run it", MenuAction.RUN, "Execute the command now"), + MenuOption("2", "Run, and trust similar this session", MenuAction.TRUST, "Don't ask again for this command type"), + MenuOption("3", "Dry run (preview)", MenuAction.DRY_RUN, "Show what would happen without executing"), + MenuOption("4", "Explain this command", MenuAction.EXPLAIN, "Break down what each part does"), + MenuOption("5", "Edit before running", MenuAction.EDIT, "Modify the command first"), + MenuOption("6", "Cancel", MenuAction.CANCEL, "Abort this operation"), +] + + +class CommandPrompt: + def __init__(self, command: str, context: str = "", commands: Optional[List[str]] = None, working_dir: Optional[str] = None): + self.command = command + self.commands = commands or [command] + self.context = context + self.working_dir = working_dir + self.selected_index = 0 + self.additional_instructions: Optional[str] = None + + def _render_command_panel(self) -> Panel: + content_lines = [] + if self.context: + content_lines.append(f"[cortex]{SYMBOLS['thinking']} {self.context}[/]") + content_lines.append("") + content_lines.append("[muted]Commands to execute:[/]") + for cmd in self.commands: + content_lines.append(f" [command]{cmd}[/]") + if self.working_dir: + content_lines.append("") + content_lines.append(f"[secondary]Directory: {self.working_dir}[/]") + return Panel("\n".join(content_lines), title="[cortex]─ CORTEX [/]", border_style="cortex", padding=(1, 2)) + + def _render_menu(self) -> str: + lines = ["", " [muted]How would you like to proceed?[/]", ""] + for i, option in enumerate(COMMAND_MENU_OPTIONS): + if i == self.selected_index: + lines.append(f" [highlight]{SYMBOLS['prompt']}[/] [highlight]{option.key}. {option.label}[/]") + else: + lines.append(f" [primary]{option.key}. {option.label}[/]") + lines.append("") + lines.append(" [secondary]esc[/] [muted]Cancel[/] · [secondary]tab[/] [muted]Add instructions[/] · [secondary]?[/] [muted]Help[/]") + return "\n".join(lines) + + def show(self) -> PromptResult: + console.print(self._render_command_panel()) + console.print(self._render_menu()) + + while True: + try: + response = input("\n > ").strip() + except (KeyboardInterrupt, EOFError): + return PromptResult(action=MenuAction.CANCEL, command=self.command) + + if response in '123456': + return PromptResult(action=COMMAND_MENU_OPTIONS[int(response) - 1].action, command=self.command, additional_instructions=self.additional_instructions) + elif response.lower() in ('q', 'esc', 'cancel'): + return PromptResult(action=MenuAction.CANCEL, command=self.command) + elif response == '?': + return PromptResult(action=MenuAction.EXPLAIN, command=self.command) + elif response.lower() == 'd': + return PromptResult(action=MenuAction.DRY_RUN, command=self.command) + elif response.lower() == 'e': + return PromptResult(action=MenuAction.EDIT, command=self.command) + else: + console.warning("Please enter 1-6, or use shortcuts: d=dry run, e=edit, q=cancel") + + +def confirm(message: str, details: Optional[List[str]] = None, default: bool = True, allow_dont_ask: bool = True) -> tuple: + content_lines = [f"[primary]{message}[/]"] + if details: + content_lines.append("") + for detail in details: + content_lines.append(f" [muted]•[/] {detail}") + content_lines.append("") + options = ["[highlight][Y][/] [primary]Yes[/]" if default else "[muted][y][/] [muted]Yes[/]"] + options.append("[muted][n][/] [muted]No[/]" if default else "[highlight][N][/] [primary]No[/]") + if allow_dont_ask: + options.append("[muted][a][/] [muted]Yes, don't ask again[/]") + content_lines.append(" " + " ".join(options)) + console.print(Panel("\n".join(content_lines), title="[highlight]─ ACTION REQUIRED [/]", border_style="highlight", padding=(1, 2))) + + while True: + response = input(" > ").strip().lower() + if response in ('', 'y', 'yes') and default: + return (True, False) + elif response in ('y', 'yes'): + return (True, False) + elif response in ('n', 'no'): + return (False, False) + elif response == 'a' and allow_dont_ask: + return (True, True) + elif response == '' and not default: + return (False, False) + console.warning("Please enter Y, N" + (", or A" if allow_dont_ask else "")) + + +def select(message: str, options: List[str], default: int = 0) -> int: + console.print(f"\n [muted]{message}[/]\n") + for i, option in enumerate(options): + prefix = f" [highlight]{SYMBOLS['prompt']}[/]" if i == default else " " + style = "highlight" if i == default else "muted" + console.print(f"{prefix} [{style}]{i + 1}. {option}[/]") + console.print("\n [secondary]Enter number to select[/]") + + while True: + try: + response = input(" > ").strip() + if response.isdigit() and 0 < int(response) <= len(options): + return int(response) - 1 + console.warning(f"Please enter 1-{len(options)}") + except (KeyboardInterrupt, EOFError): + return -1 diff --git a/cortex/ui/theme.py b/cortex/ui/theme.py new file mode 100644 index 00000000..1c12aaa6 --- /dev/null +++ b/cortex/ui/theme.py @@ -0,0 +1,60 @@ +""" +Cortex UI Theme - Color constants and styling definitions. +Based on modern CLI patterns from Claude Code, Cursor, and GitHub Copilot. +""" + +from rich.theme import Theme +from rich.style import Style + +COLORS = { + "success": "#22c55e", + "error": "#ef4444", + "warning": "#eab308", + "info": "#3b82f6", + "command": "#06b6d4", + "secondary": "#6b7280", + "primary": "#ffffff", + "cortex": "#8b5cf6", + "cortex_dim": "#7c3aed", + "panel_border": "#4b5563", + "highlight": "#fbbf24", + "muted": "#9ca3af", +} + +SYMBOLS = { + "success": "✓", + "error": "✗", + "warning": "⚠", + "info": "●", + "command": "→", + "thinking": "🔍", + "step": "●", + "prompt": "❯", +} + +CORTEX_THEME = Theme({ + "success": Style(color=COLORS["success"], bold=True), + "error": Style(color=COLORS["error"], bold=True), + "warning": Style(color=COLORS["warning"], bold=True), + "info": Style(color=COLORS["info"]), + "command": Style(color=COLORS["command"], dim=True), + "secondary": Style(color=COLORS["secondary"], dim=True), + "primary": Style(color=COLORS["primary"]), + "cortex": Style(color=COLORS["cortex"], bold=True), + "cortex_dim": Style(color=COLORS["cortex_dim"]), + "highlight": Style(color=COLORS["highlight"], bold=True), + "muted": Style(color=COLORS["muted"]), + "panel_border": Style(color=COLORS["panel_border"]), + "success_symbol": Style(color=COLORS["success"]), + "error_symbol": Style(color=COLORS["error"]), + "warning_symbol": Style(color=COLORS["warning"]), + "info_symbol": Style(color=COLORS["info"]), +}) + +PANEL_STYLES = { + "default": {"border_style": "panel_border", "title_align": "left", "padding": (1, 2)}, + "cortex": {"border_style": "cortex", "title_align": "left", "padding": (1, 2)}, + "error": {"border_style": "error", "title_align": "left", "padding": (1, 2)}, + "warning": {"border_style": "warning", "title_align": "left", "padding": (1, 2)}, + "action": {"border_style": "highlight", "title_align": "left", "padding": (1, 2)}, +} diff --git a/cortex/ui/trust.py b/cortex/ui/trust.py new file mode 100644 index 00000000..670caec0 --- /dev/null +++ b/cortex/ui/trust.py @@ -0,0 +1,112 @@ +"""Cortex Trust - Trust level management for reducing prompt friction.""" + +import os +import json +from pathlib import Path +from typing import Optional, Set, Dict, List +from enum import Enum + + +class TrustScope(Enum): + COMMAND = "command" + COMMAND_TYPE = "type" + DIRECTORY = "directory" + GLOBAL = "global" + + +class TrustManager: + CONFIG_FILE = Path.home() / ".config" / "cortex" / "trust.json" + + def __init__(self): + self._session_commands: Set[str] = set() + self._session_command_types: Set[str] = set() + self._session_directories: Set[str] = set() + self._global_commands: Set[str] = set() + self._global_command_types: Set[str] = set() + self._load_global_trust() + + def _load_global_trust(self) -> None: + if self.CONFIG_FILE.exists(): + try: + with open(self.CONFIG_FILE) as f: + data = json.load(f) + self._global_commands = set(data.get("commands", [])) + self._global_command_types = set(data.get("command_types", [])) + except (json.JSONDecodeError, IOError): + pass + + def _save_global_trust(self) -> None: + self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(self.CONFIG_FILE, 'w') as f: + json.dump({"commands": list(self._global_commands), "command_types": list(self._global_command_types)}, f, indent=2) + + def _get_command_type(self, command: str) -> str: + parts = command.strip().split() + if not parts: + return "" + base = parts[0] + if len(parts) > 1 and base in ('docker', 'git', 'apt', 'pip', 'npm', 'systemctl'): + return f"{base} {parts[1]}" + return base + + def is_trusted(self, command: str, directory: Optional[str] = None) -> bool: + command = command.strip() + command_type = self._get_command_type(command) + if command in self._session_commands or command in self._global_commands: + return True + if command_type in self._session_command_types or command_type in self._global_command_types: + return True + if directory: + directory = os.path.abspath(directory) + for trusted_dir in self._session_directories: + if directory.startswith(trusted_dir): + return True + return False + + def add_session_trust(self, command: str, scope: TrustScope = TrustScope.COMMAND_TYPE) -> None: + if scope == TrustScope.COMMAND: + self._session_commands.add(command.strip()) + elif scope == TrustScope.COMMAND_TYPE: + self._session_command_types.add(self._get_command_type(command)) + + def add_directory_trust(self, directory: str) -> None: + self._session_directories.add(os.path.abspath(directory)) + + def add_global_trust(self, command: str, scope: TrustScope = TrustScope.COMMAND_TYPE) -> None: + if scope == TrustScope.COMMAND: + self._global_commands.add(command.strip()) + elif scope == TrustScope.COMMAND_TYPE: + self._global_command_types.add(self._get_command_type(command)) + self._save_global_trust() + + def remove_trust(self, command: str) -> None: + command = command.strip() + command_type = self._get_command_type(command) + self._session_commands.discard(command) + self._session_command_types.discard(command_type) + self._global_commands.discard(command) + self._global_command_types.discard(command_type) + self._save_global_trust() + + def clear_session_trust(self) -> None: + self._session_commands.clear() + self._session_command_types.clear() + self._session_directories.clear() + + def clear_all_trust(self) -> None: + self.clear_session_trust() + self._global_commands.clear() + self._global_command_types.clear() + self._save_global_trust() + + def list_trusted(self) -> Dict[str, List[str]]: + return { + "session_commands": list(self._session_commands), + "session_command_types": list(self._session_command_types), + "session_directories": list(self._session_directories), + "global_commands": list(self._global_commands), + "global_command_types": list(self._global_command_types), + } + + +trust_manager = TrustManager() diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 00000000..0a408f24 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,64 @@ +"""Tests for Cortex UI module.""" +import pytest +from cortex.ui.theme import COLORS, SYMBOLS, CORTEX_THEME, PANEL_STYLES +from cortex.ui.console import console, CortexConsole +from cortex.ui.prompts import CommandPrompt, MenuAction, PromptResult, COMMAND_MENU_OPTIONS +from cortex.ui.progress import StepsProgress +from cortex.ui.trust import TrustManager, TrustScope +from cortex.ui.explain import parse_command, get_explanation +from cortex.ui.errors import auto_suggest_fix, COMMON_ERRORS + + +class TestTheme: + def test_colors_defined(self): + for color in ["success", "error", "warning", "info", "command", "secondary", "primary", "cortex"]: + assert color in COLORS + + def test_symbols_defined(self): + for symbol in ["success", "error", "warning", "info", "command", "thinking", "step", "prompt"]: + assert symbol in SYMBOLS + + +class TestConsole: + def test_singleton(self): + assert CortexConsole() is CortexConsole() + + def test_message_methods(self): + for method in ["success", "error", "warning", "info", "command", "step", "thinking", "secondary"]: + assert callable(getattr(console, method)) + + +class TestPrompts: + def test_menu_options_complete(self): + assert len(COMMAND_MENU_OPTIONS) == 6 + actions = {opt.action for opt in COMMAND_MENU_OPTIONS} + assert actions == {MenuAction.RUN, MenuAction.TRUST, MenuAction.DRY_RUN, MenuAction.EXPLAIN, MenuAction.EDIT, MenuAction.CANCEL} + + def test_command_prompt_init(self): + prompt = CommandPrompt(command="docker run nginx", context="Deploy nginx") + assert prompt.command == "docker run nginx" + + +class TestTrust: + def test_command_type_extraction(self): + manager = TrustManager() + assert manager._get_command_type("docker run -d nginx") == "docker run" + assert manager._get_command_type("git commit -m 'test'") == "git commit" + + +class TestExplain: + def test_parse_command(self): + base, args = parse_command("docker run -d nginx") + assert base == "docker" + assert args == ["run", "-d", "nginx"] + + +class TestErrors: + def test_auto_suggest_container_conflict(self): + fix = auto_suggest_fix('container name "/ollama" already in use') + assert fix is not None + assert "docker rm" in fix.command + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])