From 8fe61fc84ac35cc59565669bc170ba39758ca897 Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Mon, 5 Jan 2026 20:10:15 -0800 Subject: [PATCH 1/3] Refactor SuperShell TUI into modular package structure Transform monolithic supershell.py into enterprise-quality supershell/ package: - Extract themes, screens, widgets, state, data, renderers, handlers modules - Implement keyboard dispatcher pattern (on_key reduced from ~380 to ~12 lines) - Add comprehensive README.md for developer documentation - Improve error handling in base.py for SuperShell import --- .../{supershell.py => _supershell_impl.py} | 1126 +++++------------ keepercommander/commands/base.py | 6 +- keepercommander/commands/supershell/README.md | 233 ++++ .../commands/supershell/__init__.py | 67 + .../commands/supershell/constants.py | 41 + .../commands/supershell/data/__init__.py | 21 + .../commands/supershell/data/search.py | 174 +++ .../commands/supershell/data/vault_loader.py | 254 ++++ .../commands/supershell/handlers/__init__.py | 39 + .../commands/supershell/handlers/keyboard.py | 570 +++++++++ .../commands/supershell/renderers/__init__.py | 95 ++ .../commands/supershell/renderers/folder.py | 343 +++++ .../supershell/renderers/json_syntax.py | 168 +++ .../commands/supershell/renderers/record.py | 603 +++++++++ .../commands/supershell/screens/__init__.py | 8 + .../commands/supershell/screens/help.py | 114 ++ .../supershell/screens/preferences.py | 112 ++ .../commands/supershell/state/__init__.py | 16 + .../commands/supershell/state/selection.py | 96 ++ .../commands/supershell/state/ui_state.py | 97 ++ .../commands/supershell/state/vault_data.py | 75 ++ .../commands/supershell/themes/__init__.py | 8 + .../commands/supershell/themes/colors.py | 114 ++ .../commands/supershell/themes/css.py | 292 +++++ keepercommander/commands/supershell/utils.py | 53 + .../commands/supershell/widgets/__init__.py | 15 + .../supershell/widgets/clickable_field.py | 74 ++ .../supershell/widgets/clickable_line.py | 72 ++ .../supershell/widgets/clickable_uid.py | 75 ++ 29 files changed, 4138 insertions(+), 823 deletions(-) rename keepercommander/commands/{supershell.py => _supershell_impl.py} (85%) create mode 100644 keepercommander/commands/supershell/README.md create mode 100644 keepercommander/commands/supershell/__init__.py create mode 100644 keepercommander/commands/supershell/constants.py create mode 100644 keepercommander/commands/supershell/data/__init__.py create mode 100644 keepercommander/commands/supershell/data/search.py create mode 100644 keepercommander/commands/supershell/data/vault_loader.py create mode 100644 keepercommander/commands/supershell/handlers/__init__.py create mode 100644 keepercommander/commands/supershell/handlers/keyboard.py create mode 100644 keepercommander/commands/supershell/renderers/__init__.py create mode 100644 keepercommander/commands/supershell/renderers/folder.py create mode 100644 keepercommander/commands/supershell/renderers/json_syntax.py create mode 100644 keepercommander/commands/supershell/renderers/record.py create mode 100644 keepercommander/commands/supershell/screens/__init__.py create mode 100644 keepercommander/commands/supershell/screens/help.py create mode 100644 keepercommander/commands/supershell/screens/preferences.py create mode 100644 keepercommander/commands/supershell/state/__init__.py create mode 100644 keepercommander/commands/supershell/state/selection.py create mode 100644 keepercommander/commands/supershell/state/ui_state.py create mode 100644 keepercommander/commands/supershell/state/vault_data.py create mode 100644 keepercommander/commands/supershell/themes/__init__.py create mode 100644 keepercommander/commands/supershell/themes/colors.py create mode 100644 keepercommander/commands/supershell/themes/css.py create mode 100644 keepercommander/commands/supershell/utils.py create mode 100644 keepercommander/commands/supershell/widgets/__init__.py create mode 100644 keepercommander/commands/supershell/widgets/clickable_field.py create mode 100644 keepercommander/commands/supershell/widgets/clickable_line.py create mode 100644 keepercommander/commands/supershell/widgets/clickable_uid.py diff --git a/keepercommander/commands/supershell.py b/keepercommander/commands/_supershell_impl.py similarity index 85% rename from keepercommander/commands/supershell.py rename to keepercommander/commands/_supershell_impl.py index 1b093526b..91b16068d 100644 --- a/keepercommander/commands/supershell.py +++ b/keepercommander/commands/_supershell_impl.py @@ -1,5 +1,8 @@ """ Keeper SuperShell - A full-screen terminal UI for Keeper vault + +This is the implementation file during refactoring. Code is being +gradually migrated to the supershell/ package. """ import logging @@ -16,137 +19,23 @@ import pyperclip from rich.markup import escape as rich_escape - -# Color themes - each theme uses variations of a primary color -COLOR_THEMES = { - 'green': { - 'primary': '#00ff00', # Bright green - 'primary_dim': '#00aa00', # Dim green - 'primary_bright': '#44ff44', # Light green - 'secondary': '#88ff88', # Light green accent - 'selection_bg': '#004400', # Selection background - 'hover_bg': '#002200', # Hover background (dimmer than selection) - 'text': '#ffffff', # White text - 'text_dim': '#aaaaaa', # Dim text - 'folder': '#44ff44', # Folder color (light green) - 'folder_shared': '#00dd00', # Shared folder (slightly different green) - 'record': '#00aa00', # Record color (dimmer than folders) - 'record_num': '#888888', # Record number - 'attachment': '#00cc00', # Attachment color - 'virtual_folder': '#00ff88', # Virtual folder - 'status': '#00ff00', # Status bar - 'border': '#00aa00', # Borders - 'root': '#00ff00', # Root node - 'header_user': '#00bbff', # Header username (blue contrast) - }, - 'blue': { - 'primary': '#0099ff', - 'primary_dim': '#0066cc', - 'primary_bright': '#66bbff', - 'secondary': '#00ccff', - 'selection_bg': '#002244', - 'hover_bg': '#001122', - 'text': '#ffffff', - 'text_dim': '#aaaaaa', - 'folder': '#66bbff', - 'folder_shared': '#0099ff', - 'record': '#0077cc', # Record color (dimmer than folders) - 'record_num': '#888888', - 'attachment': '#0077cc', - 'virtual_folder': '#00aaff', - 'status': '#0099ff', - 'border': '#0066cc', - 'root': '#0099ff', - 'header_user': '#ff9900', # Header username (orange contrast) - }, - 'magenta': { - 'primary': '#ff66ff', - 'primary_dim': '#cc44cc', - 'primary_bright': '#ff99ff', - 'secondary': '#ffaaff', - 'selection_bg': '#330033', - 'hover_bg': '#220022', - 'text': '#ffffff', - 'text_dim': '#aaaaaa', - 'folder': '#ff99ff', - 'folder_shared': '#ff66ff', - 'record': '#cc44cc', # Record color (dimmer than folders) - 'record_num': '#888888', - 'attachment': '#cc44cc', - 'virtual_folder': '#ffaaff', - 'status': '#ff66ff', - 'border': '#cc44cc', - 'root': '#ff66ff', - 'header_user': '#66ff66', # Header username (green contrast) - }, - 'yellow': { - 'primary': '#ffff00', - 'primary_dim': '#cccc00', - 'primary_bright': '#ffff66', - 'secondary': '#ffcc00', - 'selection_bg': '#333300', - 'hover_bg': '#222200', - 'text': '#ffffff', - 'text_dim': '#aaaaaa', - 'folder': '#ffff66', - 'folder_shared': '#ffcc00', - 'record': '#cccc00', # Record color (dimmer than folders) - 'record_num': '#888888', - 'attachment': '#cccc00', - 'virtual_folder': '#ffff88', - 'status': '#ffff00', - 'border': '#cccc00', - 'root': '#ffff00', - 'header_user': '#66ccff', # Header username (blue contrast) - }, - 'white': { - 'primary': '#ffffff', - 'primary_dim': '#cccccc', - 'primary_bright': '#ffffff', - 'secondary': '#dddddd', - 'selection_bg': '#444444', - 'hover_bg': '#333333', - 'text': '#ffffff', - 'text_dim': '#999999', - 'folder': '#eeeeee', - 'folder_shared': '#dddddd', - 'record': '#bbbbbb', # Record color (dimmer than folders) - 'record_num': '#888888', - 'attachment': '#cccccc', - 'virtual_folder': '#ffffff', - 'status': '#ffffff', - 'border': '#888888', - 'root': '#ffffff', - 'header_user': '#66ccff', # Header username (blue contrast) - }, -} - -# Preferences file path -PREFS_FILE = Path.home() / '.keeper' / 'supershell_prefs.json' - - -def load_preferences() -> dict: - """Load preferences from file, return defaults if not found""" - defaults = {'color_theme': 'green'} - try: - if PREFS_FILE.exists(): - with open(PREFS_FILE, 'r') as f: - prefs = json.load(f) - # Merge with defaults - return {**defaults, **prefs} - except Exception as e: - logging.debug(f"Error loading preferences: {e}") - return defaults - - -def save_preferences(prefs: dict): - """Save preferences to file""" - try: - PREFS_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(PREFS_FILE, 'w') as f: - json.dump(prefs, f, indent=2) - except Exception as e: - logging.error(f"Error saving preferences: {e}") +# Import from refactored modules +from .supershell.themes import COLOR_THEMES +from .supershell.utils import load_preferences, save_preferences, strip_ansi_codes +from .supershell.widgets import ClickableDetailLine, ClickableField, ClickableRecordUID +from .supershell.state import VaultData, UIState, ThemeState, SelectionState +from .supershell.data import load_vault_data, search_records +from .supershell.renderers import ( + is_sensitive_field as is_sensitive_field_name, + mask_passwords_in_json, + strip_field_type_prefix, + is_section_header as is_record_section_header, + RECORD_SECTION_HEADERS, + FIELD_TYPE_PREFIXES, + TYPE_FRIENDLY_NAMES, +) +from .supershell.handlers import keyboard_dispatcher +from .supershell.screens import PreferencesScreen, HelpScreen from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical, VerticalScroll, Center, Middle @@ -162,196 +51,7 @@ def save_preferences(prefs: dict): from ..commands.base import Command - -class ClickableDetailLine(Static): - """A single line in the detail view that highlights on hover and copies on click""" - - DEFAULT_CSS = """ - ClickableDetailLine { - width: 100%; - height: auto; - padding: 0 1; - } - - ClickableDetailLine:hover { - background: #1a1a2e; - } - - ClickableDetailLine.has-value { - /* Clickable lines get a subtle left border indicator */ - } - - ClickableDetailLine.has-value:hover { - background: #16213e; - text-style: bold; - border-left: thick #00ff00; - } - """ - - def __init__(self, content: str, copy_value: str = None, record_uid: str = None, is_password: bool = False, *args, **kwargs): - """ - Create a clickable detail line. - - Args: - content: Rich markup content to display - copy_value: Value to copy on click (None = not copyable) - record_uid: Record UID for password audit events - is_password: If True, use ClipboardCommand for audit event - """ - self.copy_value = copy_value - self.record_uid = record_uid - self.is_password = is_password - classes = "has-value" if copy_value else "" - super().__init__(content, classes=classes, *args, **kwargs) - - def on_mouse_down(self, event: MouseDown) -> None: - """Handle mouse down to copy value - fires immediately without waiting for focus""" - if self.copy_value: - try: - if self.is_password and self.record_uid: - # Use ClipboardCommand to generate audit event for password copy - cc = ClipboardCommand() - cc.execute(self.app.params, record=self.record_uid, output='clipboard', - username=None, copy_uid=False, login=False, totp=False, field=None, revision=None) - self.app.notify("πŸ”‘ Password copied to clipboard!", severity="information") - else: - # Regular copy for non-password fields - pyperclip.copy(self.copy_value) - self.app.notify(f"Copied: {self.copy_value[:50]}{'...' if len(self.copy_value) > 50 else ''}", severity="information") - except Exception as e: - self.app.notify(f"Copy failed: {e}", severity="error") - - -class ClickableField(Static): - """A clickable field that copies value to clipboard on click""" - - DEFAULT_CSS = """ - ClickableField { - width: 100%; - height: auto; - padding: 0 1; - } - - ClickableField:hover { - background: #333333; - } - - ClickableField.clickable-value:hover { - background: #444444; - text-style: bold; - } - """ - - def __init__(self, label: str, value: str, copy_value: str = None, - label_color: str = "#aaaaaa", value_color: str = "#00ff00", - is_header: bool = False, indent: int = 0, *args, **kwargs): - """ - Create a clickable field. - - Args: - label: The field label (e.g., "Username:") - value: The display value - copy_value: The value to copy (defaults to value) - label_color: Color for label - value_color: Color for value - is_header: If True, style as section header - indent: Indentation level (spaces) - """ - self.copy_value = copy_value if copy_value is not None else value - - # Build content before calling super().__init__ - indent_str = " " * indent - # Escape brackets for Rich markup - safe_value = value.replace('[', '\\[').replace(']', '\\]') if value else '' - safe_label = label.replace('[', '\\[').replace(']', '\\]') if label else '' - - if is_header: - content = f"[bold {value_color}]{indent_str}{safe_label}[/bold {value_color}]" - elif label: - content = f"{indent_str}[{label_color}]{safe_label}[/{label_color}] [{value_color}]{safe_value}[/{value_color}]" - else: - content = f"{indent_str}[{value_color}]{safe_value}[/{value_color}]" - - # Set classes for hover effect - classes = "clickable-value" if self.copy_value else "" - - super().__init__(content, classes=classes, *args, **kwargs) - - def on_mouse_down(self, event: MouseDown) -> None: - """Handle mouse down to copy value - fires immediately without waiting for focus""" - if self.copy_value: - try: - pyperclip.copy(self.copy_value) - self.app.notify(f"Copied to clipboard", severity="information") - except Exception as e: - self.app.notify(f"Copy failed: {e}", severity="error") - - -class ClickableRecordUID(Static): - """A clickable record UID that navigates to the record when clicked""" - - DEFAULT_CSS = """ - ClickableRecordUID { - width: 100%; - height: auto; - padding: 0 1; - } - - ClickableRecordUID:hover { - background: #333344; - text-style: bold underline; - } - """ - - def __init__(self, label: str, record_uid: str, record_title: str = None, - label_color: str = "#aaaaaa", value_color: str = "#ffff00", - indent: int = 0, *args, **kwargs): - """ - Create a clickable record UID that navigates to the record. - - Args: - label: The field label (e.g., "Record UID:") - record_uid: The UID of the record to navigate to - record_title: Optional title to display instead of UID - label_color: Color for label - value_color: Color for value - indent: Indentation level - """ - self.record_uid = record_uid - - # Build content before calling super().__init__ - indent_str = " " * indent - display_value = record_title or record_uid - safe_value = display_value.replace('[', '\\[').replace(']', '\\]') - safe_label = label.replace('[', '\\[').replace(']', '\\]') if label else '' - - if label: - content = f"{indent_str}[{label_color}]{safe_label}[/{label_color}] [{value_color}]{safe_value} β†—[/{value_color}]" - else: - content = f"{indent_str}[{value_color}]{safe_value} β†—[/{value_color}]" - - super().__init__(content, *args, **kwargs) - - def on_mouse_down(self, event: MouseDown) -> None: - """Handle mouse down to navigate to record - fires immediately without waiting for focus""" - # Find the app and trigger record selection - app = self.app - if hasattr(app, 'records') and self.record_uid in app.records: - # Navigate to the record in the tree - app.selected_record = self.record_uid - app.selected_folder = None - app._display_record_detail(self.record_uid) - - # Try to select the node in the tree - tree = app.query_one("#folder_tree", Tree) - app._select_record_in_tree(tree, self.record_uid) - - app.notify(f"Navigated to record", severity="information") - else: - # Just copy the UID if record not found - pyperclip.copy(self.record_uid) - app.notify(f"Record not in vault. UID copied.", severity="warning") - +# Widget classes are now imported from .supershell.widgets at the top of this file from ..commands.record import RecordGetUidCommand, ClipboardCommand from ..display import bcolors @@ -359,201 +59,7 @@ def on_mouse_down(self, event: MouseDown) -> None: from .. import utils -class PreferencesScreen(ModalScreen): - """Modal screen for user preferences""" - - DEFAULT_CSS = """ - PreferencesScreen { - align: center middle; - } - - #prefs_container { - width: 40; - height: auto; - max-height: 90%; - background: #111111; - border: solid #444444; - padding: 1 2; - } - - #prefs_title { - text-align: center; - text-style: bold; - padding-bottom: 1; - } - - #prefs_content { - height: auto; - padding: 0 1; - } - - #prefs_footer { - text-align: center; - padding-top: 1; - color: #666666; - } - """ - - BINDINGS = [ - Binding("escape", "dismiss", "Close", show=False), - Binding("q", "dismiss", "Close", show=False), - Binding("1", "select_green", "Green", show=False), - Binding("2", "select_blue", "Blue", show=False), - Binding("3", "select_magenta", "Magenta", show=False), - Binding("4", "select_yellow", "Yellow", show=False), - Binding("5", "select_white", "White", show=False), - ] - - def __init__(self, app_instance): - super().__init__() - self.app_instance = app_instance - - def compose(self) -> ComposeResult: - current = self.app_instance.color_theme - with Vertical(id="prefs_container"): - yield Static("[bold cyan]βš™ Preferences[/bold cyan]", id="prefs_title") - yield Static(f"""[green]Color Theme:[/green] - [#00ff00]1[/#00ff00] {'●' if current == 'green' else 'β—‹'} Green - [#0099ff]2[/#0099ff] {'●' if current == 'blue' else 'β—‹'} Blue - [#ff66ff]3[/#ff66ff] {'●' if current == 'magenta' else 'β—‹'} Magenta - [#ffff00]4[/#ffff00] {'●' if current == 'yellow' else 'β—‹'} Yellow - [#ffffff]5[/#ffffff] {'●' if current == 'white' else 'β—‹'} White""", id="prefs_content") - yield Static("[dim]Press 1-5 to select, Esc or q to close[/dim]", id="prefs_footer") - - def action_dismiss(self): - """Close the preferences screen""" - self.dismiss() - - def key_escape(self): - """Handle escape key directly""" - self.dismiss() - - def key_q(self): - """Handle q key directly""" - self.dismiss() - - def action_select_green(self): - self._apply_theme('green') - - def action_select_blue(self): - self._apply_theme('blue') - - def action_select_magenta(self): - self._apply_theme('magenta') - - def action_select_yellow(self): - self._apply_theme('yellow') - - def action_select_white(self): - self._apply_theme('white') - - def _apply_theme(self, theme_name: str): - """Apply the selected theme and save preferences""" - self.app_instance.set_color_theme(theme_name) - # Save to preferences file - prefs = load_preferences() - prefs['color_theme'] = theme_name - save_preferences(prefs) - self.app_instance.notify(f"Theme changed to {theme_name}") - self.dismiss() - - -class HelpScreen(ModalScreen): - """Modal screen for help/keyboard shortcuts""" - - DEFAULT_CSS = """ - HelpScreen { - align: center middle; - } - - #help_container { - width: 90; - height: auto; - max-height: 90%; - background: #111111; - border: solid #444444; - padding: 1 2; - } - - #help_title { - text-align: center; - text-style: bold; - padding-bottom: 1; - } - - #help_columns { - height: auto; - } - - .help_column { - width: 1fr; - height: auto; - padding: 0 1; - } - - #help_footer { - text-align: center; - padding-top: 1; - color: #666666; - } - """ - - BINDINGS = [ - Binding("escape", "dismiss", "Close", show=False), - Binding("q", "dismiss", "Close", show=False), - ] - - def compose(self) -> ComposeResult: - with Vertical(id="help_container"): - yield Static("[bold cyan]⌨ Keyboard Shortcuts[/bold cyan]", id="help_title") - with Horizontal(id="help_columns"): - yield Static("""[green]Navigation:[/green] - j/k ↑/↓ Move up/down - h/l ←/β†’ Collapse/expand - g / G Top / bottom - Ctrl+d/u Half page - Ctrl+e/y Scroll line - Esc Clear/collapse - -[green]Focus Cycling:[/green] - Tab Treeβ†’Detailβ†’Search - Shift+Tab Cycle backwards - / Focus search - Ctrl+U Clear search - Esc Focus tree - -[green]General:[/green] - ? Help - ! Keeper shell - Ctrl+q Quit""", classes="help_column") - yield Static("""[green]Copy to Clipboard:[/green] - p Password - u Username - c Copy all - w URL - i Record UID - -[green]Actions:[/green] - t Toggle JSON view - m Mask/Unmask - d Sync vault - W User info - D Device info - P Preferences""", classes="help_column") - yield Static("[dim]Press Esc or q to close[/dim]", id="help_footer") - - def action_dismiss(self): - """Close the help screen""" - self.dismiss() - - def key_escape(self): - """Handle escape key directly""" - self.dismiss() - - def key_q(self): - """Handle q key directly""" - self.dismiss() - +# Screen classes imported from .supershell.screens class SuperShellApp(App): """The Keeper SuperShell TUI application""" @@ -565,11 +71,7 @@ class SuperShellApp(App): PAGE_DOWN_NODES = 10 # Number of nodes to move for half-page down PAGE_DOWN_FULL_NODES = 20 # Number of nodes to move for full-page down - @staticmethod - def _strip_ansi_codes(text: str) -> str: - """Remove ANSI color codes from text""" - ansi_escape = re.compile(r'\x1b\[[0-9;]*m') - return ansi_escape.sub('', text) + # _strip_ansi_codes is now imported from .supershell.utils CSS = """ Screen { @@ -773,6 +275,62 @@ def _strip_ansi_codes(text: str) -> str: padding: 0 1; border-top: solid #333333; } + + /* Content area wrapper for shell pane visibility control */ + #content_area { + height: 100%; + width: 100%; + } + + /* When shell is visible, compress main container to top half */ + #content_area.shell-visible #main_container { + height: 50%; + } + + /* Shell pane - hidden by default */ + #shell_pane { + display: none; + height: 50%; + width: 100%; + border-top: thick #666666; + background: #000000; + } + + /* Show shell pane when class is added */ + #content_area.shell-visible #shell_pane { + display: block; + } + + #shell_header { + height: 1; + background: #222222; + color: #00ff00; + padding: 0 1; + border-bottom: solid #333333; + } + + #shell_output { + height: 1fr; + overflow-y: auto; + padding: 0 1; + background: #000000; + } + + #shell_output_content { + background: #000000; + color: #ffffff; + } + + #shell_input_line { + height: 1; + background: #111111; + color: #00ff00; + padding: 0 1; + } + + #shell_pane:focus-within #shell_input_line { + background: #1a1a2e; + } """ BINDINGS = [ @@ -835,6 +393,13 @@ def __init__(self, params): # Vim-style command mode (e.g., :20 to go to line 20) self.command_mode = False self.command_buffer = "" + # Shell pane state + self.shell_pane_visible = False + self.shell_input_text = "" + self.shell_history = [] # List of (command, output) tuples + self.shell_input_active = False + self.shell_command_history = [] # For up/down arrow navigation + self.shell_history_index = -1 # Load color theme from preferences prefs = load_preferences() self.color_theme = prefs.get('color_theme', 'green') @@ -968,14 +533,24 @@ def compose(self) -> ComposeResult: yield Static("", id="user_info", classes="clickable-info") yield Static("", id="device_status_info", classes="clickable-info") - with Horizontal(id="main_container"): - with Vertical(id="folder_panel"): - yield Tree("[#00ff00]● My Vault[/#00ff00]", id="folder_tree") - with Vertical(id="record_panel"): - with VerticalScroll(id="record_detail"): - yield Static("", id="detail_content") - # Fixed footer for shortcuts - yield Static("", id="shortcuts_bar") + # Content area wrapper - allows shell pane visibility control + with Vertical(id="content_area"): + with Horizontal(id="main_container"): + with Vertical(id="folder_panel"): + yield Tree("[#00ff00]● My Vault[/#00ff00]", id="folder_tree") + with Vertical(id="record_panel"): + with VerticalScroll(id="record_detail"): + yield Static("", id="detail_content") + # Fixed footer for shortcuts + yield Static("", id="shortcuts_bar") + + # Shell pane - hidden by default, shown when :command or Ctrl+\ pressed + with Vertical(id="shell_pane"): + yield Static("", id="shell_header") + with VerticalScroll(id="shell_output"): + yield Static("", id="shell_output_content") + yield Static("", id="shell_input_line") + # Status bar at very bottom yield Static("", id="status_bar") @@ -1969,7 +1544,7 @@ def _format_record_for_tui(self, record_uid: str) -> str: # Use the get command (same as shell) to fetch record details output = self._get_record_output(record_uid, format_type='detail') # Strip ANSI codes from command output - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) if not output or output.strip() == '': return "[red]Failed to get record details[/red]" @@ -2090,7 +1665,7 @@ def _format_folder_for_tui(self, folder_uid: str) -> str: # Get the captured output output = stdout_buffer.getvalue() # Strip ANSI codes - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) if not output or output.strip() == '': # Fallback to basic folder info if get command didn't work @@ -2397,7 +1972,7 @@ def _display_record_with_clickable_fields(self, record_uid: str): # Get and parse record output output = self._get_record_output(record_uid, format_type='detail') - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) if not output or output.strip() == '': detail_widget.update("[red]Failed to get record details[/red]") @@ -2712,7 +2287,7 @@ def _display_json_with_clickable_fields(self, record_uid: str): # Get JSON output (include DAG data for PAM records) output = self._get_record_output(record_uid, format_type='json', include_dag=True) - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) try: json_obj = json.loads(output) @@ -2991,7 +2566,7 @@ def _display_folder_with_clickable_fields(self, folder_uid: str): get_cmd.execute(self.params, uid=folder_uid, format='detail') sys.stdout = old_stdout output = stdout_buffer.getvalue() - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) except Exception as e: sys.stdout = old_stdout logging.error(f"Error getting folder output: {e}") @@ -3119,7 +2694,7 @@ def _display_folder_json(self, folder_uid: str): get_cmd.execute(self.params, uid=folder_uid, format='json') sys.stdout = old_stdout output = stdout_buffer.getvalue() - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) except Exception as e: sys.stdout = old_stdout logging.error(f"Error getting folder JSON output: {e}") @@ -3654,292 +3229,19 @@ def _update_search_display(self, perform_search=True): self._update_status(f"ERROR: {str(e)}") def on_key(self, event): - """Handle keyboard events""" - search_bar = self.query_one("#search_bar") - tree = self.query_one("#folder_tree", Tree) + """Handle keyboard events using the dispatcher pattern. - # Global key handlers that work regardless of focus - # ! exits to regular shell (works from any widget) - if event.character == "!" and not self.search_input_active: - self.exit("Exited to Keeper shell. Type 'supershell' or 'ss' to return.") - event.prevent_default() - event.stop() + Keyboard handling is delegated to specialized handlers in + supershell/handlers/keyboard.py for better organization and testing. + """ + # Dispatch to the keyboard handler chain + if keyboard_dispatcher.dispatch(event, self): return - # Handle Tab/Shift+Tab cycling: Tree β†’ Detail β†’ Search (counterclockwise) - detail_scroll = self.query_one("#record_detail", VerticalScroll) - - # Handle search input mode Tab/Shift+Tab first (search_input_active takes priority) - if self.search_input_active: - if event.key == "tab": - # Search input β†’ Tree (forward in cycle) - self.search_input_active = False - tree.remove_class("search-input-active") - search_display = self.query_one("#search_display", Static) - if self.search_input_text: - search_display.update(rich_escape(self.search_input_text)) - else: - search_display.update("[dim]Search...[/dim]") - tree.focus() - self._update_status("Navigate with j/k | Tab to detail | ? for help") - event.prevent_default() - event.stop() - return - elif event.key == "shift+tab": - # Search input β†’ Detail pane (backwards in cycle) - self.search_input_active = False - tree.remove_class("search-input-active") - search_display = self.query_one("#search_display", Static) - if self.search_input_text: - search_display.update(rich_escape(self.search_input_text)) - else: - search_display.update("[dim]Search...[/dim]") - detail_scroll.focus() - self._update_status("Detail pane | Tab to search | Shift+Tab to tree") - event.prevent_default() - event.stop() - return - - if detail_scroll.has_focus: - if event.key == "tab": - # Detail pane β†’ Search input - self.search_input_active = True - tree.add_class("search-input-active") - self._update_search_display(perform_search=False) # Don't change tree when entering search - self._update_status("Type to search | Tab to tree | Ctrl+U to clear") - event.prevent_default() - event.stop() - return - elif event.key == "shift+tab": - # Detail pane β†’ Tree - tree.focus() - self._update_status("Navigate with j/k | Tab to detail | ? for help") - event.prevent_default() - event.stop() - return - elif event.key == "escape": - tree.focus() - event.prevent_default() - event.stop() - return - elif event.key == "ctrl+y": - # Ctrl+Y scrolls viewport up (like vim) - detail_scroll.scroll_relative(y=-1) - event.prevent_default() - event.stop() - return - elif event.key == "ctrl+e": - # Ctrl+E scrolls viewport down (like vim) - detail_scroll.scroll_relative(y=1) - event.prevent_default() - event.stop() - return - - if search_bar.styles.display != "none": - # Search bar is active - - # If we're navigating results (not typing), let tree/app handle its keys - if not self.search_input_active and tree.has_focus: - # Handle left/right arrow keys for expand/collapse - if event.key == "left": - if tree.cursor_node and tree.cursor_node.allow_expand: - tree.cursor_node.collapse() - event.prevent_default() - event.stop() - return - elif event.key == "right": - if tree.cursor_node and tree.cursor_node.allow_expand: - tree.cursor_node.expand() - event.prevent_default() - event.stop() - return - # Navigation keys for tree - if event.key in ("j", "k", "h", "l", "up", "down", "enter", "space"): - return - # Action keys (copy, toggle view, etc.) - let them pass through - if event.key in ("t", "c", "u", "w", "i", "y", "d", "g", "p", "question_mark"): - return - # Shift+G for go to bottom - if event.character == "G": - return - # Tab switches to detail pane - if event.key == "tab": - detail_scroll.focus() - self._update_status("Detail pane | Tab to search | Shift+Tab to tree") - event.prevent_default() - event.stop() - return - # Shift+Tab switches to search input - elif event.key == "shift+tab": - self.search_input_active = True - tree.add_class("search-input-active") - self._update_search_display(perform_search=False) # Don't change tree when entering search - self._update_status("Type to search | Tab to tree | Ctrl+U to clear") - event.prevent_default() - event.stop() - return - elif event.key == "slash": - # Switch back to search input mode - self.search_input_active = True - tree.add_class("search-input-active") - self._update_search_display(perform_search=False) # Don't change tree when entering search - event.prevent_default() - event.stop() - return - - # Ctrl+U clears the search input (like bash) - # Reset tree to show all items when clearing search - if event.key == "ctrl+u" and self.search_input_active: - self.search_input_text = "" - self._update_search_display(perform_search=False) # Just update display - self._perform_live_search("") # Reset tree to show all - event.prevent_default() - event.stop() - return - - # "/" to switch to search input mode (works from anywhere when search bar visible) - if event.key == "slash" and not self.search_input_active: - self.search_input_active = True - tree.add_class("search-input-active") - self._update_search_display(perform_search=False) # Don't change tree when entering search - event.prevent_default() - event.stop() - return + # Event was not handled by any handler - let it propagate + pass - if event.key == "escape": - # Clear search and move focus to tree (don't hide search bar) - self.search_input_text = "" - self.search_input_active = False - tree.remove_class("search-input-active") - self._perform_live_search("") # Reset to show all - - # Update search display to show placeholder - search_display = self.query_one("#search_display", Static) - search_display.update("[dim]Search... (Tab or /)[/dim]") - results_label = self.query_one("#search_results_label", Static) - results_label.update("") - - # Restore previous selection - self.selected_record = self.pre_search_selected_record - self.selected_folder = self.pre_search_selected_folder - self._restore_tree_selection(tree) - - tree.focus() - self._update_status("Navigate with j/k | Tab to detail | ? for help") - event.prevent_default() - event.stop() - elif event.key in ("enter", "down"): - # Move focus to tree to navigate results - # Switch to navigation mode - self.search_input_active = False - - # Show tree selection - remove the class that hides it - tree.remove_class("search-input-active") - - # Remove cursor from search display - search_display = self.query_one("#search_display", Static) - if self.search_input_text: - search_display.update(rich_escape(self.search_input_text)) # No cursor - else: - search_display.update("[dim]Search...[/dim]") - - # Force focus to tree - self.set_focus(tree) - tree.focus() - - self._update_status("Navigate results with j/k | / to edit search | ESC to close") - event.prevent_default() - event.stop() - return # Return immediately to avoid further processing - elif event.key == "backspace": - # Delete last character - if self.search_input_text: - self.search_input_text = self.search_input_text[:-1] - self._update_search_display() - event.prevent_default() - event.stop() - elif self.search_input_active and event.character and event.character.isprintable(): - # Only add characters when search input is active (not when navigating results) - self.search_input_text += event.character - self._update_search_display() - event.prevent_default() - event.stop() - else: - # Search bar is NOT active - handle escape and command mode - - # Handle command mode (vim :N navigation) - if self.command_mode: - if event.key == "escape": - # Cancel command mode - self.command_mode = False - self.command_buffer = "" - self._update_status("Command cancelled") - event.prevent_default() - event.stop() - return - elif event.key == "enter": - # Execute command - self._execute_command(self.command_buffer) - self.command_mode = False - self.command_buffer = "" - event.prevent_default() - event.stop() - return - elif event.key == "backspace": - # Delete last character - if self.command_buffer: - self.command_buffer = self.command_buffer[:-1] - self._update_status(f":{self.command_buffer}") - else: - # Exit command mode if buffer is empty - self.command_mode = False - self._update_status("Navigate with j/k | / to search | ? for help") - event.prevent_default() - event.stop() - return - elif event.character and event.character.isdigit(): - # Accept digits for line number navigation - self.command_buffer += event.character - self._update_status(f":{self.command_buffer}") - event.prevent_default() - event.stop() - return - else: - # Invalid character for command mode - event.prevent_default() - event.stop() - return - - # Enter command mode with : - if event.character == ":": - self.command_mode = True - self.command_buffer = "" - self._update_status(":") - event.prevent_default() - event.stop() - return - - # Handle arrow keys for expand/collapse when search is not active - if tree.has_focus: - if event.key == "left": - if tree.cursor_node and tree.cursor_node.allow_expand: - tree.cursor_node.collapse() - event.prevent_default() - event.stop() - return - elif event.key == "right": - if tree.cursor_node and tree.cursor_node.allow_expand: - tree.cursor_node.expand() - event.prevent_default() - event.stop() - return - - if event.key == "escape": - # Escape: collapse current folder or go to parent, stop at root - self._collapse_current_or_parent(tree) - event.prevent_default() - event.stop() - return + # Old keyboard handling code has been moved to supershell/handlers/keyboard.py def _collapse_current_or_parent(self, tree: Tree): """Collapse current node if expanded, or go to parent. Stop at root.""" @@ -3962,15 +3264,21 @@ def _collapse_current_or_parent(self, tree: Tree): self._update_status("Moved to parent") def _execute_command(self, command: str): - """Execute vim-style command (e.g., :20 to go to line 20)""" + """Execute vim-style command (e.g., :20 to go to line 20) or open shell with command""" command = command.strip() + if not command: + return - # Try to parse as line number + # Try to parse as line number first (vim navigation) try: line_num = int(command) self._goto_line(line_num) + return except ValueError: - self._update_status(f"Unknown command: {command}") + pass + + # Not a number - open shell pane and run the command + self._open_shell_pane(command) def _goto_line(self, line_num: int): """Go to specified line number in the tree (1-indexed like vim)""" @@ -4005,6 +3313,182 @@ def collect_visible_nodes(node, include_self=True): else: self._update_status("No visible nodes") + # ========== Shell Pane Methods ========== + + def _open_shell_pane(self, command: str = None): + """Open the shell pane, optionally running a command immediately""" + content_area = self.query_one("#content_area", Vertical) + content_area.add_class("shell-visible") + + self.shell_pane_visible = True + self.shell_input_active = True + self.shell_input_text = "" + + # Update shell header with theme colors + self._update_shell_header() + + # Initialize the input display + self._update_shell_input_display() + + # If a command was provided, execute it immediately + if command: + self._execute_shell_command(command) + + self._update_status("Shell open | Enter to run | quit/q/Ctrl+D to close") + + def _close_shell_pane(self): + """Close the shell pane and return to normal view""" + content_area = self.query_one("#content_area", Vertical) + content_area.remove_class("shell-visible") + + self.shell_pane_visible = False + self.shell_input_active = False + self.shell_input_text = "" + self.shell_history_index = -1 + + # Focus tree + tree = self.query_one("#folder_tree", Tree) + tree.focus() + + self._update_status("Navigate with j/k | / to search | ? for help") + + def _execute_shell_command(self, command: str): + """Execute a Keeper command in the shell pane and display output""" + command = command.strip() + if not command: + return + + # Handle quit commands + if command.lower() in ('quit', 'q', 'exit'): + self._close_shell_pane() + return + + # Handle clear command + if command.lower() == 'clear': + self.shell_history = [] + self._update_shell_output_display() + return + + # Add to command history for up/down navigation + if command and (not self.shell_command_history or + self.shell_command_history[-1] != command): + self.shell_command_history.append(command) + self.shell_history_index = -1 + + # Capture stdout/stderr for command execution + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() + old_stdout = sys.stdout + old_stderr = sys.stderr + + try: + sys.stdout = stdout_buffer + sys.stderr = stderr_buffer + + # Execute via cli.do_command + from ..cli import do_command + do_command(self.params, command) + + except Exception as e: + stderr_buffer.write(f"Error: {str(e)}\n") + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + + # Get output + output = stdout_buffer.getvalue() + errors = stderr_buffer.getvalue() + + # Strip ANSI codes + output = strip_ansi_codes(output) + errors = strip_ansi_codes(errors) + + # Combine output + full_output = output.rstrip() + if errors: + full_output += f"\n[red]{rich_escape(errors.rstrip())}[/red]" + + # Add to history + self.shell_history.append((command, full_output)) + + # Update shell output display + self._update_shell_output_display() + + # Scroll to bottom + try: + shell_output = self.query_one("#shell_output", VerticalScroll) + shell_output.scroll_end(animate=False) + except Exception: + pass + + def _update_shell_output_display(self): + """Update the shell output area with command history""" + try: + shell_output_content = self.query_one("#shell_output_content", Static) + except Exception: + return + + t = self.theme_colors + lines = [] + + for cmd, output in self.shell_history: + # Show prompt and command + prompt = self._get_shell_prompt() + lines.append(f"[{t['primary']}]{prompt}[/{t['primary']}]{rich_escape(cmd)}") + # Show output + if output.strip(): + lines.append(output) + lines.append("") # Blank line between commands + + shell_output_content.update("\n".join(lines)) + + def _update_shell_input_display(self): + """Update the shell input line with prompt and current input text""" + try: + shell_input = self.query_one("#shell_input_line", Static) + except Exception: + return + + t = self.theme_colors + prompt = self._get_shell_prompt() + + if self.shell_input_active: + cursor = "[reverse] [/reverse]" + else: + cursor = "" + + shell_input.update( + f"[{t['primary']}]{prompt}[/{t['primary']}]" + f"{rich_escape(self.shell_input_text)}{cursor}" + ) + + def _get_shell_prompt(self) -> str: + """Get the shell prompt based on current folder context""" + # Use the currently selected folder in the tree as context + if self.selected_folder and self.params.folder_cache: + folder = self.params.folder_cache.get(self.selected_folder) + if folder and hasattr(folder, 'name'): + name = folder.name + if len(name) > 30: + name = "..." + name[-27:] + return f"{name}> " + + # Default to "My Vault" if at root + return "My Vault> " + + def _update_shell_header(self): + """Update shell header bar with theme colors""" + try: + shell_header = self.query_one("#shell_header", Static) + except Exception: + return + + t = self.theme_colors + shell_header.update( + f"[bold {t['primary']}]Keeper Shell[/bold {t['primary']}] " + f"[{t['text_dim']}](quit/q/Ctrl+D to close)[/{t['text_dim']}]" + ) + def check_action(self, action: str, parameters: tuple) -> bool | None: """Control whether actions are enabled based on search state""" # When search input is active, disable all bindings except escape and search @@ -4249,7 +3733,7 @@ def action_copy_record(self): if self.view_mode == 'json': # Copy JSON format (with actual password, not masked) output = self._get_record_output(self.selected_record, format_type='json') - output = self._strip_ansi_codes(output) + output = strip_ansi_codes(output) json_obj = json.loads(output) formatted = json.dumps(json_obj, indent=2) pyperclip.copy(formatted) diff --git a/keepercommander/commands/base.py b/keepercommander/commands/base.py index 22f237248..c56194698 100644 --- a/keepercommander/commands/base.py +++ b/keepercommander/commands/base.py @@ -139,8 +139,10 @@ def register_commands(commands, aliases, command_info): commands['supershell'] = SuperShellCommand() command_info['supershell'] = 'Launch full terminal vault UI with vim navigation' aliases['ss'] = 'supershell' - except ImportError: - pass # textual not installed, skip supershell + except ImportError as e: + logging.debug(f"SuperShell not available: {e}") + except Exception as e: + logging.error(f"SuperShell import error: {e}") from . import credential_provision credential_provision.register_commands(commands) diff --git a/keepercommander/commands/supershell/README.md b/keepercommander/commands/supershell/README.md new file mode 100644 index 000000000..26f236835 --- /dev/null +++ b/keepercommander/commands/supershell/README.md @@ -0,0 +1,233 @@ +# SuperShell Package Architecture + +SuperShell is a full-screen terminal UI (TUI) for browsing and managing Keeper vault records. It's built on [Textual](https://textual.textualize.io/), a modern Python TUI framework. + +## Package Structure + +``` +supershell/ +β”œβ”€β”€ __init__.py # Main exports and package interface +β”œβ”€β”€ constants.py # Configuration constants +β”œβ”€β”€ utils.py # Utility functions (preferences, ANSI stripping) +β”‚ +β”œβ”€β”€ themes/ # Visual theming +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ colors.py # COLOR_THEMES dict with 5 color schemes +β”‚ └── css.py # Textual CSS stylesheet +β”‚ +β”œβ”€β”€ screens/ # Modal screens +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ preferences.py # Theme selection modal +β”‚ └── help.py # Keyboard shortcuts help modal +β”‚ +β”œβ”€β”€ widgets/ # Custom Textual widgets +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ clickable_line.py # ClickableDetailLine - copy-on-click text +β”‚ β”œβ”€β”€ clickable_field.py # ClickableField - labeled copy-on-click +β”‚ └── clickable_uid.py # ClickableRecordUID - UID with navigation +β”‚ +β”œβ”€β”€ state/ # State management dataclasses +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ vault_data.py # VaultData - records, folders, mappings +β”‚ β”œβ”€β”€ ui_state.py # UIState, ThemeState - UI presentation state +β”‚ └── selection.py # SelectionState - current selection tracking +β”‚ +β”œβ”€β”€ data/ # Data loading and search +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ vault_loader.py # load_vault_data() - extracts data from params +β”‚ └── search.py # search_records() - tokenized search +β”‚ +β”œβ”€β”€ renderers/ # Display formatting +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ json_syntax.py # JSON syntax highlighting, password masking +β”‚ β”œβ”€β”€ record.py # Record field formatting, JsonRenderer class +β”‚ └── folder.py # Folder field formatting, FolderJsonRenderer +β”‚ +└── handlers/ # Input handling + β”œβ”€β”€ __init__.py + └── keyboard.py # KeyboardDispatcher and handler classes +``` + +## Key Components + +### Main Application (`_supershell_impl.py`) + +The `SuperShellApp` class (in the parent directory) is the main Textual application. It: +- Composes the UI layout (tree, detail pane, search bar, shell pane) +- Manages application state +- Handles tree node selection events +- Coordinates between components + +### Keyboard Handling (`handlers/keyboard.py`) + +Uses a **dispatcher pattern** for clean keyboard event handling: + +```python +class KeyboardDispatcher: + handlers = [ + GlobalExitHandler(), # ! to exit + ShellPaneToggleHandler(), # Ctrl+\ to toggle shell + CommandModeHandler(), # :command vim-style + ShellInputHandler(), # Shell pane input + SearchInputHandler(), # Search typing + # ... more handlers + ] +``` + +Each handler has: +- `can_handle(event, app)` - Check if this handler applies +- `handle(event, app)` - Process the event, return True if handled + +### State Management (`state/`) + +Uses Python dataclasses for type-safe state: + +```python +@dataclass +class VaultData: + records: Dict[str, dict] # record_uid -> record data + record_to_folder: Dict[str, str] # record_uid -> folder_uid + records_in_subfolders: Set[str] # records not in root + # ... attachment and linked record mappings + +@dataclass +class UIState: + view_mode: str = 'detail' # 'detail' or 'json' + unmask_secrets: bool = False # Show/hide passwords + search_query: str = "" + # ... more UI state +``` + +### Search (`data/search.py`) + +Smart tokenized search: +- Splits query into tokens by whitespace +- Each token must match somewhere in record fields OR folder name +- Order doesn't matter: "aws prod" matches "Production AWS Server" +- Searches: title, URL, username, custom fields, notes, folder name + +### Renderers (`renderers/`) + +Format data for display with Rich markup: + +```python +# JSON rendering with syntax highlighting +renderer = JsonRenderer(theme_colors, unmask_secrets=False) +renderer.render_lines(json_obj, on_line_callback) + +# Field formatting helpers +format_password_line("Password", "******", theme_colors) +format_totp_display("123456", 25, theme_colors) +``` + +### Themes (`themes/`) + +Five color themes available: +- **green** (default) - Matrix-style green +- **blue** - Cool blue tones +- **magenta** - Purple/pink +- **yellow** - Warm amber +- **white** - High contrast + +Each theme defines 18 color properties used throughout the UI. + +## Data Flow + +``` +1. User launches `keeper supershell` + └── SuperShellCommand.execute() creates SuperShellApp + +2. App initialization + └── _load_vault_data() extracts records from params.record_cache + └── _setup_folder_tree() builds the tree widget + +3. User navigates tree + └── on_tree_node_selected() fires + └── _display_record_with_clickable_fields() renders detail + +4. User presses key + └── on_key() delegates to keyboard_dispatcher + └── Appropriate handler processes event + +5. User searches + └── _update_search_display() captures input + └── _perform_live_search() filters tree in real-time +``` + +## Key Bindings + +| Key | Action | +|-----|--------| +| `j/k` | Navigate up/down | +| `h/l` | Collapse/expand folder | +| `Enter` | Select item | +| `/` | Focus search | +| `Esc` | Clear/back | +| `t` | Toggle JSON view | +| `m` | Mask/unmask secrets | +| `p` | Copy password | +| `u` | Copy username | +| `c` | Copy all fields | +| `:cmd` | Run Keeper command | +| `Ctrl+\` | Toggle shell pane | +| `?` | Show help | +| `!` | Exit to Keeper shell | + +## Adding New Features + +### Adding a New Keyboard Shortcut + +1. Create a handler class in `handlers/keyboard.py`: +```python +class MyNewHandler(KeyHandler): + def can_handle(self, event, app): + return event.key == "my_key" and not app.search_input_active + + def handle(self, event, app): + # Do something + self._stop_event(event) + return True +``` + +2. Add to `KeyboardDispatcher.handlers` list in appropriate priority position + +### Adding a New Theme + +1. Add color dict to `themes/colors.py`: +```python +COLOR_THEMES = { + # ... existing themes + 'mytheme': { + 'primary': '#ff0000', + 'secondary': '#00ff00', + # ... all 18 color properties + } +} +``` + +2. Update `screens/preferences.py` to show the new option + +### Adding a New Display Mode + +1. Add rendering logic to `renderers/` +2. Add state tracking to `state/ui_state.py` if needed +3. Add toggle action in main app +4. Add keyboard binding via handler + +## Testing + +Run SuperShell with: +```bash +keeper supershell +``` + +Or with the alias: +```bash +keeper ss +``` + +## Dependencies + +- **textual** - TUI framework +- **rich** - Terminal formatting (used by Textual) +- **pyperclip** - Clipboard operations diff --git a/keepercommander/commands/supershell/__init__.py b/keepercommander/commands/supershell/__init__.py new file mode 100644 index 000000000..bd78ee783 --- /dev/null +++ b/keepercommander/commands/supershell/__init__.py @@ -0,0 +1,67 @@ +""" +Keeper SuperShell - A full-screen terminal UI for Keeper vault + +This package provides a modern TUI interface with vim-style navigation +for browsing and managing Keeper vault records. + +During refactoring, the main implementation is in _supershell_impl.py. +This will be gradually migrated into this package structure. +""" + +# Re-export from implementation file for backward compatibility +from .._supershell_impl import SuperShellCommand, SuperShellApp + +# Export theme and utility modules +from .themes import COLOR_THEMES +from .screens import PreferencesScreen, HelpScreen +from .utils import load_preferences, save_preferences, strip_ansi_codes +from .widgets import ClickableDetailLine, ClickableField, ClickableRecordUID +from .state import VaultData, UIState, ThemeState, SelectionState +from .renderers import ( + is_sensitive_field, + mask_passwords_in_json, + strip_field_type_prefix, + is_section_header, + JsonRenderer, + FolderJsonRenderer, +) +from .handlers import ( + KeyHandler, + KeyboardDispatcher, + keyboard_dispatcher, +) + +__all__ = [ + # Main classes + 'SuperShellCommand', + 'SuperShellApp', + # Themes + 'COLOR_THEMES', + # Screens + 'PreferencesScreen', + 'HelpScreen', + # Utils + 'load_preferences', + 'save_preferences', + 'strip_ansi_codes', + # Widgets + 'ClickableDetailLine', + 'ClickableField', + 'ClickableRecordUID', + # State + 'VaultData', + 'UIState', + 'ThemeState', + 'SelectionState', + # Renderers + 'is_sensitive_field', + 'mask_passwords_in_json', + 'strip_field_type_prefix', + 'is_section_header', + 'JsonRenderer', + 'FolderJsonRenderer', + # Handlers + 'KeyHandler', + 'KeyboardDispatcher', + 'keyboard_dispatcher', +] diff --git a/keepercommander/commands/supershell/constants.py b/keepercommander/commands/supershell/constants.py new file mode 100644 index 000000000..90b65fd95 --- /dev/null +++ b/keepercommander/commands/supershell/constants.py @@ -0,0 +1,41 @@ +""" +SuperShell constants + +Thresholds, limits, and other constant values. +""" + +# Auto-expand folders with fewer records than this +AUTO_EXPAND_THRESHOLD = 20 + +# Maximum devices to show in device status dropdown +DEVICE_DISPLAY_LIMIT = 10 + +# Sensitive field names that should be masked +SENSITIVE_FIELD_NAMES = frozenset({ + 'password', 'secret', 'pin', 'token', 'key', 'apikey', 'api_key', + 'privatekey', 'private_key', 'secret2', 'pincode', 'passphrase', + 'onetimecode', 'totp', 'passkey' +}) + +# Field type prefixes to strip from display names +FIELD_TYPE_PREFIXES = ( + 'text:', 'multiline:', 'url:', 'phone:', 'email:', + 'secret:', 'date:', 'name:', 'host:', 'address:' +) + +# Friendly names for field types +FIELD_TYPE_FRIENDLY_NAMES = { + 'text:': 'Text', + 'multiline:': 'Note', + 'url:': 'URL', + 'phone:': 'Phone', + 'email:': 'Email', + 'secret:': 'Secret', + 'date:': 'Date', + 'name:': 'Name', + 'host:': 'Host', + 'address:': 'Address', +} + +# Reference field types to skip in display (shown elsewhere) +REFERENCE_FIELD_TYPES = frozenset({'fileRef', 'addressRef', 'cardRef'}) diff --git a/keepercommander/commands/supershell/data/__init__.py b/keepercommander/commands/supershell/data/__init__.py new file mode 100644 index 000000000..85b3bba61 --- /dev/null +++ b/keepercommander/commands/supershell/data/__init__.py @@ -0,0 +1,21 @@ +""" +SuperShell data loading + +Functions for loading vault data, building trees, and searching. +""" + +from .vault_loader import load_vault_data +from .search import ( + search_records, + filter_records_by_folder, + get_root_records, + count_records_in_folder, +) + +__all__ = [ + 'load_vault_data', + 'search_records', + 'filter_records_by_folder', + 'get_root_records', + 'count_records_in_folder', +] diff --git a/keepercommander/commands/supershell/data/search.py b/keepercommander/commands/supershell/data/search.py new file mode 100644 index 000000000..4d75521d4 --- /dev/null +++ b/keepercommander/commands/supershell/data/search.py @@ -0,0 +1,174 @@ +""" +Search functions for SuperShell + +Functions for searching and filtering vault records. +""" + +from typing import Dict, Set, Optional, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ....params import KeeperParams + + +def search_records( + query: str, + records: Dict[str, dict], + record_to_folder: Dict[str, str], + params: 'KeeperParams' +) -> Optional[Set[str]]: + """ + Search records with smart partial matching. + Returns set of matching record UIDs, or None if no query. + + Search logic: + - Tokenizes query by whitespace + - Each token must match (partial) at least one field OR folder name + - Order doesn't matter: "aws prod us" matches "us production aws" + - Searches: title, url, custom field values, notes, AND folder name + - If folder name matches, all records in that folder are candidates + (but other tokens must still match the record) + + Args: + query: Search query string + records: Dict mapping record_uid -> record data + record_to_folder: Dict mapping record_uid -> folder_uid + params: Keeper params (for folder_cache) + + Returns: + Set of matching record UIDs, or None if no query (show all) + """ + if not query or not query.strip(): + return None # None means show all + + # Tokenize query - split by whitespace and lowercase + query_tokens = [token.lower().strip() for token in query.split() if token.strip()] + if not query_tokens: + return None + + matching_uids: Set[str] = set() + + # Build folder name cache for quick lookup + folder_names: Dict[str, str] = {} + if hasattr(params, 'folder_cache'): + for folder_uid, folder in params.folder_cache.items(): + if hasattr(folder, 'name') and folder.name: + folder_names[folder_uid] = folder.name.lower() + + for record_uid, record in records.items(): + # Build searchable text from all record fields + record_parts = [] + + # Record UID - important for searching by UID + record_parts.append(record_uid) + + # Title + if record.get('title'): + record_parts.append(str(record['title'])) + + # URL + if record.get('login_url'): + record_parts.append(str(record['login_url'])) + + # Username/Login + if record.get('login'): + record_parts.append(str(record['login'])) + + # Custom fields + if record.get('custom_fields'): + for field in record['custom_fields']: + name = field.get('name', '') + value = field.get('value', '') + if name: + record_parts.append(str(name)) + if value: + record_parts.append(str(value)) + + # Notes + if record.get('notes'): + record_parts.append(str(record['notes'])) + + # Combine record text + record_text = ' '.join(record_parts).lower() + + # Get folder UID and name for this record + folder_uid = record_to_folder.get(record_uid) + folder_name = folder_names.get(folder_uid, '') if folder_uid else '' + + # Combined text includes record fields, folder UID, AND folder name + combined_text = record_text + ' ' + (folder_uid.lower() if folder_uid else '') + ' ' + folder_name + + # Check if ALL query tokens match somewhere (record OR folder) + all_tokens_match = all( + token in combined_text + for token in query_tokens + ) + + if all_tokens_match: + matching_uids.add(record_uid) + + return matching_uids + + +def filter_records_by_folder( + records: Dict[str, dict], + record_to_folder: Dict[str, str], + folder_uid: str +) -> Set[str]: + """Get all record UIDs in a specific folder. + + Args: + records: Dict mapping record_uid -> record data + record_to_folder: Dict mapping record_uid -> folder_uid + folder_uid: Folder UID to filter by + + Returns: + Set of record UIDs in the folder + """ + return { + record_uid + for record_uid, rec_folder in record_to_folder.items() + if rec_folder == folder_uid and record_uid in records + } + + +def get_root_records( + records: Dict[str, dict], + records_in_subfolders: Set[str] +) -> Set[str]: + """Get all record UIDs that are in the root folder (not in any subfolder). + + Args: + records: Dict mapping record_uid -> record data + records_in_subfolders: Set of record UIDs in subfolders + + Returns: + Set of record UIDs in root folder + """ + return { + record_uid + for record_uid in records + if record_uid not in records_in_subfolders + } + + +def count_records_in_folder( + record_to_folder: Dict[str, str], + folder_uid: str, + filtered_uids: Optional[Set[str]] = None +) -> int: + """Count records in a folder, optionally filtered by search results. + + Args: + record_to_folder: Dict mapping record_uid -> folder_uid + folder_uid: Folder UID to count + filtered_uids: Optional set of UIDs to restrict count to + + Returns: + Count of records in folder + """ + count = 0 + for record_uid, rec_folder in record_to_folder.items(): + if rec_folder == folder_uid: + if filtered_uids is None or record_uid in filtered_uids: + count += 1 + return count diff --git a/keepercommander/commands/supershell/data/vault_loader.py b/keepercommander/commands/supershell/data/vault_loader.py new file mode 100644 index 000000000..49cfb88fd --- /dev/null +++ b/keepercommander/commands/supershell/data/vault_loader.py @@ -0,0 +1,254 @@ +""" +Vault data loading functions + +Functions for loading and parsing vault data from Keeper params. +""" + +import json +import logging +from typing import Dict, Set, List, Any, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ....params import KeeperParams + +from ..state import VaultData + + +def load_vault_data(params: 'KeeperParams') -> VaultData: + """Load vault data from params and return a VaultData instance. + + This is a pure function that extracts vault data without side effects. + + Args: + params: Keeper params with record_cache, folder_cache, etc. + + Returns: + VaultData instance with all vault data loaded + """ + from .... import vault + + # Initialize collections + records: Dict[str, dict] = {} + record_to_folder: Dict[str, str] = {} + records_in_subfolders: Set[str] = set() + file_attachment_to_parent: Dict[str, str] = {} + record_file_attachments: Dict[str, List[str]] = {} + linked_record_to_parent: Dict[str, str] = {} + record_linked_records: Dict[str, List[str]] = {} + app_record_uids: Set[str] = set() + + # Build record to folder mapping + if hasattr(params, 'subfolder_record_cache'): + for folder_uid, record_uids in params.subfolder_record_cache.items(): + for record_uid in record_uids: + record_to_folder[record_uid] = folder_uid + if folder_uid and folder_uid != '': + records_in_subfolders.add(record_uid) + + # Process records + if hasattr(params, 'record_cache'): + for record_uid, record_data in params.record_cache.items(): + try: + record = vault.KeeperRecord.load(params, record_uid) + if not record: + continue + + # Get record type + record_type = _get_record_type(record, params, record_uid) + + record_dict = { + 'uid': record_uid, + 'title': record.title if hasattr(record, 'title') else 'Untitled', + 'folder_uid': record_to_folder.get(record_uid), + 'record_type': record_type, + } + + # Track Secrets Manager apps + if record_type == 'app': + app_record_uids.add(record_uid) + + # Extract file references + file_refs = _extract_file_refs(record, record_uid) + for ref_uid in file_refs: + file_attachment_to_parent[ref_uid] = record_uid + if file_refs: + record_file_attachments[record_uid] = file_refs + + # Extract linked record references + linked_refs = _extract_linked_refs(record, record_uid) + for ref_uid in linked_refs: + linked_record_to_parent[ref_uid] = record_uid + if linked_refs: + record_linked_records[record_uid] = linked_refs + + # Extract common fields + _extract_record_fields(record, record_dict) + + records[record_uid] = record_dict + + except Exception as e: + logging.debug(f"Error loading record {record_uid}: {e}") + continue + + return VaultData( + records=records, + record_to_folder=record_to_folder, + records_in_subfolders=records_in_subfolders, + file_attachment_to_parent=file_attachment_to_parent, + record_file_attachments=record_file_attachments, + linked_record_to_parent=linked_record_to_parent, + record_linked_records=record_linked_records, + app_record_uids=app_record_uids, + ) + + +def _get_record_type(record: Any, params: 'KeeperParams', record_uid: str) -> str: + """Extract record type using multiple approaches.""" + record_type = 'login' # Default + + # Try get_record_type() method + if hasattr(record, 'get_record_type'): + try: + rt = record.get_record_type() + if rt: + return rt + except: + pass + + # Try record_type property + if hasattr(record, 'record_type'): + try: + rt = record.record_type + if rt: + return rt + except: + pass + + # Fallback: try cached data + cached_rec = params.record_cache.get(record_uid, {}) + version = cached_rec.get('version', 2) + if version == 3: + try: + rec_data = cached_rec.get('data_unencrypted') + if rec_data: + if isinstance(rec_data, bytes): + rec_data = rec_data.decode('utf-8') + data_obj = json.loads(rec_data) + rt = data_obj.get('type') + if rt: + return rt + except: + pass + elif version == 2: + return 'legacy' + + return record_type + + +def _extract_file_refs(record: Any, record_uid: str) -> List[str]: + """Extract file reference UIDs from record fields.""" + file_refs = [] + + if not hasattr(record, 'fields'): + return file_refs + + for field in record.fields: + field_type = getattr(field, 'type', None) + field_value = getattr(field, 'value', None) + + if field_type == 'fileRef': + if field_value and isinstance(field_value, list): + for ref_uid in field_value: + if isinstance(ref_uid, str) and ref_uid: + file_refs.append(ref_uid) + + elif field_type == 'script': + if field_value and isinstance(field_value, list): + for script_item in field_value: + if isinstance(script_item, dict): + ref_uid = script_item.get('fileRef') + if ref_uid and isinstance(ref_uid, str): + file_refs.append(ref_uid) + + return file_refs + + +def _extract_linked_refs(record: Any, record_uid: str) -> List[str]: + """Extract linked record reference UIDs (addressRef, cardRef, etc.).""" + linked_refs = [] + + if not hasattr(record, 'fields'): + return linked_refs + + for field in record.fields: + field_type = getattr(field, 'type', None) + field_value = getattr(field, 'value', None) + + if field_type in ('addressRef', 'cardRef'): + if field_value and isinstance(field_value, list): + for ref_uid in field_value: + if isinstance(ref_uid, str) and ref_uid: + linked_refs.append(ref_uid) + + return linked_refs + + +def _extract_record_fields(record: Any, record_dict: dict) -> None: + """Extract common fields from record into record_dict.""" + # Basic fields + if hasattr(record, 'login'): + record_dict['login'] = record.login + if hasattr(record, 'password'): + record_dict['password'] = record.password + if hasattr(record, 'login_url'): + record_dict['login_url'] = record.login_url + if hasattr(record, 'notes'): + record_dict['notes'] = record.notes + if hasattr(record, 'totp') and record.totp: + record_dict['totp_url'] = record.totp + + # Typed record fields + if hasattr(record, 'fields'): + custom_fields = [] + for field in record.fields: + field_type = getattr(field, 'type', None) + field_value = getattr(field, 'value', None) + field_label = getattr(field, 'label', None) + + # Extract password + if field_type == 'password' and field_value and not record_dict.get('password'): + if isinstance(field_value, list) and len(field_value) > 0: + record_dict['password'] = field_value[0] + elif isinstance(field_value, str): + record_dict['password'] = field_value + + # Extract login + if field_type == 'login' and field_value and not record_dict.get('login'): + if isinstance(field_value, list) and len(field_value) > 0: + record_dict['login'] = field_value[0] + elif isinstance(field_value, str): + record_dict['login'] = field_value + + # Extract URL + if field_type == 'url' and field_value and not record_dict.get('login_url'): + if isinstance(field_value, list) and len(field_value) > 0: + record_dict['login_url'] = field_value[0] + elif isinstance(field_value, str): + record_dict['login_url'] = field_value + + # Extract TOTP + if field_type == 'oneTimeCode' and field_value and not record_dict.get('totp_url'): + if isinstance(field_value, list) and len(field_value) > 0: + record_dict['totp_url'] = field_value[0] + elif isinstance(field_value, str): + record_dict['totp_url'] = field_value + + # Collect custom fields + if field_label and field_value: + custom_fields.append({ + 'name': field_label, + 'value': str(field_value) if field_value else '' + }) + + if custom_fields: + record_dict['custom_fields'] = custom_fields diff --git a/keepercommander/commands/supershell/handlers/__init__.py b/keepercommander/commands/supershell/handlers/__init__.py new file mode 100644 index 000000000..db4e90f38 --- /dev/null +++ b/keepercommander/commands/supershell/handlers/__init__.py @@ -0,0 +1,39 @@ +""" +SuperShell input handlers + +Keyboard and clipboard handlers with dispatch pattern. +""" + +from .keyboard import ( + KeyHandler, + KeyboardDispatcher, + keyboard_dispatcher, + GlobalExitHandler, + ShellPaneToggleHandler, + CommandModeHandler, + ShellInputHandler, + ShellPaneCloseHandler, + SearchInputTabHandler, + DetailPaneHandler, + SearchBarTreeNavigationHandler, + SearchInputHandler, + TreeArrowHandler, + TreeEscapeHandler, +) + +__all__ = [ + 'KeyHandler', + 'KeyboardDispatcher', + 'keyboard_dispatcher', + 'GlobalExitHandler', + 'ShellPaneToggleHandler', + 'CommandModeHandler', + 'ShellInputHandler', + 'ShellPaneCloseHandler', + 'SearchInputTabHandler', + 'DetailPaneHandler', + 'SearchBarTreeNavigationHandler', + 'SearchInputHandler', + 'TreeArrowHandler', + 'TreeEscapeHandler', +] diff --git a/keepercommander/commands/supershell/handlers/keyboard.py b/keepercommander/commands/supershell/handlers/keyboard.py new file mode 100644 index 000000000..6fc5d0635 --- /dev/null +++ b/keepercommander/commands/supershell/handlers/keyboard.py @@ -0,0 +1,570 @@ +""" +Keyboard handling for SuperShell + +Implements a dispatcher pattern to handle keyboard events based on +the current application state (search mode, shell mode, command mode, etc.) +""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional, List + +from rich.markup import escape as rich_escape + +if TYPE_CHECKING: + from textual.events import Key + from textual.widgets import Tree + from .._supershell_impl import SuperShellApp + + +class KeyHandler(ABC): + """Base class for keyboard event handlers. + + Each handler is responsible for a specific context (e.g., command mode, + search input, shell input). The dispatcher checks each handler in order + until one handles the event. + """ + + @abstractmethod + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + """Check if this handler can handle the given event. + + Args: + event: The keyboard event + app: The SuperShell app instance + + Returns: + True if this handler should handle the event + """ + pass + + @abstractmethod + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + """Handle the keyboard event. + + Args: + event: The keyboard event + app: The SuperShell app instance + + Returns: + True if the event was handled and should not propagate + """ + pass + + def _stop_event(self, event: 'Key') -> None: + """Helper to stop event propagation.""" + event.prevent_default() + event.stop() + + +class GlobalExitHandler(KeyHandler): + """Handles ! key to exit to Keeper shell.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + return ( + event.character == "!" and + not app.search_input_active and + not app.shell_input_active + ) + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + app.exit("Exited to Keeper shell. Type 'supershell' or 'ss' to return.") + self._stop_event(event) + return True + + +class ShellPaneToggleHandler(KeyHandler): + """Handles Ctrl+\\ to toggle shell pane.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + return event.key == "ctrl+backslash" + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + if app.shell_pane_visible: + app._close_shell_pane() + else: + app._open_shell_pane() + self._stop_event(event) + return True + + +class CommandModeHandler(KeyHandler): + """Handles vim-style :command mode.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + # Handle when in command mode OR when entering command mode with : + if app.search_input_active or app.shell_input_active: + return False + return app.command_mode or event.character == ":" + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + # Enter command mode with : + if event.character == ":" and not app.command_mode: + app.command_mode = True + app.command_buffer = "" + app._update_status(":") + self._stop_event(event) + return True + + # Already in command mode + if app.command_mode: + if event.key == "escape": + app.command_mode = False + app.command_buffer = "" + app._update_status("Command cancelled") + self._stop_event(event) + return True + + elif event.key == "enter": + app._execute_command(app.command_buffer) + app.command_mode = False + app.command_buffer = "" + self._stop_event(event) + return True + + elif event.key == "backspace": + if app.command_buffer: + app.command_buffer = app.command_buffer[:-1] + app._update_status(f":{app.command_buffer}") + else: + app.command_mode = False + app._update_status("Navigate with j/k | / to search | ? for help") + self._stop_event(event) + return True + + elif event.character and event.character.isprintable(): + app.command_buffer += event.character + app._update_status(f":{app.command_buffer}") + self._stop_event(event) + return True + + return False + + +class ShellInputHandler(KeyHandler): + """Handles input when shell pane is visible and active.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + return app.shell_pane_visible and app.shell_input_active + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + tree = app.query_one("#folder_tree") + + if event.key == "ctrl+d": + app._close_shell_pane() + self._stop_event(event) + return True + + if event.key == "enter": + app._execute_shell_command(app.shell_input_text) + app.shell_input_text = "" + app._update_shell_input_display() + self._stop_event(event) + return True + + if event.key == "backspace": + if app.shell_input_text: + app.shell_input_text = app.shell_input_text[:-1] + app._update_shell_input_display() + self._stop_event(event) + return True + + if event.key == "escape": + app.shell_input_active = False + tree.focus() + app._update_shell_input_display() + app._update_status("Shell open | Tab to cycle | press Enter in shell to run commands") + self._stop_event(event) + return True + + if event.key == "up": + if app.shell_command_history: + if app.shell_history_index < len(app.shell_command_history) - 1: + app.shell_history_index += 1 + app.shell_input_text = app.shell_command_history[-(app.shell_history_index + 1)] + app._update_shell_input_display() + self._stop_event(event) + return True + + if event.key == "down": + if app.shell_history_index > 0: + app.shell_history_index -= 1 + app.shell_input_text = app.shell_command_history[-(app.shell_history_index + 1)] + elif app.shell_history_index == 0: + app.shell_history_index = -1 + app.shell_input_text = "" + app._update_shell_input_display() + self._stop_event(event) + return True + + if event.character and event.character.isprintable(): + app.shell_input_text += event.character + app._update_shell_input_display() + self._stop_event(event) + return True + + return False + + +class ShellPaneCloseHandler(KeyHandler): + """Handles Ctrl+D to close shell even when not focused on input.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + return app.shell_pane_visible and event.key == "ctrl+d" + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + app._close_shell_pane() + self._stop_event(event) + return True + + +class SearchInputTabHandler(KeyHandler): + """Handles Tab/Shift+Tab when in search input mode.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + return app.search_input_active and event.key in ("tab", "shift+tab") + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.widgets import Tree + from textual.containers import VerticalScroll + + tree = app.query_one("#folder_tree", Tree) + detail_scroll = app.query_one("#record_detail", VerticalScroll) + search_display = app.query_one("#search_display") + + app.search_input_active = False + tree.remove_class("search-input-active") + + if app.search_input_text: + search_display.update(rich_escape(app.search_input_text)) + else: + search_display.update("[dim]Search...[/dim]") + + if event.key == "tab": + # Search input β†’ Tree + tree.focus() + app._update_status("Navigate with j/k | Tab to detail | ? for help") + else: + # Shift+Tab: Search input β†’ Shell (if visible) or Detail pane + if app.shell_pane_visible: + app.shell_input_active = True + app._update_shell_input_display() + app._update_status("Shell | Shift+Tab to detail | Tab to search") + else: + detail_scroll.focus() + app._update_status("Detail pane | Tab to search | Shift+Tab to tree") + + self._stop_event(event) + return True + + +class DetailPaneHandler(KeyHandler): + """Handles keyboard events when detail pane has focus.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.containers import VerticalScroll + detail_scroll = app.query_one("#record_detail", VerticalScroll) + return detail_scroll.has_focus + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.widgets import Tree + from textual.containers import VerticalScroll + + tree = app.query_one("#folder_tree", Tree) + detail_scroll = app.query_one("#record_detail", VerticalScroll) + + if event.key == "tab": + # Detail pane β†’ Shell (if visible) or Search input + if app.shell_pane_visible: + app.shell_input_active = True + app._update_shell_input_display() + app._update_status("Shell | Tab to search | Shift+Tab to detail") + else: + app.search_input_active = True + tree.add_class("search-input-active") + app._update_search_display(perform_search=False) + app._update_status("Type to search | Tab to tree | Ctrl+U to clear") + self._stop_event(event) + return True + + if event.key == "shift+tab": + # Detail pane β†’ Tree + tree.focus() + app._update_status("Navigate with j/k | Tab to detail | ? for help") + self._stop_event(event) + return True + + if event.key == "escape": + tree.focus() + self._stop_event(event) + return True + + if event.key == "ctrl+y": + # Ctrl+Y scrolls viewport up (like vim) + detail_scroll.scroll_relative(y=-1) + self._stop_event(event) + return True + + if event.key == "ctrl+e": + # Ctrl+E scrolls viewport down (like vim) + detail_scroll.scroll_relative(y=1) + self._stop_event(event) + return True + + return False + + +class SearchBarTreeNavigationHandler(KeyHandler): + """Handles tree navigation when search bar is visible but not typing.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + search_bar = app.query_one("#search_bar") + from textual.widgets import Tree + tree = app.query_one("#folder_tree", Tree) + return ( + search_bar.styles.display != "none" and + not app.search_input_active and + tree.has_focus + ) + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.widgets import Tree + from textual.containers import VerticalScroll + + tree = app.query_one("#folder_tree", Tree) + detail_scroll = app.query_one("#record_detail", VerticalScroll) + + # Handle arrow keys for expand/collapse + if event.key == "left": + if tree.cursor_node and tree.cursor_node.allow_expand: + tree.cursor_node.collapse() + self._stop_event(event) + return True + + if event.key == "right": + if tree.cursor_node and tree.cursor_node.allow_expand: + tree.cursor_node.expand() + self._stop_event(event) + return True + + # Navigation keys - let tree handle them + if event.key in ("j", "k", "h", "l", "up", "down", "enter", "space"): + return False + + # Action keys - let them pass through + if event.key in ("t", "c", "u", "w", "i", "y", "d", "g", "p", "question_mark"): + return False + + # Shift+G for go to bottom + if event.character == "G": + return False + + # Tab switches to detail pane + if event.key == "tab": + detail_scroll.focus() + app._update_status("Detail pane | Tab to search | Shift+Tab to tree") + self._stop_event(event) + return True + + # Shift+Tab switches to search input + if event.key == "shift+tab": + app.search_input_active = True + tree.add_class("search-input-active") + app._update_search_display(perform_search=False) + app._update_status("Type to search | Tab to tree | Ctrl+U to clear") + self._stop_event(event) + return True + + # / switches back to search input mode + if event.key == "slash": + app.search_input_active = True + tree.add_class("search-input-active") + app._update_search_display(perform_search=False) + self._stop_event(event) + return True + + return False + + +class SearchInputHandler(KeyHandler): + """Handles keyboard input in search mode.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + search_bar = app.query_one("#search_bar") + return search_bar.styles.display != "none" + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.widgets import Tree + + tree = app.query_one("#folder_tree", Tree) + + # Ctrl+U clears the search input + if event.key == "ctrl+u" and app.search_input_active: + app.search_input_text = "" + app._update_search_display(perform_search=False) + app._perform_live_search("") + self._stop_event(event) + return True + + # / to switch to search input mode + if event.key == "slash" and not app.search_input_active: + app.search_input_active = True + tree.add_class("search-input-active") + app._update_search_display(perform_search=False) + self._stop_event(event) + return True + + if event.key == "escape": + # Clear search and move focus to tree + app.search_input_text = "" + app.search_input_active = False + tree.remove_class("search-input-active") + app._perform_live_search("") + + search_display = app.query_one("#search_display") + search_display.update("[dim]Search... (Tab or /)[/dim]") + results_label = app.query_one("#search_results_label") + results_label.update("") + + # Restore previous selection + app.selected_record = app.pre_search_selected_record + app.selected_folder = app.pre_search_selected_folder + app._restore_tree_selection(tree) + + tree.focus() + app._update_status("Navigate with j/k | Tab to detail | ? for help") + self._stop_event(event) + return True + + if event.key in ("enter", "down") and app.search_input_active: + # Move focus to tree to navigate results (only when typing in search) + app.search_input_active = False + tree.remove_class("search-input-active") + + search_display = app.query_one("#search_display") + if app.search_input_text: + search_display.update(rich_escape(app.search_input_text)) + else: + search_display.update("[dim]Search...[/dim]") + + app.set_focus(tree) + tree.focus() + + app._update_status("Navigate results with j/k | / to edit search | ESC to close") + self._stop_event(event) + return True + + if event.key == "backspace": + if app.search_input_text: + app.search_input_text = app.search_input_text[:-1] + app._update_search_display() + self._stop_event(event) + return True + + if app.search_input_active and event.character and event.character.isprintable(): + app.search_input_text += event.character + app._update_search_display() + self._stop_event(event) + return True + + return False + + +class TreeArrowHandler(KeyHandler): + """Handles arrow keys for tree expand/collapse when search is not active.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + search_bar = app.query_one("#search_bar") + from textual.widgets import Tree + tree = app.query_one("#folder_tree", Tree) + return ( + search_bar.styles.display == "none" and + tree.has_focus and + event.key in ("left", "right") + ) + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.widgets import Tree + tree = app.query_one("#folder_tree", Tree) + + if event.key == "left": + if tree.cursor_node and tree.cursor_node.allow_expand: + tree.cursor_node.collapse() + self._stop_event(event) + return True + + if event.key == "right": + if tree.cursor_node and tree.cursor_node.allow_expand: + tree.cursor_node.expand() + self._stop_event(event) + return True + + return False + + +class TreeEscapeHandler(KeyHandler): + """Handles Escape to collapse current or go to parent when search not active.""" + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + search_bar = app.query_one("#search_bar") + return search_bar.styles.display == "none" and event.key == "escape" + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + from textual.widgets import Tree + tree = app.query_one("#folder_tree", Tree) + app._collapse_current_or_parent(tree) + self._stop_event(event) + return True + + +class KeyboardDispatcher: + """Dispatches keyboard events to appropriate handlers. + + Handlers are checked in order until one handles the event. + The order matters - more specific handlers should come before + more general ones. + """ + + def __init__(self): + """Initialize with the handler chain.""" + self.handlers: List[KeyHandler] = [ + # Global handlers (highest priority) + GlobalExitHandler(), + ShellPaneToggleHandler(), + + # Mode-specific handlers + CommandModeHandler(), + ShellInputHandler(), + ShellPaneCloseHandler(), + + # Tab cycling handlers + SearchInputTabHandler(), + DetailPaneHandler(), + + # Search handlers + SearchBarTreeNavigationHandler(), + SearchInputHandler(), + + # Tree handlers (lowest priority) + TreeArrowHandler(), + TreeEscapeHandler(), + ] + + def dispatch(self, event: 'Key', app: 'SuperShellApp') -> bool: + """Dispatch a keyboard event to the appropriate handler. + + Args: + event: The keyboard event + app: The SuperShell app instance + + Returns: + True if the event was handled + """ + for handler in self.handlers: + if handler.can_handle(event, app): + if handler.handle(event, app): + return True + return False + + +# Module-level dispatcher instance for use by the app +keyboard_dispatcher = KeyboardDispatcher() diff --git a/keepercommander/commands/supershell/renderers/__init__.py b/keepercommander/commands/supershell/renderers/__init__.py new file mode 100644 index 000000000..97669ced1 --- /dev/null +++ b/keepercommander/commands/supershell/renderers/__init__.py @@ -0,0 +1,95 @@ +""" +SuperShell display renderers + +Functions and classes for formatting records, folders, and JSON for display +with syntax highlighting and copy-on-click functionality. +""" + +from .json_syntax import ( + SENSITIVE_FIELD_NAMES, + is_sensitive_field, + mask_passwords_in_json, + format_json_key, + format_json_string, + format_json_number, + format_json_boolean, + format_json_null, + get_json_value_for_copy, +) + +from .record import ( + FIELD_TYPE_PREFIXES, + TYPE_FRIENDLY_NAMES, + RECORD_SECTION_HEADERS, + strip_field_type_prefix, + is_section_header, + format_uid_line, + format_title_line, + format_type_line, + format_password_line, + format_field_line, + format_section_header, + format_totp_display, + format_attachment_line, + format_rotation_status, + format_rotation_last_status, + JsonRenderer, +) + +from .folder import ( + FOLDER_SECTION_HEADERS, + is_folder_section_header, + format_folder_uid_line, + format_folder_name_line, + format_folder_type_line, + format_folder_section_header, + format_folder_boolean_field, + format_folder_field_line, + format_record_permission_line, + format_separator_line, + count_share_admins, + FolderJsonRenderer, +) + +__all__ = [ + # JSON syntax helpers + 'SENSITIVE_FIELD_NAMES', + 'is_sensitive_field', + 'mask_passwords_in_json', + 'format_json_key', + 'format_json_string', + 'format_json_number', + 'format_json_boolean', + 'format_json_null', + 'get_json_value_for_copy', + # Record rendering + 'FIELD_TYPE_PREFIXES', + 'TYPE_FRIENDLY_NAMES', + 'RECORD_SECTION_HEADERS', + 'strip_field_type_prefix', + 'is_section_header', + 'format_uid_line', + 'format_title_line', + 'format_type_line', + 'format_password_line', + 'format_field_line', + 'format_section_header', + 'format_totp_display', + 'format_attachment_line', + 'format_rotation_status', + 'format_rotation_last_status', + 'JsonRenderer', + # Folder rendering + 'FOLDER_SECTION_HEADERS', + 'is_folder_section_header', + 'format_folder_uid_line', + 'format_folder_name_line', + 'format_folder_type_line', + 'format_folder_section_header', + 'format_folder_boolean_field', + 'format_folder_field_line', + 'format_record_permission_line', + 'format_separator_line', + 'count_share_admins', + 'FolderJsonRenderer', +] diff --git a/keepercommander/commands/supershell/renderers/folder.py b/keepercommander/commands/supershell/renderers/folder.py new file mode 100644 index 000000000..57f7bfaeb --- /dev/null +++ b/keepercommander/commands/supershell/renderers/folder.py @@ -0,0 +1,343 @@ +""" +Folder rendering utilities for SuperShell + +Functions for formatting folder data for display with syntax highlighting. +""" + +import json +from typing import Any, Dict, List, Optional, Callable + +from rich.markup import escape as rich_escape + + +# Section headers in folder detail output +FOLDER_SECTION_HEADERS = { + 'Record Permissions', 'User Permissions', + 'Team Permissions', 'Share Administrators' +} + + +def is_folder_section_header(key: str) -> bool: + """Check if key is a folder section header. + + Args: + key: Field name + + Returns: + True if this is a section header + """ + return key in FOLDER_SECTION_HEADERS + + +def format_folder_uid_line(key: str, value: str, theme_colors: dict) -> str: + """Format a folder UID field line. + + Args: + key: Field name (e.g., 'Shared Folder UID', 'Folder UID') + value: UID value + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]" + + +def format_folder_name_line(value: str, theme_colors: dict) -> str: + """Format a folder name line (bold). + + Args: + value: Folder name + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[bold {t['primary']}]{rich_escape(str(value))}[/bold {t['primary']}]" + + +def format_folder_type_line(key: str, value: str, theme_colors: dict) -> str: + """Format a folder type line. + + Args: + key: Field name (usually 'Type' or 'Folder Type') + value: Type value + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[{t['text_dim']}]Type:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]" + + +def format_folder_section_header( + name: str, + theme_colors: dict, + count: Optional[int] = None +) -> str: + """Format a folder section header line. + + Args: + name: Section name + theme_colors: Theme color dict + count: Optional count to display + + Returns: + Rich markup formatted line + """ + t = theme_colors + if count is not None and count > 0: + return f"[bold {t['primary_bright']}]{name}:[/bold {t['primary_bright']}] [{t['text_dim']}]({count} users)[/{t['text_dim']}]" + return f"[bold {t['primary_bright']}]{name}:[/bold {t['primary_bright']}]" + + +def format_folder_boolean_field( + key: str, + value: str, + theme_colors: dict, + in_section: bool = False +) -> str: + """Format a boolean field line in folder display. + + Args: + key: Field name + value: Boolean value as string ('true' or 'false') + theme_colors: Theme color dict + in_section: Whether this is inside a section (adds indent) + + Returns: + Rich markup formatted line + """ + t = theme_colors + color = t['primary'] if value.lower() == 'true' else t['primary_dim'] + indent = " " if in_section else "" + return f"{indent}[{t['secondary']}]{rich_escape(str(key))}:[/{t['secondary']}] [{color}]{rich_escape(str(value))}[/{color}]" + + +def format_folder_field_line( + key: str, + value: str, + theme_colors: dict, + in_section: bool = False +) -> str: + """Format a general folder field line. + + Args: + key: Field name + value: Field value + theme_colors: Theme color dict + in_section: Whether this is inside a section (adds indent) + + Returns: + Rich markup formatted line + """ + t = theme_colors + indent = " " if in_section else "" + return f"{indent}[{t['secondary']}]{rich_escape(str(key))}:[/{t['secondary']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]" + + +def format_record_permission_line( + title: str, + uid: str, + theme_colors: dict +) -> tuple: + """Format record permission lines (returns two lines). + + Args: + title: Record title + uid: Record UID + theme_colors: Theme color dict + + Returns: + Tuple of (title_line, uid_line) with Rich markup + """ + t = theme_colors + title_line = f" [{t['text_dim']}]Record:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(title))}[/{t['primary']}]" + uid_line = f" [{t['text_dim']}]UID:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(uid))}[/{t['primary']}]" + return title_line, uid_line + + +def format_separator_line(theme_colors: dict, width: int = 60) -> str: + """Format a separator line. + + Args: + theme_colors: Theme color dict + width: Width of the separator + + Returns: + Rich markup formatted separator + """ + t = theme_colors + return f"[bold {t['secondary']}]{'━' * width}[/bold {t['secondary']}]" + + +def count_share_admins(output: str) -> int: + """Count share admins in folder output. + + Args: + output: Raw folder detail output + + Returns: + Number of share admin users + """ + count = 0 + in_share_admins = False + + for line in output.split('\n'): + stripped = line.strip() + if ':' in stripped: + key = stripped.split(':', 1)[0].strip() + if key == 'Share Administrators': + in_share_admins = True + elif key in FOLDER_SECTION_HEADERS and key != 'Share Administrators': + in_share_admins = False + elif in_share_admins and key == 'User': + count += 1 + + return count + + +class FolderJsonRenderer: + """Renders folder JSON with syntax highlighting and clickable values. + + This class provides methods to render folder JSON as a series of lines + suitable for display in a TUI with copy-on-click functionality. + """ + + def __init__(self, theme_colors: dict): + """Initialize the folder JSON renderer. + + Args: + theme_colors: Theme color dictionary + """ + self.theme_colors = theme_colors + + def render_lines( + self, + json_obj: Any, + on_line: Callable[[str, Optional[str]], None] + ): + """Render a JSON object as a series of lines. + + Args: + json_obj: JSON object to render + on_line: Callback for each line: (content, copy_value) + """ + self._render_value(json_obj, on_line, indent=0) + + def _render_value( + self, + obj: Any, + on_line: Callable, + indent: int + ): + """Recursively render a JSON value.""" + t = self.theme_colors + + if isinstance(obj, dict): + # Opening brace - copyable with entire object + on_line(f"{' ' * indent}{{", json.dumps(obj, indent=2)) + items = list(obj.items()) + for i, (key, value) in enumerate(items): + comma = "," if i < len(items) - 1 else "" + self._render_key_value(key, value, on_line, indent + 1, comma) + on_line(f"{' ' * indent}}}", None) + + elif isinstance(obj, list): + # Opening bracket - copyable with entire array + on_line(f"{' ' * indent}[", json.dumps(obj, indent=2)) + for i, item in enumerate(obj): + comma = "," if i < len(obj) - 1 else "" + self._render_list_item(item, on_line, indent + 1, comma) + on_line(f"{' ' * indent}]", None) + + def _render_key_value( + self, + key: str, + value: Any, + on_line: Callable, + indent: int, + comma: str + ): + """Render a key-value pair.""" + t = self.theme_colors + prefix = " " * indent + + if isinstance(value, str): + escaped_value = rich_escape(value) + on_line( + f"{prefix}[{t['secondary']}]\"{rich_escape(key)}\"[/{t['secondary']}]: " + f"[{t['primary']}]\"{escaped_value}\"[/{t['primary']}]{comma}", + value + ) + elif isinstance(value, bool): + bool_str = "true" if value else "false" + on_line( + f"{prefix}[{t['secondary']}]\"{rich_escape(key)}\"[/{t['secondary']}]: " + f"[{t['primary_bright']}]{bool_str}[/{t['primary_bright']}]{comma}", + str(value) + ) + elif isinstance(value, (int, float)): + on_line( + f"{prefix}[{t['secondary']}]\"{rich_escape(key)}\"[/{t['secondary']}]: " + f"[{t['primary_bright']}]{value}[/{t['primary_bright']}]{comma}", + str(value) + ) + elif value is None: + on_line( + f"{prefix}[{t['secondary']}]\"{rich_escape(key)}\"[/{t['secondary']}]: " + f"[{t['text_dim']}]null[/{t['text_dim']}]{comma}", + None + ) + elif isinstance(value, dict): + on_line( + f"{prefix}[{t['secondary']}]\"{rich_escape(key)}\"[/{t['secondary']}]: {{", + json.dumps(value, indent=2) + ) + items = list(value.items()) + for i, (k, v) in enumerate(items): + item_comma = "," if i < len(items) - 1 else "" + self._render_key_value(k, v, on_line, indent + 1, item_comma) + on_line(f"{prefix}}}{comma}", None) + elif isinstance(value, list): + on_line( + f"{prefix}[{t['secondary']}]\"{rich_escape(key)}\"[/{t['secondary']}]: [", + json.dumps(value, indent=2) + ) + for i, item in enumerate(value): + item_comma = "," if i < len(value) - 1 else "" + self._render_list_item(item, on_line, indent + 1, item_comma) + on_line(f"{prefix}]{comma}", None) + + def _render_list_item( + self, + item: Any, + on_line: Callable, + indent: int, + comma: str + ): + """Render a single list item.""" + t = self.theme_colors + prefix = " " * indent + + if isinstance(item, str): + escaped_item = rich_escape(item) + on_line(f"{prefix}[{t['primary']}]\"{escaped_item}\"[/{t['primary']}]{comma}", item) + elif isinstance(item, dict): + on_line(f"{prefix}{{", json.dumps(item, indent=2)) + items = list(item.items()) + for i, (k, v) in enumerate(items): + item_comma = "," if i < len(items) - 1 else "" + self._render_key_value(k, v, on_line, indent + 1, item_comma) + on_line(f"{prefix}}}{comma}", None) + elif isinstance(item, list): + on_line(f"{prefix}[", json.dumps(item, indent=2)) + for i, sub_item in enumerate(item): + item_comma = "," if i < len(item) - 1 else "" + self._render_list_item(sub_item, on_line, indent + 1, item_comma) + on_line(f"{prefix}]{comma}", None) + else: + on_line(f"{prefix}[{t['primary_bright']}]{item}[/{t['primary_bright']}]{comma}", str(item)) diff --git a/keepercommander/commands/supershell/renderers/json_syntax.py b/keepercommander/commands/supershell/renderers/json_syntax.py new file mode 100644 index 000000000..7f1f3e894 --- /dev/null +++ b/keepercommander/commands/supershell/renderers/json_syntax.py @@ -0,0 +1,168 @@ +""" +JSON syntax highlighting and masking utilities + +Functions for rendering JSON with syntax highlighting and password masking. +""" + +from typing import Any, Dict, List, Optional, Set + +# Fields that should be masked when displaying +SENSITIVE_FIELD_NAMES: Set[str] = { + 'password', 'secret', 'passphrase', 'pin', 'token', 'key', + 'apikey', 'api_key', 'privatekey', 'private_key', 'secret2', + 'pincode', 'onetimecode', 'totp' +} + + +def is_sensitive_field(field_name: str) -> bool: + """Check if a field name indicates it contains sensitive data. + + Args: + field_name: Name of the field to check + + Returns: + True if the field appears to contain sensitive data + """ + if not field_name: + return False + name_lower = field_name.lower() + return any(term in name_lower for term in ('secret', 'password', 'passphrase')) + + +def mask_passwords_in_json(obj: Any, unmask: bool = False, parent_key: str = None) -> Any: + """Recursively mask password/secret/passphrase values in JSON object for display. + + Args: + obj: JSON object (dict, list, or primitive) + unmask: If True, return object unchanged (don't mask) + parent_key: Parent key name for context + + Returns: + Object with sensitive values masked as '************' + """ + if unmask: + return obj # Don't mask if unmask mode is enabled + + if isinstance(obj, dict): + # Check if this dict is a password field (has type: "password") + if obj.get('type') == 'password': + masked = dict(obj) + if 'value' in masked and isinstance(masked['value'], list) and len(masked['value']) > 0: + masked['value'] = ['************'] + return masked + + # Check if this dict has a label that indicates sensitive data + label = obj.get('label', '') + if is_sensitive_field(label): + masked = dict(obj) + if 'value' in masked and isinstance(masked['value'], list) and len(masked['value']) > 0: + masked['value'] = ['************'] + return masked + + # Otherwise recurse into dict values + result = {} + for key, value in obj.items(): + # Check if key itself indicates sensitive data + if is_sensitive_field(key) and isinstance(value, str) and value: + result[key] = '************' + else: + result[key] = mask_passwords_in_json(value, unmask=unmask, parent_key=key) + return result + + elif isinstance(obj, list): + return [mask_passwords_in_json(item, unmask=unmask, parent_key=parent_key) for item in obj] + + else: + return obj + + +def format_json_key(key: str, theme_colors: dict) -> str: + """Format a JSON key with theme colors. + + Args: + key: The JSON key + theme_colors: Theme color dict + + Returns: + Rich markup formatted key + """ + primary = theme_colors.get('primary', '#00ff00') + return f'[{primary}]"{key}"[/{primary}]' + + +def format_json_string(value: str, theme_colors: dict) -> str: + """Format a JSON string value with theme colors. + + Args: + value: The string value + theme_colors: Theme color dict + + Returns: + Rich markup formatted string + """ + secondary = theme_colors.get('secondary', '#88ff88') + # Escape Rich markup characters + safe_value = value.replace('[', '\\[').replace(']', '\\]') + return f'[{secondary}]"{safe_value}"[/{secondary}]' + + +def format_json_number(value: Any, theme_colors: dict) -> str: + """Format a JSON number with theme colors. + + Args: + value: The numeric value + theme_colors: Theme color dict + + Returns: + Rich markup formatted number + """ + text_dim = theme_colors.get('text_dim', '#aaaaaa') + return f'[{text_dim}]{value}[/{text_dim}]' + + +def format_json_boolean(value: bool, theme_colors: dict) -> str: + """Format a JSON boolean with theme colors. + + Args: + value: The boolean value + theme_colors: Theme color dict + + Returns: + Rich markup formatted boolean + """ + text_dim = theme_colors.get('text_dim', '#aaaaaa') + return f'[{text_dim}]{str(value).lower()}[/{text_dim}]' + + +def format_json_null(theme_colors: dict) -> str: + """Format JSON null with theme colors. + + Args: + theme_colors: Theme color dict + + Returns: + Rich markup formatted null + """ + text_dim = theme_colors.get('text_dim', '#aaaaaa') + return f'[{text_dim}]null[/{text_dim}]' + + +def get_json_value_for_copy(value: Any) -> Optional[str]: + """Get the copyable string value from a JSON value. + + Args: + value: JSON value (string, number, bool, etc.) + + Returns: + String to copy, or None if not copyable + """ + if isinstance(value, str): + return value + elif isinstance(value, (int, float)): + return str(value) + elif isinstance(value, bool): + return str(value).lower() + elif value is None: + return None + else: + return None diff --git a/keepercommander/commands/supershell/renderers/record.py b/keepercommander/commands/supershell/renderers/record.py new file mode 100644 index 000000000..ed8e783cc --- /dev/null +++ b/keepercommander/commands/supershell/renderers/record.py @@ -0,0 +1,603 @@ +""" +Record rendering utilities for SuperShell + +Functions for formatting record data for display with syntax highlighting. +""" + +import json +from typing import Any, Dict, List, Optional, Callable, TYPE_CHECKING + +from rich.markup import escape as rich_escape + +from .json_syntax import ( + is_sensitive_field, + mask_passwords_in_json, + get_json_value_for_copy, +) + +if TYPE_CHECKING: + pass + + +# Field type prefixes to strip from display +FIELD_TYPE_PREFIXES = ( + 'text:', 'multiline:', 'url:', 'phone:', 'email:', + 'secret:', 'date:', 'name:', 'host:', 'address:' +) + +# Friendly names for field type prefixes when label is empty +TYPE_FRIENDLY_NAMES = { + 'text:': 'Text', + 'multiline:': 'Note', + 'url:': 'URL', + 'phone:': 'Phone', + 'email:': 'Email', + 'secret:': 'Secret', + 'date:': 'Date', + 'name:': 'Name', + 'host:': 'Host', + 'address:': 'Address', +} + +# Section headers in record detail output +RECORD_SECTION_HEADERS = { + 'Custom Fields', 'Attachments', 'User Permissions', + 'Shared Folder Permissions', 'Share Admins', 'One-Time Share URL' +} + + +def strip_field_type_prefix(key: str) -> str: + """Strip type prefix from field name (e.g., 'text:Label' -> 'Label'). + + Args: + key: Field name potentially with type prefix + + Returns: + Display name without type prefix + """ + for prefix in FIELD_TYPE_PREFIXES: + if key.lower().startswith(prefix): + display_key = key[len(prefix):] + if not display_key: + # Use friendly name based on type + display_key = TYPE_FRIENDLY_NAMES.get(prefix, prefix.rstrip(':').title()) + return display_key + return key + + +def is_section_header(key: str, value: str) -> bool: + """Check if key is a section header (only when value is empty). + + Args: + key: Field name + value: Field value + + Returns: + True if this is a section header + """ + if value: # If there's a value, not a section header + return False + if key in RECORD_SECTION_HEADERS: + return True + # Handle cases like "Share Admins (64, showing first 10)" + for header in RECORD_SECTION_HEADERS: + if key.startswith(header): + return True + return False + + +def format_uid_line(key: str, value: str, theme_colors: dict) -> str: + """Format a UID field line. + + Args: + key: Field name (e.g., 'UID', 'Record UID') + value: UID value + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]" + + +def format_title_line(key: str, value: str, theme_colors: dict) -> str: + """Format a title field line (bold). + + Args: + key: Field name (e.g., 'Title', 'Name') + value: Title value + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [bold {t['primary']}]{rich_escape(str(value))}[/bold {t['primary']}]" + + +def format_type_line(key: str, value: str, theme_colors: dict) -> str: + """Format a type field line. + + Args: + key: Field name (e.g., 'Type') + value: Type value + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary_dim']}]{rich_escape(str(value))}[/{t['primary_dim']}]" + + +def format_password_line( + key: str, + display_value: str, + theme_colors: dict +) -> str: + """Format a password field line. + + Args: + key: Field name (e.g., 'Password') + display_value: Value to display (masked or unmasked) + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(display_value))}[/{t['primary']}]" + + +def format_field_line( + key: str, + value: str, + theme_colors: dict, + in_section: bool = False +) -> str: + """Format a general field line. + + Args: + key: Field name + value: Field value + theme_colors: Theme color dict + in_section: Whether this field is inside a section (adds indent) + + Returns: + Rich markup formatted line + """ + t = theme_colors + indent = " " if in_section else "" + return f"{indent}[{t['text_dim']}]{rich_escape(str(key))}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]" + + +def format_section_header(name: str, theme_colors: dict) -> str: + """Format a section header line. + + Args: + name: Section name + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return f"[bold {t['secondary']}]{name}:[/bold {t['secondary']}]" + + +def format_totp_display( + code: str, + seconds_remaining: int, + theme_colors: dict +) -> str: + """Format TOTP code display. + + Args: + code: TOTP code + seconds_remaining: Seconds until expiry + theme_colors: Theme color dict + + Returns: + Rich markup formatted line + """ + t = theme_colors + return ( + f" [{t['text_dim']}]Code:[/{t['text_dim']}] " + f"[bold {t['primary']}]{code}[/bold {t['primary']}] " + f"[{t['text_dim']}]valid for[/{t['text_dim']}] " + f"[bold {t['secondary']}]{seconds_remaining} sec[/bold {t['secondary']}]" + ) + + +def format_attachment_line( + title: str, + uid: str, + theme_colors: dict, + is_linked: bool = False +) -> str: + """Format an attachment or linked record line. + + Args: + title: Attachment/record title + uid: UID for copying + theme_colors: Theme color dict + is_linked: True for linked records (uses arrow), False for files (uses +) + + Returns: + Rich markup formatted line + """ + t = theme_colors + symbol = 'β†’' if is_linked else '+' + return f" [{t['text_dim']}]{symbol}[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(title))}[/{t['primary']}]" + + +def format_rotation_status(status: str, theme_colors: dict) -> str: + """Format rotation status with appropriate color. + + Args: + status: Status string (Enabled, Disabled, etc.) + theme_colors: Theme color dict + + Returns: + Rich markup formatted status + """ + t = theme_colors + if status == 'Enabled': + color = '#00ff00' + elif status == 'Disabled': + color = '#ff6600' + else: + color = t['text_dim'] + return f"[{color}]{status}[/{color}]" + + +def format_rotation_last_status(status: str) -> str: + """Format rotation last status with appropriate color. + + Args: + status: Status string (Success, Failure, etc.) + + Returns: + Rich markup formatted status + """ + if status == 'Success': + color = '#00ff00' + elif status == 'Failure': + color = '#ff0000' + else: + color = '#ffff00' + return f"[{color}]{status}[/{color}]" + + +class JsonRenderer: + """Renders JSON objects with syntax highlighting and clickable values. + + This class provides methods to render JSON as a series of lines + suitable for display in a TUI with copy-on-click functionality. + """ + + def __init__(self, theme_colors: dict, unmask_secrets: bool = False): + """Initialize the JSON renderer. + + Args: + theme_colors: Theme color dictionary + unmask_secrets: If True, don't mask sensitive values + """ + self.theme_colors = theme_colors + self.unmask_secrets = unmask_secrets + # Colors for JSON syntax + self.key_color = "#88ccff" # Light blue for keys + self.string_color = theme_colors.get('primary', '#00ff00') + self.number_color = "#ffcc66" # Orange for numbers + self.bool_color = "#ff99cc" # Pink for booleans + self.null_color = "#999999" # Gray for null + self.bracket_color = theme_colors.get('text_dim', '#888888') + + def render_lines( + self, + json_obj: Any, + on_line: Callable[[str, Optional[str], bool], None], + record_uid: Optional[str] = None + ): + """Render a JSON object as a series of lines. + + Args: + json_obj: JSON object to render + on_line: Callback for each line: (content, copy_value, is_password) + record_uid: Optional record UID for password copying + """ + # Create masked version for display + display_obj = mask_passwords_in_json(json_obj, unmask=self.unmask_secrets) + unmasked_obj = json_obj + + self._render_value(display_obj, unmasked_obj, on_line, record_uid, indent=0) + + def _render_value( + self, + display_obj: Any, + unmasked_obj: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int + ): + """Recursively render a JSON value.""" + if isinstance(display_obj, dict): + self._render_dict(display_obj, unmasked_obj, on_line, record_uid, indent) + elif isinstance(display_obj, list): + self._render_list(display_obj, unmasked_obj, on_line, record_uid, indent) + else: + # Primitive value at top level + self._render_primitive(display_obj, unmasked_obj, on_line, record_uid, indent, "") + + def _render_dict( + self, + display_dict: dict, + unmasked_dict: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int + ): + """Render a dictionary.""" + indent_str = " " * indent + t = self.theme_colors + + # Opening brace - copyable with entire object + on_line( + f"{indent_str}[{self.bracket_color}]{{[/{self.bracket_color}]", + json.dumps(unmasked_dict, indent=2) if isinstance(unmasked_dict, dict) else None, + False + ) + + items = list(display_dict.items()) + for i, (key, value) in enumerate(items): + comma = "," if i < len(items) - 1 else "" + unmasked_value = ( + unmasked_dict.get(key, value) + if isinstance(unmasked_dict, dict) + else value + ) + + if isinstance(value, (dict, list)): + self._render_complex_field( + key, value, unmasked_value, on_line, record_uid, indent + 1, comma + ) + else: + self._render_primitive_field( + key, value, unmasked_value, on_line, record_uid, indent + 1, comma + ) + + # Closing brace + on_line(f"{indent_str}[{self.bracket_color}]}}[/{self.bracket_color}]", None, False) + + def _render_list( + self, + display_list: list, + unmasked_list: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int + ): + """Render a list.""" + indent_str = " " * indent + + # Opening bracket - copyable with entire array + on_line( + f"{indent_str}[{self.bracket_color}]\\[[/{self.bracket_color}]", + json.dumps(unmasked_list, indent=2) if isinstance(unmasked_list, list) else None, + False + ) + + for i, value in enumerate(display_list): + comma = "," if i < len(display_list) - 1 else "" + unmasked_value = ( + unmasked_list[i] + if isinstance(unmasked_list, list) and i < len(unmasked_list) + else value + ) + + self._render_list_item(value, unmasked_value, on_line, record_uid, indent + 1, comma) + + # Closing bracket + on_line(f"{indent_str}[{self.bracket_color}]][/{self.bracket_color}]", None, False) + + def _render_primitive_field( + self, + key: str, + value: Any, + unmasked_value: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int, + comma: str + ): + """Render a primitive key-value pair.""" + indent_str = " " * indent + + if isinstance(value, str): + display_val = value.replace("[", "\\[") + is_password = (value == "************") + copy_val = unmasked_value if isinstance(unmasked_value, str) else str(unmasked_value) + on_line( + f"{indent_str}[{self.key_color}]\"{key}\"[/{self.key_color}]: " + f"[{self.string_color}]\"{display_val}\"[/{self.string_color}]{comma}", + copy_val, + is_password + ) + elif isinstance(value, bool): + bool_str = "true" if value else "false" + on_line( + f"{indent_str}[{self.key_color}]\"{key}\"[/{self.key_color}]: " + f"[{self.bool_color}]{bool_str}[/{self.bool_color}]{comma}", + str(value), + False + ) + elif isinstance(value, (int, float)): + on_line( + f"{indent_str}[{self.key_color}]\"{key}\"[/{self.key_color}]: " + f"[{self.number_color}]{value}[/{self.number_color}]{comma}", + str(value), + False + ) + elif value is None: + on_line( + f"{indent_str}[{self.key_color}]\"{key}\"[/{self.key_color}]: " + f"[{self.null_color}]null[/{self.null_color}]{comma}", + None, + False + ) + + def _render_complex_field( + self, + key: str, + value: Any, + unmasked_value: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int, + comma: str + ): + """Render a complex (dict/list) key-value pair.""" + indent_str = " " * indent + + if isinstance(value, list): + unmasked_list = unmasked_value if isinstance(unmasked_value, list) else value + on_line( + f"{indent_str}[{self.key_color}]\"{key}\"[/{self.key_color}]: " + f"[{self.bracket_color}]\\[[/{self.bracket_color}]", + json.dumps(unmasked_list, indent=2), + False + ) + for i, item in enumerate(value): + item_comma = "," if i < len(value) - 1 else "" + unmasked_item = ( + unmasked_list[i] + if isinstance(unmasked_list, list) and i < len(unmasked_list) + else item + ) + self._render_list_item(item, unmasked_item, on_line, record_uid, indent + 1, item_comma) + on_line(f"{indent_str}[{self.bracket_color}]][/{self.bracket_color}]{comma}", None, False) + + elif isinstance(value, dict): + unmasked_dict = unmasked_value if isinstance(unmasked_value, dict) else value + on_line( + f"{indent_str}[{self.key_color}]\"{key}\"[/{self.key_color}]: " + f"[{self.bracket_color}]{{[/{self.bracket_color}]", + json.dumps(unmasked_dict, indent=2), + False + ) + items = list(value.items()) + for i, (k, v) in enumerate(items): + item_comma = "," if i < len(items) - 1 else "" + unmasked_v = unmasked_dict.get(k, v) if isinstance(unmasked_dict, dict) else v + if isinstance(v, (dict, list)): + self._render_complex_field(k, v, unmasked_v, on_line, record_uid, indent + 1, item_comma) + else: + self._render_primitive_field(k, v, unmasked_v, on_line, record_uid, indent + 1, item_comma) + on_line(f"{indent_str}[{self.bracket_color}]}}[/{self.bracket_color}]{comma}", None, False) + + def _render_list_item( + self, + value: Any, + unmasked_value: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int, + comma: str + ): + """Render a single list item.""" + indent_str = " " * indent + + if isinstance(value, str): + display_val = value.replace("[", "\\[") + is_password = (value == "************") + copy_val = unmasked_value if isinstance(unmasked_value, str) else str(unmasked_value) + on_line( + f"{indent_str}[{self.string_color}]\"{display_val}\"[/{self.string_color}]{comma}", + copy_val, + is_password + ) + elif isinstance(value, bool): + bool_str = "true" if value else "false" + on_line( + f"{indent_str}[{self.bool_color}]{bool_str}[/{self.bool_color}]{comma}", + str(value), + False + ) + elif isinstance(value, (int, float)): + on_line( + f"{indent_str}[{self.number_color}]{value}[/{self.number_color}]{comma}", + str(value), + False + ) + elif value is None: + on_line(f"{indent_str}[{self.null_color}]null[/{self.null_color}]{comma}", None, False) + elif isinstance(value, dict): + unmasked_dict = unmasked_value if isinstance(unmasked_value, dict) else value + on_line( + f"{indent_str}[{self.bracket_color}]{{[/{self.bracket_color}]", + json.dumps(unmasked_dict, indent=2), + False + ) + items = list(value.items()) + for i, (k, v) in enumerate(items): + item_comma = "," if i < len(items) - 1 else "" + unmasked_v = unmasked_dict.get(k, v) if isinstance(unmasked_dict, dict) else v + if isinstance(v, (dict, list)): + self._render_complex_field(k, v, unmasked_v, on_line, record_uid, indent + 1, item_comma) + else: + self._render_primitive_field(k, v, unmasked_v, on_line, record_uid, indent + 1, item_comma) + on_line(f"{indent_str}[{self.bracket_color}]}}[/{self.bracket_color}]{comma}", None, False) + elif isinstance(value, list): + unmasked_list = unmasked_value if isinstance(unmasked_value, list) else value + on_line( + f"{indent_str}[{self.bracket_color}]\\[[/{self.bracket_color}]", + json.dumps(unmasked_list, indent=2), + False + ) + for i, item in enumerate(value): + item_comma = "," if i < len(value) - 1 else "" + unmasked_item = ( + unmasked_list[i] + if isinstance(unmasked_list, list) and i < len(unmasked_list) + else item + ) + self._render_list_item(item, unmasked_item, on_line, record_uid, indent + 1, item_comma) + on_line(f"{indent_str}[{self.bracket_color}]][/{self.bracket_color}]{comma}", None, False) + + def _render_primitive( + self, + value: Any, + unmasked_value: Any, + on_line: Callable, + record_uid: Optional[str], + indent: int, + comma: str + ): + """Render a primitive value (string, number, bool, null).""" + indent_str = " " * indent + + if isinstance(value, str): + display_val = value.replace("[", "\\[") + is_password = (value == "************") + copy_val = unmasked_value if isinstance(unmasked_value, str) else str(unmasked_value) + on_line( + f"{indent_str}[{self.string_color}]\"{display_val}\"[/{self.string_color}]{comma}", + copy_val, + is_password + ) + elif isinstance(value, bool): + bool_str = "true" if value else "false" + on_line( + f"{indent_str}[{self.bool_color}]{bool_str}[/{self.bool_color}]{comma}", + str(value), + False + ) + elif isinstance(value, (int, float)): + on_line( + f"{indent_str}[{self.number_color}]{value}[/{self.number_color}]{comma}", + str(value), + False + ) + elif value is None: + on_line(f"{indent_str}[{self.null_color}]null[/{self.null_color}]{comma}", None, False) diff --git a/keepercommander/commands/supershell/screens/__init__.py b/keepercommander/commands/supershell/screens/__init__.py new file mode 100644 index 000000000..98e8c3bcd --- /dev/null +++ b/keepercommander/commands/supershell/screens/__init__.py @@ -0,0 +1,8 @@ +""" +SuperShell modal screens +""" + +from .preferences import PreferencesScreen +from .help import HelpScreen + +__all__ = ['PreferencesScreen', 'HelpScreen'] diff --git a/keepercommander/commands/supershell/screens/help.py b/keepercommander/commands/supershell/screens/help.py new file mode 100644 index 000000000..cf161c3e2 --- /dev/null +++ b/keepercommander/commands/supershell/screens/help.py @@ -0,0 +1,114 @@ +""" +SuperShell Help Screen + +Modal screen displaying keyboard shortcuts and help information. +""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Static + + +class HelpScreen(ModalScreen): + """Modal screen for help/keyboard shortcuts""" + + DEFAULT_CSS = """ + HelpScreen { + align: center middle; + } + + #help_container { + width: 90; + height: auto; + max-height: 90%; + background: #111111; + border: solid #444444; + padding: 1 2; + } + + #help_title { + text-align: center; + text-style: bold; + padding-bottom: 1; + } + + #help_columns { + height: auto; + } + + .help_column { + width: 1fr; + height: auto; + padding: 0 1; + } + + #help_footer { + text-align: center; + padding-top: 1; + color: #666666; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss", "Close", show=False), + Binding("q", "dismiss", "Close", show=False), + ] + + def compose(self) -> ComposeResult: + with Vertical(id="help_container"): + yield Static("[bold cyan]Keyboard Shortcuts[/bold cyan]", id="help_title") + with Horizontal(id="help_columns"): + yield Static("""[green]Navigation:[/green] + j/k Move up/down + h/l Collapse/expand + g / G Top / bottom + Ctrl+d/u Half page + Ctrl+e/y Scroll line + Esc Clear/collapse + +[green]Focus Cycling:[/green] + Tab Tree->Detail->Search + Shift+Tab Cycle backwards + / Focus search + Ctrl+U Clear search + Esc Focus tree + +[green]Shell Pane:[/green] + :cmd Open shell + run cmd + Ctrl+\\ Open/close shell + quit/q Close shell pane + Ctrl+D Close shell pane + +[green]General:[/green] + ? Help + ! Exit to Keeper shell + Ctrl+q Quit""", classes="help_column") + yield Static("""[green]Copy to Clipboard:[/green] + p Password + u Username + c Copy all + w URL + i Record UID + +[green]Actions:[/green] + t Toggle JSON view + m Mask/Unmask + d Sync vault + W User info + D Device info + P Preferences""", classes="help_column") + yield Static("[dim]Press Esc or q to close[/dim]", id="help_footer") + + def action_dismiss(self): + """Close the help screen""" + self.dismiss() + + def key_escape(self): + """Handle escape key directly""" + self.dismiss() + + def key_q(self): + """Handle q key directly""" + self.dismiss() diff --git a/keepercommander/commands/supershell/screens/preferences.py b/keepercommander/commands/supershell/screens/preferences.py new file mode 100644 index 000000000..e5b8a03c0 --- /dev/null +++ b/keepercommander/commands/supershell/screens/preferences.py @@ -0,0 +1,112 @@ +""" +SuperShell Preferences Screen + +Modal screen for user preferences including theme selection. +""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +from ..utils import load_preferences, save_preferences + + +class PreferencesScreen(ModalScreen): + """Modal screen for user preferences""" + + DEFAULT_CSS = """ + PreferencesScreen { + align: center middle; + } + + #prefs_container { + width: 40; + height: auto; + max-height: 90%; + background: #111111; + border: solid #444444; + padding: 1 2; + } + + #prefs_title { + text-align: center; + text-style: bold; + padding-bottom: 1; + } + + #prefs_content { + height: auto; + padding: 0 1; + } + + #prefs_footer { + text-align: center; + padding-top: 1; + color: #666666; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss", "Close", show=False), + Binding("q", "dismiss", "Close", show=False), + Binding("1", "select_green", "Green", show=False), + Binding("2", "select_blue", "Blue", show=False), + Binding("3", "select_magenta", "Magenta", show=False), + Binding("4", "select_yellow", "Yellow", show=False), + Binding("5", "select_white", "White", show=False), + ] + + def __init__(self, app_instance): + super().__init__() + self.app_instance = app_instance + + def compose(self) -> ComposeResult: + current = self.app_instance.color_theme + with Vertical(id="prefs_container"): + yield Static("[bold cyan]Preferences[/bold cyan]", id="prefs_title") + yield Static(f"""[green]Color Theme:[/green] + [#00ff00]1[/#00ff00] {'●' if current == 'green' else 'β—‹'} Green + [#0099ff]2[/#0099ff] {'●' if current == 'blue' else 'β—‹'} Blue + [#ff66ff]3[/#ff66ff] {'●' if current == 'magenta' else 'β—‹'} Magenta + [#ffff00]4[/#ffff00] {'●' if current == 'yellow' else 'β—‹'} Yellow + [#ffffff]5[/#ffffff] {'●' if current == 'white' else 'β—‹'} White""", id="prefs_content") + yield Static("[dim]Press 1-5 to select, Esc or q to close[/dim]", id="prefs_footer") + + def action_dismiss(self): + """Close the preferences screen""" + self.dismiss() + + def key_escape(self): + """Handle escape key directly""" + self.dismiss() + + def key_q(self): + """Handle q key directly""" + self.dismiss() + + def action_select_green(self): + self._apply_theme('green') + + def action_select_blue(self): + self._apply_theme('blue') + + def action_select_magenta(self): + self._apply_theme('magenta') + + def action_select_yellow(self): + self._apply_theme('yellow') + + def action_select_white(self): + self._apply_theme('white') + + def _apply_theme(self, theme_name: str): + """Apply the selected theme and save preferences""" + self.app_instance.set_color_theme(theme_name) + # Save to preferences file + prefs = load_preferences() + prefs['color_theme'] = theme_name + save_preferences(prefs) + self.app_instance.notify(f"Theme changed to {theme_name}") + self.dismiss() diff --git a/keepercommander/commands/supershell/state/__init__.py b/keepercommander/commands/supershell/state/__init__.py new file mode 100644 index 000000000..fa19031b3 --- /dev/null +++ b/keepercommander/commands/supershell/state/__init__.py @@ -0,0 +1,16 @@ +""" +SuperShell state management + +Dataclasses for managing application state. +""" + +from .vault_data import VaultData +from .ui_state import UIState, ThemeState +from .selection import SelectionState + +__all__ = [ + 'VaultData', + 'UIState', + 'ThemeState', + 'SelectionState', +] diff --git a/keepercommander/commands/supershell/state/selection.py b/keepercommander/commands/supershell/state/selection.py new file mode 100644 index 000000000..5d8abc785 --- /dev/null +++ b/keepercommander/commands/supershell/state/selection.py @@ -0,0 +1,96 @@ +""" +SelectionState - Current selection state + +Contains the currently selected record/folder and pre-search state. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class SelectionState: + """Selection state for SuperShell. + + Tracks what record or folder is currently selected, + and preserves pre-search selection for restoration. + """ + + # Current selection + current_folder: Optional[str] = None + """Currently displayed folder UID""" + + selected_record: Optional[str] = None + """Currently selected record UID""" + + selected_folder: Optional[str] = None + """Currently selected folder UID (when a folder is selected, not a record)""" + + # Pre-search state (for restoration when search is cancelled) + pre_search_selected_record: Optional[str] = None + """Record that was selected before search started""" + + pre_search_selected_folder: Optional[str] = None + """Folder that was selected before search started""" + + def has_record_selected(self) -> bool: + """Check if a record is currently selected.""" + return self.selected_record is not None + + def has_folder_selected(self) -> bool: + """Check if a folder is currently selected.""" + return self.selected_folder is not None + + def has_selection(self) -> bool: + """Check if anything is selected.""" + return self.has_record_selected() or self.has_folder_selected() + + def save_for_search(self) -> 'SelectionState': + """Return a new state with current selection saved for search restoration.""" + return SelectionState( + current_folder=self.current_folder, + selected_record=self.selected_record, + selected_folder=self.selected_folder, + pre_search_selected_record=self.selected_record, + pre_search_selected_folder=self.selected_folder, + ) + + def restore_from_search(self) -> 'SelectionState': + """Return a new state with pre-search selection restored.""" + return SelectionState( + current_folder=self.current_folder, + selected_record=self.pre_search_selected_record, + selected_folder=self.pre_search_selected_folder, + pre_search_selected_record=None, + pre_search_selected_folder=None, + ) + + def select_record(self, record_uid: str) -> 'SelectionState': + """Return a new state with the given record selected.""" + return SelectionState( + current_folder=self.current_folder, + selected_record=record_uid, + selected_folder=None, + pre_search_selected_record=self.pre_search_selected_record, + pre_search_selected_folder=self.pre_search_selected_folder, + ) + + def select_folder(self, folder_uid: str) -> 'SelectionState': + """Return a new state with the given folder selected.""" + return SelectionState( + current_folder=self.current_folder, + selected_record=None, + selected_folder=folder_uid, + pre_search_selected_record=self.pre_search_selected_record, + pre_search_selected_folder=self.pre_search_selected_folder, + ) + + def clear_selection(self) -> 'SelectionState': + """Return a new state with selection cleared.""" + return SelectionState( + current_folder=self.current_folder, + selected_record=None, + selected_folder=None, + pre_search_selected_record=self.pre_search_selected_record, + pre_search_selected_folder=self.pre_search_selected_folder, + ) diff --git a/keepercommander/commands/supershell/state/ui_state.py b/keepercommander/commands/supershell/state/ui_state.py new file mode 100644 index 000000000..0e1057285 --- /dev/null +++ b/keepercommander/commands/supershell/state/ui_state.py @@ -0,0 +1,97 @@ +""" +UIState - UI presentation state + +Contains all state related to the UI presentation, not the underlying data. +""" + +from dataclasses import dataclass, field +from typing import Optional, Set, List, Tuple + + +@dataclass +class UIState: + """UI presentation state for SuperShell. + + This class holds all UI-related state that affects how + data is displayed, but not the data itself. + """ + + # View settings + view_mode: str = 'detail' + """Current view mode: 'detail' or 'json'""" + + unmask_secrets: bool = False + """When True, show secret/password/passphrase field values""" + + # Search state + search_query: str = "" + """Current search query""" + + search_input_text: str = "" + """Text being typed in search box""" + + search_input_active: bool = False + """True when typing in search, False when navigating results""" + + filtered_record_uids: Optional[Set[str]] = None + """None = show all, Set = filtered UIDs from search""" + + # Command mode (vim :command) + command_mode: bool = False + """True when in : command mode""" + + command_buffer: str = "" + """Accumulated command input""" + + # Shell pane state + shell_pane_visible: bool = False + """True when shell pane is shown""" + + shell_input_text: str = "" + """Current text in shell input""" + + shell_history: List[Tuple[str, str]] = field(default_factory=list) + """List of (command, output) tuples""" + + shell_input_active: bool = False + """True when shell input has focus""" + + shell_command_history: List[str] = field(default_factory=list) + """Command history for up/down arrow navigation""" + + shell_history_index: int = -1 + """Current position in command history (-1 = new command)""" + + def is_searching(self) -> bool: + """Check if a search is active.""" + return self.filtered_record_uids is not None + + def clear_search(self) -> 'UIState': + """Return a new UIState with search cleared.""" + return UIState( + view_mode=self.view_mode, + unmask_secrets=self.unmask_secrets, + search_query="", + search_input_text="", + search_input_active=False, + filtered_record_uids=None, + command_mode=self.command_mode, + command_buffer=self.command_buffer, + shell_pane_visible=self.shell_pane_visible, + shell_input_text=self.shell_input_text, + shell_history=self.shell_history, + shell_input_active=self.shell_input_active, + shell_command_history=self.shell_command_history, + shell_history_index=self.shell_history_index, + ) + + +@dataclass +class ThemeState: + """Theme/color state for SuperShell.""" + + color_theme: str = 'green' + """Current theme name""" + + theme_colors: dict = field(default_factory=dict) + """Color values for current theme""" diff --git a/keepercommander/commands/supershell/state/vault_data.py b/keepercommander/commands/supershell/state/vault_data.py new file mode 100644 index 000000000..1c62878d9 --- /dev/null +++ b/keepercommander/commands/supershell/state/vault_data.py @@ -0,0 +1,75 @@ +""" +VaultData - Immutable snapshot of vault data + +Contains records, folders, and their relationships. +""" + +from dataclasses import dataclass, field +from typing import Dict, Set, List, Any, Optional + + +@dataclass +class VaultData: + """Immutable snapshot of vault data loaded from Keeper. + + This class holds all the vault data needed by SuperShell, + organized for efficient lookup and navigation. + """ + + # Record data + records: Dict[str, dict] = field(default_factory=dict) + """Maps record_uid -> record data dict""" + + # Folder relationships + record_to_folder: Dict[str, str] = field(default_factory=dict) + """Maps record_uid -> folder_uid for direct folder membership""" + + records_in_subfolders: Set[str] = field(default_factory=set) + """Set of record UIDs that are in actual subfolders (not root)""" + + # File attachments + file_attachment_to_parent: Dict[str, str] = field(default_factory=dict) + """Maps attachment_uid -> parent_record_uid""" + + record_file_attachments: Dict[str, List[str]] = field(default_factory=dict) + """Maps record_uid -> list of attachment_uids""" + + # Linked records (addressRef, cardRef, etc.) + linked_record_to_parent: Dict[str, str] = field(default_factory=dict) + """Maps linked_record_uid -> parent_record_uid""" + + record_linked_records: Dict[str, List[str]] = field(default_factory=dict) + """Maps record_uid -> list of linked_record_uids""" + + # Special record types + app_record_uids: Set[str] = field(default_factory=set) + """Set of Secrets Manager app record UIDs""" + + def get_record(self, record_uid: str) -> Optional[dict]: + """Get a record by UID, returns None if not found.""" + return self.records.get(record_uid) + + def get_folder_for_record(self, record_uid: str) -> Optional[str]: + """Get the folder UID containing a record.""" + return self.record_to_folder.get(record_uid) + + def is_in_subfolder(self, record_uid: str) -> bool: + """Check if a record is in a subfolder (not root).""" + return record_uid in self.records_in_subfolders + + def get_attachments(self, record_uid: str) -> List[str]: + """Get attachment UIDs for a record.""" + return self.record_file_attachments.get(record_uid, []) + + def get_linked_records(self, record_uid: str) -> List[str]: + """Get linked record UIDs for a record.""" + return self.record_linked_records.get(record_uid, []) + + def is_app_record(self, record_uid: str) -> bool: + """Check if a record is a Secrets Manager app.""" + return record_uid in self.app_record_uids + + @property + def record_count(self) -> int: + """Total number of records in vault.""" + return len(self.records) diff --git a/keepercommander/commands/supershell/themes/__init__.py b/keepercommander/commands/supershell/themes/__init__.py new file mode 100644 index 000000000..28d2f2ac2 --- /dev/null +++ b/keepercommander/commands/supershell/themes/__init__.py @@ -0,0 +1,8 @@ +""" +SuperShell themes - Color themes and CSS styling +""" + +from .colors import COLOR_THEMES +from .css import BASE_CSS, get_theme_css + +__all__ = ['COLOR_THEMES', 'BASE_CSS', 'get_theme_css'] diff --git a/keepercommander/commands/supershell/themes/colors.py b/keepercommander/commands/supershell/themes/colors.py new file mode 100644 index 000000000..34f7de15b --- /dev/null +++ b/keepercommander/commands/supershell/themes/colors.py @@ -0,0 +1,114 @@ +""" +SuperShell color themes + +Each theme uses variations of a primary color with consistent structure. +""" + +COLOR_THEMES = { + 'green': { + 'primary': '#00ff00', # Bright green + 'primary_dim': '#00aa00', # Dim green + 'primary_bright': '#44ff44', # Light green + 'secondary': '#88ff88', # Light green accent + 'selection_bg': '#004400', # Selection background + 'hover_bg': '#002200', # Hover background (dimmer than selection) + 'text': '#ffffff', # White text + 'text_dim': '#aaaaaa', # Dim text + 'folder': '#44ff44', # Folder color (light green) + 'folder_shared': '#00dd00', # Shared folder (slightly different green) + 'record': '#00aa00', # Record color (dimmer than folders) + 'record_num': '#888888', # Record number + 'attachment': '#00cc00', # Attachment color + 'virtual_folder': '#00ff88', # Virtual folder + 'status': '#00ff00', # Status bar + 'border': '#00aa00', # Borders + 'root': '#00ff00', # Root node + 'header_user': '#00bbff', # Header username (blue contrast) + }, + 'blue': { + 'primary': '#0099ff', + 'primary_dim': '#0066cc', + 'primary_bright': '#66bbff', + 'secondary': '#00ccff', + 'selection_bg': '#002244', + 'hover_bg': '#001122', + 'text': '#ffffff', + 'text_dim': '#aaaaaa', + 'folder': '#66bbff', + 'folder_shared': '#0099ff', + 'record': '#0077cc', # Record color (dimmer than folders) + 'record_num': '#888888', + 'attachment': '#0077cc', + 'virtual_folder': '#00aaff', + 'status': '#0099ff', + 'border': '#0066cc', + 'root': '#0099ff', + 'header_user': '#ff9900', # Header username (orange contrast) + }, + 'magenta': { + 'primary': '#ff66ff', + 'primary_dim': '#cc44cc', + 'primary_bright': '#ff99ff', + 'secondary': '#ffaaff', + 'selection_bg': '#330033', + 'hover_bg': '#220022', + 'text': '#ffffff', + 'text_dim': '#aaaaaa', + 'folder': '#ff99ff', + 'folder_shared': '#ff66ff', + 'record': '#cc44cc', # Record color (dimmer than folders) + 'record_num': '#888888', + 'attachment': '#cc44cc', + 'virtual_folder': '#ffaaff', + 'status': '#ff66ff', + 'border': '#cc44cc', + 'root': '#ff66ff', + 'header_user': '#66ff66', # Header username (green contrast) + }, + 'yellow': { + 'primary': '#ffff00', + 'primary_dim': '#cccc00', + 'primary_bright': '#ffff66', + 'secondary': '#ffcc00', + 'selection_bg': '#333300', + 'hover_bg': '#222200', + 'text': '#ffffff', + 'text_dim': '#aaaaaa', + 'folder': '#ffff66', + 'folder_shared': '#ffcc00', + 'record': '#cccc00', # Record color (dimmer than folders) + 'record_num': '#888888', + 'attachment': '#cccc00', + 'virtual_folder': '#ffff88', + 'status': '#ffff00', + 'border': '#cccc00', + 'root': '#ffff00', + 'header_user': '#66ccff', # Header username (blue contrast) + }, + 'white': { + 'primary': '#ffffff', + 'primary_dim': '#cccccc', + 'primary_bright': '#ffffff', + 'secondary': '#dddddd', + 'selection_bg': '#444444', + 'hover_bg': '#333333', + 'text': '#ffffff', + 'text_dim': '#999999', + 'folder': '#eeeeee', + 'folder_shared': '#dddddd', + 'record': '#bbbbbb', # Record color (dimmer than folders) + 'record_num': '#888888', + 'attachment': '#cccccc', + 'virtual_folder': '#ffffff', + 'status': '#ffffff', + 'border': '#888888', + 'root': '#ffffff', + 'header_user': '#66ccff', # Header username (blue contrast) + }, +} + +# Default theme +DEFAULT_THEME = 'green' + +# Available theme names +THEME_NAMES = list(COLOR_THEMES.keys()) diff --git a/keepercommander/commands/supershell/themes/css.py b/keepercommander/commands/supershell/themes/css.py new file mode 100644 index 000000000..3efb72508 --- /dev/null +++ b/keepercommander/commands/supershell/themes/css.py @@ -0,0 +1,292 @@ +""" +SuperShell CSS styling + +Base CSS and dynamic theme-specific CSS generation. +""" + +from .colors import COLOR_THEMES + +# Base CSS - static styles that don't change with themes +BASE_CSS = """ +Screen { + background: #000000; +} + +Input { + background: #111111; + color: #ffffff; +} + +Input > .input--content { + color: #ffffff; +} + +Input > .input--placeholder { + color: #666666; +} + +Input > .input--cursor { + color: #ffffff; + text-style: reverse; +} + +Input:focus { + border: solid #888888; +} + +Input:focus > .input--content { + color: #ffffff; +} + +#search_bar { + dock: top; + height: 3; + width: 100%; + background: #222222; + border: solid #666666; +} + +#search_display { + width: 35%; + background: #222222; + color: #ffffff; + padding: 0 2; + height: 3; +} + +#search_results_label { + width: 15%; + color: #aaaaaa; + text-align: right; + padding: 0 2; + height: 3; + background: #222222; +} + +#user_info { + width: auto; + height: 3; + background: #222222; + color: #888888; + padding: 0 1; +} + +#device_status_info { + width: auto; + height: 3; + background: #222222; + color: #888888; + padding: 0 2; + text-align: right; +} + +.clickable-info:hover { + background: #333333; +} + +#main_container { + height: 100%; + background: #000000; +} + +#folder_panel { + width: 50%; + border-right: thick #666666; + padding: 1; + background: #000000; +} + +#folder_tree { + height: 100%; + background: #000000; +} + +#record_panel { + width: 50%; + padding: 1; + background: #000000; +} + +#record_detail { + height: 100%; + overflow-y: auto; + padding: 1; + background: #000000; +} + +#detail_content { + background: #000000; + color: #ffffff; +} + +Tree { + background: #000000; + color: #ffffff; +} + +Tree > .tree--guides { + color: #444444; +} + +Tree > .tree--toggle { + /* Hide expand/collapse icons - nodes still expand/collapse on click */ + width: 0; +} + +Tree > .tree--cursor { + /* Selected row - neutral background that works with all color themes */ + background: #333333; + text-style: bold; +} + +Tree > .tree--highlight { + /* Hover row - subtle background, different from selection */ + background: #1a1a1a; +} + +Tree > .tree--highlight-line { + background: #1a1a1a; +} + +/* Hide tree selection when search input is active */ +Tree.search-input-active > .tree--cursor { + background: transparent; + text-style: none; +} + +Tree.search-input-active > .tree--highlight { + background: transparent; +} + +DataTable { + background: #000000; + color: #ffffff; +} + +DataTable > .datatable--cursor { + background: #444444; + color: #ffffff; + text-style: bold; +} + +DataTable > .datatable--header { + background: #222222; + color: #ffffff; + text-style: bold; +} + +Static { + background: #000000; + color: #ffffff; +} + +VerticalScroll { + background: #000000; +} + +#record_detail:focus { + background: #0a0a0a; + border: solid #333333; +} + +#record_detail:focus-within { + background: #0a0a0a; +} + +#status_bar { + dock: bottom; + height: 1; + background: #000000; + color: #aaaaaa; + padding: 0 2; +} + +#shortcuts_bar { + dock: bottom; + height: 2; + background: #111111; + color: #888888; + padding: 0 1; + border-top: solid #333333; +} + +/* Content area wrapper for shell pane visibility control */ +#content_area { + height: 100%; + width: 100%; +} + +/* When shell is visible, compress main container to top half */ +#content_area.shell-visible #main_container { + height: 50%; +} + +/* Shell pane - hidden by default */ +#shell_pane { + display: none; + height: 50%; + width: 100%; + border-top: thick #666666; + background: #000000; +} + +/* Show shell pane when class is added */ +#content_area.shell-visible #shell_pane { + display: block; +} + +#shell_header { + height: 1; + background: #222222; + color: #00ff00; + padding: 0 1; + border-bottom: solid #333333; +} + +#shell_output { + height: 1fr; + overflow-y: auto; + padding: 0 1; + background: #000000; +} + +#shell_output_content { + background: #000000; + color: #ffffff; +} + +#shell_input_line { + height: 1; + background: #111111; + color: #00ff00; + padding: 0 1; +} + +#shell_pane:focus-within #shell_input_line { + background: #1a1a2e; +} +""" + + +def get_theme_css(theme_name: str) -> str: + """Generate dynamic CSS for a specific theme. + + This can be used to override specific colors based on theme. + Currently themes are applied via Rich markup rather than CSS, + but this provides a hook for future theme-based CSS customization. + """ + if theme_name not in COLOR_THEMES: + theme_name = 'green' + + theme = COLOR_THEMES[theme_name] + + # Dynamic CSS based on theme colors + return f""" + #shell_header {{ + color: {theme['primary']}; + }} + + #shell_input_line {{ + color: {theme['primary']}; + }} + """ diff --git a/keepercommander/commands/supershell/utils.py b/keepercommander/commands/supershell/utils.py new file mode 100644 index 000000000..273d806a9 --- /dev/null +++ b/keepercommander/commands/supershell/utils.py @@ -0,0 +1,53 @@ +""" +SuperShell utility functions + +Helper functions for preferences, text processing, and other utilities. +""" + +import json +import logging +import re +from pathlib import Path + +# Preferences file path +PREFS_FILE = Path.home() / '.keeper' / 'supershell_prefs.json' + + +def load_preferences() -> dict: + """Load preferences from file, return defaults if not found""" + defaults = {'color_theme': 'green'} + try: + if PREFS_FILE.exists(): + with open(PREFS_FILE, 'r') as f: + prefs = json.load(f) + # Merge with defaults + return {**defaults, **prefs} + except Exception as e: + logging.debug(f"Error loading preferences: {e}") + return defaults + + +def save_preferences(prefs: dict): + """Save preferences to file""" + try: + PREFS_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(PREFS_FILE, 'w') as f: + json.dump(prefs, f, indent=2) + except Exception as e: + logging.error(f"Error saving preferences: {e}") + + +def strip_ansi_codes(text: str) -> str: + """Remove ANSI color codes from text""" + ansi_escape = re.compile(r'\x1b\[[0-9;]*m') + return ansi_escape.sub('', text) + + +def escape_rich_markup(text: str) -> str: + """Escape text for use in Rich markup. + + This prevents special characters like [ and ] from being + interpreted as Rich markup tags. + """ + from rich.markup import escape as rich_escape + return rich_escape(text) diff --git a/keepercommander/commands/supershell/widgets/__init__.py b/keepercommander/commands/supershell/widgets/__init__.py new file mode 100644 index 000000000..8887cf33d --- /dev/null +++ b/keepercommander/commands/supershell/widgets/__init__.py @@ -0,0 +1,15 @@ +""" +SuperShell custom widgets + +Reusable Textual widgets for the SuperShell TUI. +""" + +from .clickable_line import ClickableDetailLine +from .clickable_field import ClickableField +from .clickable_uid import ClickableRecordUID + +__all__ = [ + 'ClickableDetailLine', + 'ClickableField', + 'ClickableRecordUID', +] diff --git a/keepercommander/commands/supershell/widgets/clickable_field.py b/keepercommander/commands/supershell/widgets/clickable_field.py new file mode 100644 index 000000000..d8b4e5e03 --- /dev/null +++ b/keepercommander/commands/supershell/widgets/clickable_field.py @@ -0,0 +1,74 @@ +""" +ClickableField widget + +A clickable field that copies value to clipboard on click. +""" + +import pyperclip +from textual.widgets import Static +from textual.events import MouseDown + + +class ClickableField(Static): + """A clickable field that copies value to clipboard on click""" + + DEFAULT_CSS = """ + ClickableField { + width: 100%; + height: auto; + padding: 0 1; + } + + ClickableField:hover { + background: #333333; + } + + ClickableField.clickable-value:hover { + background: #444444; + text-style: bold; + } + """ + + def __init__(self, label: str, value: str, copy_value: str = None, + label_color: str = "#aaaaaa", value_color: str = "#00ff00", + is_header: bool = False, indent: int = 0, *args, **kwargs): + """ + Create a clickable field. + + Args: + label: The field label (e.g., "Username:") + value: The display value + copy_value: The value to copy (defaults to value) + label_color: Color for label + value_color: Color for value + is_header: If True, style as section header + indent: Indentation level (spaces) + """ + self.copy_value = copy_value if copy_value is not None else value + + # Build content before calling super().__init__ + indent_str = " " * indent + # Escape brackets for Rich markup + safe_value = value.replace('[', '\\[').replace(']', '\\]') if value else '' + safe_label = label.replace('[', '\\[').replace(']', '\\]') if label else '' + + if is_header: + content = f"[bold {value_color}]{indent_str}{safe_label}[/bold {value_color}]" + elif label: + content = f"{indent_str}[{label_color}]{safe_label}[/{label_color}] [{value_color}]{safe_value}[/{value_color}]" + else: + content = f"{indent_str}[{value_color}]{safe_value}[/{value_color}]" + + # Set classes for hover effect + classes = "clickable-value" if self.copy_value else "" + + super().__init__(content, classes=classes, *args, **kwargs) + + def on_mouse_down(self, event: MouseDown) -> None: + """Handle mouse down to copy value - fires immediately without waiting for focus""" + if self.copy_value: + try: + pyperclip.copy(self.copy_value) + self.app.notify(f"Copied to clipboard", severity="information") + except Exception as e: + self.app.notify(f"Copy failed: {e}", severity="error") diff --git a/keepercommander/commands/supershell/widgets/clickable_line.py b/keepercommander/commands/supershell/widgets/clickable_line.py new file mode 100644 index 000000000..e6637e263 --- /dev/null +++ b/keepercommander/commands/supershell/widgets/clickable_line.py @@ -0,0 +1,72 @@ +""" +ClickableDetailLine widget + +A single line in the detail view that highlights on hover and copies on click. +""" + +import pyperclip +from textual.widgets import Static +from textual.events import MouseDown + + +class ClickableDetailLine(Static): + """A single line in the detail view that highlights on hover and copies on click""" + + DEFAULT_CSS = """ + ClickableDetailLine { + width: 100%; + height: auto; + padding: 0 1; + } + + ClickableDetailLine:hover { + background: #1a1a2e; + } + + ClickableDetailLine.has-value { + /* Clickable lines get a subtle left border indicator */ + } + + ClickableDetailLine.has-value:hover { + background: #16213e; + text-style: bold; + border-left: thick #00ff00; + } + """ + + def __init__(self, content: str, copy_value: str = None, record_uid: str = None, + is_password: bool = False, *args, **kwargs): + """ + Create a clickable detail line. + + Args: + content: Rich markup content to display + copy_value: Value to copy on click (None = not copyable) + record_uid: Record UID for password audit events + is_password: If True, use ClipboardCommand for audit event + """ + self.copy_value = copy_value + self.record_uid = record_uid + self.is_password = is_password + classes = "has-value" if copy_value else "" + super().__init__(content, classes=classes, *args, **kwargs) + + def on_mouse_down(self, event: MouseDown) -> None: + """Handle mouse down to copy value - fires immediately without waiting for focus""" + if self.copy_value: + try: + if self.is_password and self.record_uid: + # Use ClipboardCommand to generate audit event for password copy + from ...record import ClipboardCommand + cc = ClipboardCommand() + cc.execute(self.app.params, record=self.record_uid, output='clipboard', + username=None, copy_uid=False, login=False, totp=False, + field=None, revision=None) + self.app.notify("Password copied to clipboard!", severity="information") + else: + # Regular copy for non-password fields + pyperclip.copy(self.copy_value) + truncated = self.copy_value[:50] + ('...' if len(self.copy_value) > 50 else '') + self.app.notify(f"Copied: {truncated}", severity="information") + except Exception as e: + self.app.notify(f"Copy failed: {e}", severity="error") diff --git a/keepercommander/commands/supershell/widgets/clickable_uid.py b/keepercommander/commands/supershell/widgets/clickable_uid.py new file mode 100644 index 000000000..78bac6458 --- /dev/null +++ b/keepercommander/commands/supershell/widgets/clickable_uid.py @@ -0,0 +1,75 @@ +""" +ClickableRecordUID widget + +A clickable record UID that navigates to the record when clicked. +""" + +import pyperclip +from textual.widgets import Static, Tree +from textual.events import MouseDown + + +class ClickableRecordUID(Static): + """A clickable record UID that navigates to the record when clicked""" + + DEFAULT_CSS = """ + ClickableRecordUID { + width: 100%; + height: auto; + padding: 0 1; + } + + ClickableRecordUID:hover { + background: #333344; + text-style: bold underline; + } + """ + + def __init__(self, label: str, record_uid: str, record_title: str = None, + label_color: str = "#aaaaaa", value_color: str = "#ffff00", + indent: int = 0, *args, **kwargs): + """ + Create a clickable record UID that navigates to the record. + + Args: + label: The field label (e.g., "Record UID:") + record_uid: The UID of the record to navigate to + record_title: Optional title to display instead of UID + label_color: Color for label + value_color: Color for value + indent: Indentation level + """ + self.record_uid = record_uid + + # Build content before calling super().__init__ + indent_str = " " * indent + display_value = record_title or record_uid + safe_value = display_value.replace('[', '\\[').replace(']', '\\]') + safe_label = label.replace('[', '\\[').replace(']', '\\]') if label else '' + + if label: + content = f"{indent_str}[{label_color}]{safe_label}[/{label_color}] [{value_color}]{safe_value} ->[/{value_color}]" + else: + content = f"{indent_str}[{value_color}]{safe_value} ->[/{value_color}]" + + super().__init__(content, *args, **kwargs) + + def on_mouse_down(self, event: MouseDown) -> None: + """Handle mouse down to navigate to record - fires immediately without waiting for focus""" + # Find the app and trigger record selection + app = self.app + if hasattr(app, 'records') and self.record_uid in app.records: + # Navigate to the record in the tree + app.selected_record = self.record_uid + app.selected_folder = None + app._display_record_detail(self.record_uid) + + # Try to select the node in the tree + tree = app.query_one("#folder_tree", Tree) + app._select_record_in_tree(tree, self.record_uid) + + app.notify(f"Navigated to record", severity="information") + else: + # Just copy the UID if record not found + pyperclip.copy(self.record_uid) + app.notify(f"Record not in vault. UID copied.", severity="warning") From a8115ad7a6e1272ad77e2adc3924360a8f8c1935 Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Tue, 6 Jan 2026 10:52:54 -0800 Subject: [PATCH 2/3] Fix GovCloud router URL and improve SuperShell UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate router URL logic into get_router_host() helper in constants.py (GovCloud environments use connect.* instead of connect.govcloud.*) - Add icon headers for records (πŸ”’), shared folders (πŸ‘₯), regular folders (πŸ“) - Fix shell pane: Tab/Shift+Tab cycling, Enter key handling, click event handling - Add Ctrl+Q exit for main CLI shell, :q/:quit to exit SuperShell - Fix pluralization in breachwatch messages - Document Shift+drag for native text selection in shell pane --- keepercommander/__main__.py | 4 +- keepercommander/api.py | 7 +- keepercommander/auth/console_ui.py | 80 +++++----- keepercommander/breachwatch.py | 2 +- keepercommander/cli.py | 18 ++- keepercommander/commands/_supershell_impl.py | 137 +++++++++++++----- keepercommander/commands/breachwatch.py | 3 +- keepercommander/commands/pam/router_helper.py | 8 +- .../commands/supershell/handlers/keyboard.py | 56 ++++++- .../commands/supershell/renderers/__init__.py | 2 - .../commands/supershell/renderers/folder.py | 14 -- .../commands/supershell/screens/help.py | 2 + .../commands/supershell/themes/css.py | 7 +- keepercommander/commands/utils.py | 51 +++++-- keepercommander/constants.py | 24 +++ .../keeper_dag/connection/commander.py | 13 +- keepercommander/keeper_dag/connection/ksm.py | 8 +- keepercommander/versioning.py | 13 +- 18 files changed, 291 insertions(+), 158 deletions(-) diff --git a/keepercommander/__main__.py b/keepercommander/__main__.py index 95fd6d0b5..54691109d 100644 --- a/keepercommander/__main__.py +++ b/keepercommander/__main__.py @@ -399,6 +399,7 @@ def main(from_package=False): print('Keeper Commander - CLI-based vault and admin interface to the Keeper platform') print('') print('To get started:') + print(' keeper login Authenticate to Keeper') print(' keeper shell Open interactive command shell') print(' keeper supershell Open full-screen vault browser (TUI)') print(' keeper -h Show help and available options') @@ -417,9 +418,6 @@ def main(from_package=False): # Special handling for shell/- when NOT asking for help if opts.command == '-': params.batch_mode = True - elif opts.command == 'login' and not original_args_after_command and not command_wants_help: - # 'keeper login' with no args - just open shell and let it handle login - pass elif opts.command and os.path.isfile(opts.command): with open(opts.command, 'r') as f: lines = f.readlines() diff --git a/keepercommander/api.py b/keepercommander/api.py index 652c7fe08..73a8bd9b6 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -75,7 +75,8 @@ def login(params, new_login=False, login_ui=None): try: flow.login(params, new_login=new_login) except loginv3.InvalidDeviceToken: - logging.warning('Registering new device') + from colorama import Fore + print(f'{Fore.CYAN}Registering new device...{Fore.RESET}') flow.login(params, new_device=True) @@ -706,8 +707,10 @@ def execute_router_rest(params: KeeperParams, endpoint: str, payload: Optional[b up = urlparse(os.environ['ROUTER_URL']) url_comp = (up.scheme, up.netloc, f'api/user/{endpoint}', None, None, None) else: + from .constants import get_router_host up = urlparse(params.rest_context.server_base) - url_comp = ('https', f'connect.{up.hostname}', f'api/user/{endpoint}', None, None, None) + router_host = get_router_host(up.hostname) + url_comp = ('https', router_host, f'api/user/{endpoint}', None, None, None) url = urlunparse(url_comp) if payload is not None: diff --git a/keepercommander/auth/console_ui.py b/keepercommander/auth/console_ui.py index c12fbb9b6..c10e7e496 100644 --- a/keepercommander/auth/console_ui.py +++ b/keepercommander/auth/console_ui.py @@ -9,7 +9,6 @@ from colorama import Fore, Style from . import login_steps from .. import utils -from ..display import bcolors from ..error import KeeperApiError @@ -24,8 +23,8 @@ def __init__(self): def on_device_approval(self, step): if self._show_device_approval_help: - print(f"\n{Style.BRIGHT}Device Approval Required{Style.RESET_ALL}\n") - print("Select an approval method:") + print(f"\n{Fore.YELLOW}Device Approval Required{Fore.RESET}\n") + print(f"{Fore.CYAN}Select an approval method:{Fore.RESET}") print(f" {Fore.GREEN}1{Fore.RESET}. Email - Send approval link to your email") print(f" {Fore.GREEN}2{Fore.RESET}. Keeper Push - Send notification to an approved device") print(f" {Fore.GREEN}3{Fore.RESET}. 2FA Push - Send code via your 2FA method") @@ -35,12 +34,12 @@ def on_device_approval(self, step): print() self._show_device_approval_help = False else: - print(f"\n{Style.BRIGHT}Waiting for device approval.{Style.RESET_ALL}") - print(f"Check email, SMS, or push notification on the approved device.") + print(f"\n{Fore.YELLOW}Waiting for device approval.{Fore.RESET}") + print(f"{Fore.CYAN}Check email, SMS, or push notification on the approved device.{Fore.RESET}") print(f"Enter {Fore.GREEN}c {Fore.RESET} to submit a verification code.\n") try: - selection = input('Selection (or Enter to check status): ').strip().lower() + selection = input(f'{Fore.GREEN}Selection{Fore.RESET} (or Enter to check status): ').strip().lower() if selection == '1' or selection == 'email_send' or selection == 'es': step.send_push(login_steps.DeviceApprovalChannel.Email) @@ -60,7 +59,7 @@ def on_device_approval(self, step): elif selection == 'c' or selection.startswith('c '): # Support both "c" (prompts for code) and "c " (code inline) if selection == 'c': - code_input = input('Enter verification code: ').strip() + code_input = input(f'{Fore.GREEN}Enter verification code: {Fore.RESET}').strip() else: code_input = selection[2:].strip() # Extract code after "c " @@ -99,7 +98,7 @@ def on_device_approval(self, step): step.cancel() except KeeperApiError as kae: print() - print(bcolors.WARNING + kae.message + bcolors.ENDC) + print(f'{Fore.YELLOW}{kae.message}{Fore.RESET}') pass @staticmethod @@ -123,16 +122,18 @@ def on_two_factor(self, step): channels = step.get_channels() if self._show_two_factor_help: - print("\nThis account requires 2FA Authentication\n") + print(f"\n{Fore.YELLOW}Two-Factor Authentication Required{Fore.RESET}\n") + print(f"{Fore.CYAN}Select your 2FA method:{Fore.RESET}") for i in range(len(channels)): channel = channels[i] - print(f"{i+1:>3}. {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)} {channel.channel_name} {channel.phone}") - print(f"{'q':>3}. Quit login attempt and return to Commander prompt") + print(f" {Fore.GREEN}{i+1}{Fore.RESET}. {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)} {channel.channel_name} {channel.phone}") + print(f" {Fore.GREEN}q{Fore.RESET}. Cancel login") + print() self._show_device_approval_help = False channel = None # type: Optional[login_steps.TwoFactorChannelInfo] while channel is None: - selection = input('Selection: ') + selection = input(f'{Fore.GREEN}Selection: {Fore.RESET}') if selection == 'q': raise KeyboardInterrupt() @@ -154,7 +155,7 @@ def on_two_factor(self, step): mfa_prompt = True try: step.send_push(channel.channel_uid, login_steps.TwoFactorPushAction.TextMessage) - print(bcolors.OKGREEN + "\nSuccessfully sent SMS.\n" + bcolors.ENDC) + print(f'\n{Fore.GREEN}SMS sent successfully.{Fore.RESET}\n') except KeeperApiError: print("Was unable to send SMS.") elif channel.channel_type == login_steps.TwoFactorChannel.SecurityKey: @@ -177,14 +178,14 @@ def on_two_factor(self, step): } step.duration = login_steps.TwoFactorDuration.EveryLogin step.send_code(channel.channel_uid, json.dumps(signature)) - print(bcolors.OKGREEN + "Verified Security Key." + bcolors.ENDC) + print(f'{Fore.GREEN}Security key verified.{Fore.RESET}') except ImportError as e: from ..yubikey import display_fido2_warning display_fido2_warning() logging.warning(e) except KeeperApiError: - print(bcolors.FAIL + "Unable to verify code generated by security key" + bcolors.ENDC) + print(f'{Fore.RED}Unable to verify security key.{Fore.RESET}') except Exception as e: logging.error(e) @@ -230,7 +231,7 @@ def on_two_factor(self, step): print(prompt_exp) try: - answer = input('\nEnter 2FA Code or Duration: ') + answer = input(f'\n{Fore.GREEN}Enter 2FA Code: {Fore.RESET}') except KeyboardInterrupt: step.cancel() return @@ -263,19 +264,18 @@ def on_two_factor(self, step): step.duration = mfa_expiration try: step.send_code(channel.channel_uid, otp_code) - print(bcolors.OKGREEN + "Successfully verified 2FA Code." + bcolors.ENDC) + print(f'{Fore.GREEN}2FA code verified.{Fore.RESET}') except KeeperApiError: - warning_msg = bcolors.WARNING + f"Unable to verify 2FA code. Regenerate the code and try again." + bcolors.ENDC - print(warning_msg) + print(f'{Fore.YELLOW}Invalid 2FA code. Please try again.{Fore.RESET}') def on_password(self, step): if self._show_password_help: - print(f'Enter master password for {step.username}') + print(f'{Fore.CYAN}Enter master password for {Fore.WHITE}{step.username}{Fore.RESET}') if self._failed_password_attempt > 0: - print('Forgot password? Type "recover"') + print(f'{Fore.YELLOW}Forgot password? Type "recover"{Fore.RESET}') - password = getpass.getpass(prompt='Password: ', stream=None) + password = getpass.getpass(prompt=f'{Fore.GREEN}Password: {Fore.RESET}', stream=None) if not password: step.cancel() elif password == 'recover': @@ -300,24 +300,24 @@ def on_sso_redirect(self, step): wb = None sp_url = step.sso_login_url - print(f'\nSSO Login URL:\n{sp_url}\n') + print(f'\n{Fore.CYAN}SSO Login URL:{Fore.RESET}\n{sp_url}\n') if self._show_sso_redirect_help: - print('Navigate to SSO Login URL with your browser and complete login.') - print('Copy a returned SSO Token into clipboard.') - print('Paste that token into Commander') - print('NOTE: To copy SSO Token please click "Copy login token" button on "SSO Connect" page.') + print(f'{Fore.CYAN}Navigate to SSO Login URL with your browser and complete login.{Fore.RESET}') + print(f'{Fore.CYAN}Copy the returned SSO Token and paste it here.{Fore.RESET}') + print(f'{Fore.YELLOW}TIP: Click "Copy login token" button on the SSO Connect page.{Fore.RESET}') print('') - print(' a. SSO User with a Master Password') - print(' c. Copy SSO Login URL to clipboard') + print(f' {Fore.GREEN}a{Fore.RESET}. SSO User with a Master Password') + print(f' {Fore.GREEN}c{Fore.RESET}. Copy SSO Login URL to clipboard') if wb: - print(' o. Navigate to SSO Login URL with the default web browser') - print(' p. Paste SSO Token from clipboard') - print(' q. Quit SSO login attempt and return to Commander prompt') + print(f' {Fore.GREEN}o{Fore.RESET}. Open SSO Login URL in web browser') + print(f' {Fore.GREEN}p{Fore.RESET}. Paste SSO Token from clipboard') + print(f' {Fore.GREEN}q{Fore.RESET}. Cancel SSO login') + print() self._show_sso_redirect_help = False while True: try: - token = input('Selection: ') + token = input(f'{Fore.GREEN}Selection: {Fore.RESET}') except KeyboardInterrupt: step.cancel() return @@ -357,17 +357,19 @@ def on_sso_redirect(self, step): def on_sso_data_key(self, step): if self._show_sso_data_key_help: - print('\nApprove this device by selecting a method below:') - print(' 1. Keeper Push. Send a push notification to your device.') - print(' 2. Admin Approval. Request your admin to approve this device.') + print(f'\n{Fore.YELLOW}Device Approval Required for SSO{Fore.RESET}\n') + print(f'{Fore.CYAN}Select an approval method:{Fore.RESET}') + print(f' {Fore.GREEN}1{Fore.RESET}. Keeper Push - Send a push notification to your device') + print(f' {Fore.GREEN}2{Fore.RESET}. Admin Approval - Request your admin to approve this device') print('') - print(' r. Resume SSO login after device is approved.') - print(' q. Quit SSO login attempt and return to Commander prompt.') + print(f' {Fore.GREEN}r{Fore.RESET}. Resume SSO login after device is approved') + print(f' {Fore.GREEN}q{Fore.RESET}. Cancel SSO login') + print() self._show_sso_data_key_help = False while True: try: - answer = input('Selection: ') + answer = input(f'{Fore.GREEN}Selection: {Fore.RESET}') except KeyboardInterrupt: answer = 'q' diff --git a/keepercommander/breachwatch.py b/keepercommander/breachwatch.py index 3967dab5e..2814bf9bd 100644 --- a/keepercommander/breachwatch.py +++ b/keepercommander/breachwatch.py @@ -87,7 +87,7 @@ def scan_passwords(self, params, passwords): status.breachDetected = True results[password] = status if len(bw_hashes) > 0: - logging.info('Breachwatch: %d passwords to scan', len(bw_hashes)) + logging.info('Breachwatch: %d %s to scan', len(bw_hashes), 'password' if len(bw_hashes) == 1 else 'passwords') hashes = [] # type: List[breachwatch_pb2.HashCheck] for bw_hash in bw_hashes: check = breachwatch_pb2.HashCheck() diff --git a/keepercommander/cli.py b/keepercommander/cli.py index a5b1189e8..0f374222b 100644 --- a/keepercommander/cli.py +++ b/keepercommander/cli.py @@ -26,6 +26,7 @@ from prompt_toolkit import PromptSession from prompt_toolkit.enums import EditingMode from prompt_toolkit.shortcuts import CompleteStyle +from prompt_toolkit.key_binding import KeyBindings from . import api, display, ttk, utils from . import versioning @@ -380,7 +381,8 @@ def is_msp(params_local): try: # Some commands (like logout) need auth but not sync skip_sync = getattr(command, 'skip_sync_on_auth', False) - LoginCommand().execute(params, email=params.user, password=params.password, new_login=False, skip_sync=skip_sync) + # Auto-login for commands - don't show help text (show_help=False) + LoginCommand().execute(params, email=params.user, password=params.password, new_login=False, skip_sync=skip_sync, show_help=False) except KeyboardInterrupt: logging.info('Canceled') if not params.session_token: @@ -691,7 +693,7 @@ def read_command_with_continuation(prompt_session, params): return result -def loop(params, skip_init=False, suppress_goodbye=False, new_login=False): # type: (KeeperParams, bool, bool, bool) -> int +def loop(params, skip_init=False, suppress_goodbye=False, new_login=False): # type: (KeeperParams, bool, bool, bool) -> int # suppress_goodbye kept for API compat global prompt_session error_no = 0 suppress_errno = False @@ -702,11 +704,18 @@ def loop(params, skip_init=False, suppress_goodbye=False, new_login=False): # t if not params.batch_mode: if os.isatty(0) and os.isatty(1): completer = CommandCompleter(params, aliases) + # Create key bindings with Ctrl+Q to exit (consistent with supershell) + bindings = KeyBindings() + @bindings.add('c-q') + def _(event): + """Exit shell on Ctrl+Q""" + event.app.exit(exception=EOFError) prompt_session = PromptSession(multiline=False, editing_mode=EditingMode.VI, completer=completer, complete_style=CompleteStyle.MULTI_COLUMN, - complete_while_typing=False) + complete_while_typing=False, + key_bindings=bindings) if not skip_init: display.welcome() @@ -810,9 +819,6 @@ def loop(params, skip_init=False, suppress_goodbye=False, new_login=False): # t # Clear the shell loop flag params._in_shell_loop = False - if not params.batch_mode and not suppress_goodbye: - logging.info('\nGoodbye.\n') - return error_no diff --git a/keepercommander/commands/_supershell_impl.py b/keepercommander/commands/_supershell_impl.py index 91b16068d..8c0fc8e92 100644 --- a/keepercommander/commands/_supershell_impl.py +++ b/keepercommander/commands/_supershell_impl.py @@ -1672,19 +1672,26 @@ def _format_folder_for_tui(self, folder_uid: str) -> str: folder = self.params.folder_cache.get(folder_uid) if folder: folder_type = folder.get_folder_type() if hasattr(folder, 'get_folder_type') else folder.type + folder_type_str = str(folder_type) if folder_type else 'Folder' + folder_icon = "πŸ‘₯" if 'shared' in folder_type_str.lower() else "πŸ“" return ( - f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]\n" - f"[bold {t['primary']}]{rich_escape(str(folder.name))}[/bold {t['primary']}]\n" - f"[{t['text_dim']}]UID:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(folder_uid))}[/{t['primary']}]\n" - f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]\n\n" - f"[{t['secondary']}]{'Type':>20}:[/{t['secondary']}] [{t['primary']}]{rich_escape(str(folder_type))}[/{t['primary']}]\n\n" + f"[bold {t['secondary']}]{folder_icon} {rich_escape(str(folder.name))}[/bold {t['secondary']}]\n\n" + f"[{t['text_dim']}]Folder:[/{t['text_dim']}] [bold {t['primary']}]{rich_escape(str(folder.name))}[/bold {t['primary']}]\n" + f"[{t['text_dim']}]UID:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(folder_uid))}[/{t['primary']}]\n\n" f"[{t['primary_dim']}]Expand folder (press 'l' or β†’) to view records[/{t['primary_dim']}]" ) return "[red]Folder not found[/red]" # Format the output with proper alignment and theme colors lines = [] - lines.append(f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]") + + # Determine folder header with icon and name + folder = self.params.folder_cache.get(folder_uid) + folder_name = folder.name if folder else "Folder" + is_shared = 'Shared Folder UID' in output + folder_icon = "πŸ‘₯" if is_shared else "πŸ“" + lines.append(f"[bold {t['secondary']}]{folder_icon} {rich_escape(str(folder_name))}[/bold {t['secondary']}]") + lines.append("") for line in output.split('\n'): line = line.strip() @@ -1703,7 +1710,7 @@ def _format_folder_for_tui(self, folder_uid: str) -> str: if key in ['Shared Folder UID', 'Folder UID', 'Team UID']: lines.append(f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]") elif key == 'Name': - lines.append(f"[bold {t['primary']}]{rich_escape(str(value))}[/bold {t['primary']}]") + lines.append(f"[{t['text_dim']}]Folder:[/{t['text_dim']}] [bold {t['primary']}]{rich_escape(str(value))}[/bold {t['primary']}]") # Section headers (no value or short value) elif key in ['Record Permissions', 'User Permissions', 'Team Permissions', 'Share Administrators']: lines.append("") @@ -1726,7 +1733,6 @@ def _format_folder_for_tui(self, folder_uid: str) -> str: if line: lines.append(f"[{t['primary']}] {rich_escape(str(line))}[/{t['primary']}]") - lines.append(f"\n[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]") return "\n".join(lines) except Exception as e: @@ -1993,6 +1999,17 @@ def mount_line(content: str, copy_value: str = None, is_password: bool = False): record_data = self.records.get(record_uid, {}) actual_password = record_data.get('password', '') + # Determine header with icon and title + record_title = record_data.get('title', 'Untitled') + if record_uid in self.app_record_uids: + type_header = f"πŸ” {rich_escape(record_title)}" + else: + type_header = f"πŸ”’ {rich_escape(record_title)}" + + # Type header with icon and title + mount_line(f"[bold {t['secondary']}]{type_header}[/bold {t['secondary']}]", None) + mount_line("", None) + # Parse and create clickable lines current_section = None seen_first_user = False # Track if we've seen first user in permissions section @@ -2320,8 +2337,14 @@ def mount_line(content: str, copy_value: str = None, is_password: bool = False): widgets_to_mount.append(line) self.clickable_fields.append(line) - # Render JSON header - mount_line(f"[bold {t['primary']}]JSON View:[/bold {t['primary']}]") + # Render header with icon and title + record_data = self.records.get(record_uid, {}) + record_title = record_data.get('title', 'Untitled') + if record_uid in self.app_record_uids: + type_header = f"πŸ” {rich_escape(record_title)}" + else: + type_header = f"πŸ”’ {rich_escape(record_title)}" + mount_line(f"[bold {t['secondary']}]{type_header}[/bold {t['secondary']}] [{t['text_dim']}](JSON)[/{t['text_dim']}]") mount_line("") # Render JSON with syntax highlighting @@ -2580,16 +2603,26 @@ def mount_line(content: str, copy_value: str = None): line = ClickableDetailLine(content, copy_value) detail_scroll.mount(line, before=detail_widget) - # Header line - mount_line(f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]", None) + # Determine folder header with icon and name + folder_name = folder.name if folder else "Folder" + if folder: + ft = folder.get_folder_type() if hasattr(folder, 'get_folder_type') else str(folder.type) + if 'shared' in ft.lower(): + folder_icon = "πŸ‘₯" + else: + folder_icon = "πŸ“" + else: + folder_icon = "πŸ“" + + # Type header with icon and folder name + mount_line(f"[bold {t['secondary']}]{folder_icon} {rich_escape(str(folder_name))}[/bold {t['secondary']}]", None) + mount_line("", None) if not output or output.strip() == '': # Fallback to basic folder info if folder: - mount_line(f"[bold {t['primary']}]{rich_escape(str(folder.name))}[/bold {t['primary']}]", folder.name) + mount_line(f"[{t['text_dim']}]Folder:[/{t['text_dim']}] [bold {t['primary']}]{rich_escape(str(folder.name))}[/bold {t['primary']}]", folder.name) mount_line(f"[{t['text_dim']}]UID:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(folder_uid))}[/{t['primary']}]", folder_uid) - mount_line(f"[{t['text_dim']}]Type:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(folder_type))}[/{t['primary']}]", folder_type) - mount_line(f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]", None) return # Parse the output and format with clickable lines @@ -2629,9 +2662,9 @@ def mount_line(content: str, copy_value: str = None): elif key == 'Folder Type': display_type = value if value else folder_type mount_line(f"[{t['text_dim']}]Type:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(display_type))}[/{t['primary']}]", display_type) - # Name - title + # Name - show with "Folder:" label for consistency elif key == 'Name': - mount_line(f"[bold {t['primary']}]{rich_escape(str(value))}[/bold {t['primary']}]", value) + mount_line(f"[{t['text_dim']}]Folder:[/{t['text_dim']}] [bold {t['primary']}]{rich_escape(str(value))}[/bold {t['primary']}]", value) # Section headers elif key in section_headers: current_section = key @@ -2673,9 +2706,6 @@ def mount_line(content: str, copy_value: str = None): continue mount_line(f"{indent}[{t['primary']}]{rich_escape(str(stripped))}[/{t['primary']}]", stripped) - # Footer line - mount_line(f"\n[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]", None) - def _display_folder_json(self, folder_uid: str): """Display folder/shared folder as JSON with clickable values""" t = self.theme_colors @@ -2716,10 +2746,20 @@ def mount_json_line(content: str, copy_value: str = None, indent: int = 0): line = ClickableDetailLine(content, copy_value) container.mount(line, before=detail_widget) + # Determine folder header with icon and name + folder = self.params.folder_cache.get(folder_uid) + folder_name = folder.name if folder else "Folder" + if folder: + ft = folder.get_folder_type() if hasattr(folder, 'get_folder_type') else str(folder.type) + if 'shared' in ft.lower(): + folder_icon = "πŸ‘₯" + else: + folder_icon = "πŸ“" + else: + folder_icon = "πŸ“" + # Build formatted JSON output with clickable values - mount_json_line(f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]", None) - mount_json_line(f"[bold {t['primary']}]JSON View[/bold {t['primary']}] [{t['text_dim']}](press 't' for detail view)[/{t['text_dim']}]", None) - mount_json_line(f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]", None) + mount_json_line(f"[bold {t['secondary']}]{folder_icon} {rich_escape(str(folder_name))}[/bold {t['secondary']}] [{t['text_dim']}](JSON)[/{t['text_dim']}]", None) mount_json_line("", None) def render_json(obj, indent=0): @@ -2831,9 +2871,6 @@ def render_json_list_items(obj, indent): render_json(json_obj) - mount_json_line("", None) - mount_json_line(f"[bold {t['secondary']}]{'━' * 60}[/bold {t['secondary']}]", None) - # Add copy full JSON option full_json = json.dumps(json_obj, indent=2) mount_json_line(f"\n[{t['text_dim']}]Click to copy full JSON:[/{t['text_dim']}]", full_json) @@ -3082,6 +3119,21 @@ def on_device_status_click(self, event: Click) -> None: self._display_device_info() event.stop() + @on(Click, "#shell_pane, #shell_output, #shell_output_content, #shell_input_line, #shell_header") + def on_shell_pane_click(self, event: Click) -> None: + """Handle clicks in shell pane - activate shell input and stop propagation. + + This prevents clicks in the shell pane from bubbling up and triggering + expensive operations in other parts of the UI. + Note: Native terminal text selection requires Shift+click (terminal-dependent). + """ + if self.shell_pane_visible: + # Activate shell input when clicking anywhere in shell pane + if not self.shell_input_active: + self.shell_input_active = True + self._update_shell_input_display() + event.stop() + def on_paste(self, event: Paste) -> None: """Handle paste events (Cmd+V on Mac, Ctrl+V on Windows/Linux)""" if self.search_input_active and event.text: @@ -3269,6 +3321,11 @@ def _execute_command(self, command: str): if not command: return + # Handle quit commands (vim-style :q and :quit) + if command.lower() in ('q', 'quit'): + self.exit() + return + # Try to parse as line number first (vim navigation) try: line_num = int(command) @@ -3356,6 +3413,7 @@ def _execute_shell_command(self, command: str): """Execute a Keeper command in the shell pane and display output""" command = command.strip() if not command: + # Empty command - do nothing return # Handle quit commands @@ -3414,12 +3472,14 @@ def _execute_shell_command(self, command: str): # Update shell output display self._update_shell_output_display() - # Scroll to bottom - try: - shell_output = self.query_one("#shell_output", VerticalScroll) - shell_output.scroll_end(animate=False) - except Exception: - pass + # Scroll to bottom (defer to ensure content is rendered) + def scroll_to_bottom(): + try: + shell_output = self.query_one("#shell_output", VerticalScroll) + shell_output.scroll_end(animate=False) + except Exception: + pass + self.call_after_refresh(scroll_to_bottom) def _update_shell_output_display(self): """Update the shell output area with command history""" @@ -3435,10 +3495,10 @@ def _update_shell_output_display(self): # Show prompt and command prompt = self._get_shell_prompt() lines.append(f"[{t['primary']}]{prompt}[/{t['primary']}]{rich_escape(cmd)}") - # Show output + # Show output (with blank line separator only if there's output) if output.strip(): lines.append(output) - lines.append("") # Blank line between commands + lines.append("") # Blank line after output shell_output_content.update("\n".join(lines)) @@ -4062,25 +4122,24 @@ def update(self, message): from .utils import LoginCommand try: # Run login (no spinner - login may prompt for 2FA, password, etc.) - LoginCommand().execute(params, email=params.user, password=params.password, new_login=False) + # show_help=False to suppress the batch mode help text + LoginCommand().execute(params, email=params.user, password=params.password, new_login=False, show_help=False) if not params.session_token: logging.error("\nLogin failed or was cancelled.") return - # Sync vault data with spinner + # Sync vault data with spinner (no success message - TUI will load immediately) sync_spinner = Spinner("Syncing vault data...") sync_spinner.start() try: from .utils import SyncDownCommand SyncDownCommand().execute(params) - sync_spinner.stop("Vault synced!") + sync_spinner.stop() # No success message - TUI loads immediately except Exception as e: sync_spinner.stop() raise - print() # Blank line before TUI - except KeyboardInterrupt: print("\n\nLogin cancelled.") return diff --git a/keepercommander/commands/breachwatch.py b/keepercommander/commands/breachwatch.py index c805ec7f5..e6be0241b 100644 --- a/keepercommander/commands/breachwatch.py +++ b/keepercommander/commands/breachwatch.py @@ -200,7 +200,8 @@ def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> Any if euid_to_delete: params.breach_watch.delete_euids(params, euid_to_delete) if not kwargs.get('suppress_no_op') or record_passwords: - logging.info(f'Reported security audit data for {len(record_passwords)} records.') + count = len(record_passwords) + logging.info(f'Reported security audit data for {count} {"record" if count == 1 else "records"}.') if record_passwords: api.sync_down(params) diff --git a/keepercommander/commands/pam/router_helper.py b/keepercommander/commands/pam/router_helper.py index b5499783b..2d0badcc8 100644 --- a/keepercommander/commands/pam/router_helper.py +++ b/keepercommander/commands/pam/router_helper.py @@ -28,16 +28,12 @@ def get_router_url(params: KeeperParams): krouter_server_url = os.getenv(krouter_env_var_name) logging.debug(f"Getting Krouter url from ENV Variable '{krouter_env_var_name}'='{krouter_server_url}'") else: + from ...constants import get_router_host base_server_url = params.rest_context.server_base base_server = urlparse(base_server_url).netloc - str_base_server = base_server if isinstance(base_server, bytes): base_server = base_server.decode('utf-8') - - # In GovCloud environments, the router service is not under the govcloud subdomain - krouter_server_url = 'https://connect.' + base_server - if '.govcloud.' in krouter_server_url: - krouter_server_url = krouter_server_url.replace('.govcloud.', '.') + krouter_server_url = 'https://' + get_router_host(base_server) return krouter_server_url diff --git a/keepercommander/commands/supershell/handlers/keyboard.py b/keepercommander/commands/supershell/handlers/keyboard.py index 6fc5d0635..6ef3d03e1 100644 --- a/keepercommander/commands/supershell/handlers/keyboard.py +++ b/keepercommander/commands/supershell/handlers/keyboard.py @@ -144,6 +144,9 @@ class ShellInputHandler(KeyHandler): """Handles input when shell pane is visible and active.""" def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + # Handle Enter key whenever shell pane is visible (even if not actively focused on input) + if app.shell_pane_visible and event.key == "enter": + return True return app.shell_pane_visible and app.shell_input_active def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: @@ -155,6 +158,11 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: return True if event.key == "enter": + # Ensure shell input is active when Enter is pressed + if not app.shell_input_active: + app.shell_input_active = True + app._update_shell_input_display() + # Execute command (even if empty, to show new prompt) app._execute_shell_command(app.shell_input_text) app.shell_input_text = "" app._update_shell_input_display() @@ -168,6 +176,13 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: self._stop_event(event) return True + if event.key == "ctrl+u": + # Ctrl+U clears the input line (like bash) + app.shell_input_text = "" + app._update_shell_input_display() + self._stop_event(event) + return True + if event.key == "escape": app.shell_input_active = False tree.focus() @@ -176,6 +191,28 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: self._stop_event(event) return True + if event.key == "tab": + # Shell β†’ Search input + app.shell_input_active = False + app._update_shell_input_display() + app.search_input_active = True + tree.add_class("search-input-active") + app._update_search_display(perform_search=False) + app._update_status("Type to search | Tab to tree | Ctrl+U to clear") + self._stop_event(event) + return True + + if event.key == "shift+tab": + # Shell β†’ Detail pane + from textual.containers import VerticalScroll + app.shell_input_active = False + app._update_shell_input_display() + detail_scroll = app.query_one("#record_detail", VerticalScroll) + detail_scroll.focus() + app._update_status("Detail pane | Tab to shell | Shift+Tab to tree") + self._stop_event(event) + return True + if event.key == "up": if app.shell_command_history: if app.shell_history_index < len(app.shell_command_history) - 1: @@ -299,17 +336,29 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: return True if event.key == "ctrl+y": - # Ctrl+Y scrolls viewport up (like vim) + # Ctrl+Y scrolls viewport up one line (like vim) detail_scroll.scroll_relative(y=-1) self._stop_event(event) return True if event.key == "ctrl+e": - # Ctrl+E scrolls viewport down (like vim) + # Ctrl+E scrolls viewport down one line (like vim) detail_scroll.scroll_relative(y=1) self._stop_event(event) return True + if event.key == "ctrl+u": + # Ctrl+U scrolls viewport up half page (like vim) + detail_scroll.scroll_relative(y=-10) + self._stop_event(event) + return True + + if event.key == "ctrl+d": + # Ctrl+D scrolls viewport down half page (like vim) + detail_scroll.scroll_relative(y=10) + self._stop_event(event) + return True + return False @@ -397,7 +446,8 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: tree = app.query_one("#folder_tree", Tree) - # Ctrl+U clears the search input + # Ctrl+U clears the search input ONLY when actively typing in search + # Otherwise, let it pass through for page-up navigation if event.key == "ctrl+u" and app.search_input_active: app.search_input_text = "" app._update_search_display(perform_search=False) diff --git a/keepercommander/commands/supershell/renderers/__init__.py b/keepercommander/commands/supershell/renderers/__init__.py index 97669ced1..9c0f78324 100644 --- a/keepercommander/commands/supershell/renderers/__init__.py +++ b/keepercommander/commands/supershell/renderers/__init__.py @@ -46,7 +46,6 @@ format_folder_boolean_field, format_folder_field_line, format_record_permission_line, - format_separator_line, count_share_admins, FolderJsonRenderer, ) @@ -89,7 +88,6 @@ 'format_folder_boolean_field', 'format_folder_field_line', 'format_record_permission_line', - 'format_separator_line', 'count_share_admins', 'FolderJsonRenderer', ] diff --git a/keepercommander/commands/supershell/renderers/folder.py b/keepercommander/commands/supershell/renderers/folder.py index 57f7bfaeb..9d49d8d8f 100644 --- a/keepercommander/commands/supershell/renderers/folder.py +++ b/keepercommander/commands/supershell/renderers/folder.py @@ -160,20 +160,6 @@ def format_record_permission_line( return title_line, uid_line -def format_separator_line(theme_colors: dict, width: int = 60) -> str: - """Format a separator line. - - Args: - theme_colors: Theme color dict - width: Width of the separator - - Returns: - Rich markup formatted separator - """ - t = theme_colors - return f"[bold {t['secondary']}]{'━' * width}[/bold {t['secondary']}]" - - def count_share_admins(output: str) -> int: """Count share admins in folder output. diff --git a/keepercommander/commands/supershell/screens/help.py b/keepercommander/commands/supershell/screens/help.py index cf161c3e2..0e13b95b0 100644 --- a/keepercommander/commands/supershell/screens/help.py +++ b/keepercommander/commands/supershell/screens/help.py @@ -78,8 +78,10 @@ def compose(self) -> ComposeResult: [green]Shell Pane:[/green] :cmd Open shell + run cmd Ctrl+\\ Open/close shell + Up/Down Command history quit/q Close shell pane Ctrl+D Close shell pane + Shift+drag Select text (native) [green]General:[/green] ? Help diff --git a/keepercommander/commands/supershell/themes/css.py b/keepercommander/commands/supershell/themes/css.py index 3efb72508..dc4be9eb5 100644 --- a/keepercommander/commands/supershell/themes/css.py +++ b/keepercommander/commands/supershell/themes/css.py @@ -248,18 +248,21 @@ overflow-y: auto; padding: 0 1; background: #000000; + align: left bottom; } #shell_output_content { background: #000000; color: #ffffff; + width: 100%; } #shell_input_line { - height: 1; + height: 2; background: #111111; color: #00ff00; - padding: 0 1; + padding: 1 1 0 1; + border-top: solid #333333; } #shell_pane:focus-within #shell_input_line { diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index 214f28619..a46560670 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -295,6 +295,7 @@ def register_command_info(aliases, command_info): login_parser = argparse.ArgumentParser(prog='login', description='Start a login session on Commander') login_parser.add_argument('-p', '--pass', dest='password', action='store', help='master password') login_parser.add_argument('--new-login', dest='new_login', action='store_true', help='Force full login flow') +login_parser.add_argument('--server', dest='server', action='store', help='Data center region (US, EU, AU, CA, JP, GOV, etc.)') login_parser.add_argument('email', nargs='?', type=str, help='account email') login_parser.error = raise_parse_exception login_parser.exit = suppress_exit @@ -1656,16 +1657,36 @@ def is_authorised(self): return False def execute(self, params, **kwargs): + from ..constants import KEEPER_SERVERS, get_abbrev_by_host + if msp.current_mc_id: msp.current_mc_id = None msp.mc_params_dict.clear() + # Handle --server option to change data center + server = kwargs.get('server') + if server: + server_upper = server.upper().replace('-', '_') + if server_upper in KEEPER_SERVERS: + params.server = KEEPER_SERVERS[server_upper] + logging.info(f'Data center set to {server_upper}') + else: + logging.warning(f'Unknown server region: {server}. Using default.') + user = kwargs.get('email') or '' password = kwargs.get('password') or '' try: if not user: - user = input('... {0:>16}: '.format('User(Email)')).strip() + # Show current data center before prompting for email + region = get_abbrev_by_host(params.server) if params.server else 'US' + if not region: + # Check extended server list + region = next((k for k, v in KEEPER_SERVERS.items() if v == params.server), params.server) + print(f'{Fore.CYAN}Data center: {Fore.WHITE}{region}{Fore.RESET}') + print(f'{Fore.CYAN}Use {Fore.GREEN}login --server {Fore.CYAN} to change (US, EU, AU, CA, JP, GOV){Fore.RESET}') + print() + user = input(f'{Fore.GREEN}Email: {Fore.RESET}').strip() if not user: return except KeyboardInterrupt as e: @@ -1707,19 +1728,21 @@ def execute(self, params, **kwargs): except Exception as e: logging.debug(f'Device registration: {e}') - # Show post-login message - if params.batch_mode: - # One-shot login from terminal - show simple success message - print() - print(f'{Fore.GREEN}Keeper login successful.{Fore.RESET}') - print(f'Type "{Fore.GREEN}keeper shell{Fore.RESET}" for the interactive shell, "{Fore.GREEN}keeper supershell{Fore.RESET}" for the vault UI,') - print(f'or "{Fore.GREEN}keeper help{Fore.RESET}" to see all available commands.') - print() - else: - # Interactive shell - show full summary with tips - record_count = getattr(params, '_sync_record_count', 0) - breachwatch_count = getattr(params, '_sync_breachwatch_count', 0) - post_login_summary(record_count=record_count, breachwatch_count=breachwatch_count) + # Show post-login message (only for explicit login command, not auto-login) + show_help = kwargs.get('show_help', True) + if show_help: + if params.batch_mode: + # One-shot login from terminal - show simple success message + print() + print(f'{Fore.GREEN}Keeper login successful.{Fore.RESET}') + print(f'Type "{Fore.GREEN}keeper shell{Fore.RESET}" for the interactive shell, "{Fore.GREEN}keeper supershell{Fore.RESET}" for the vault UI,') + print(f'or "{Fore.GREEN}keeper help{Fore.RESET}" to see all available commands.') + print() + else: + # Interactive shell - show full summary with tips + record_count = getattr(params, '_sync_record_count', 0) + breachwatch_count = getattr(params, '_sync_breachwatch_count', 0) + post_login_summary(record_count=record_count, breachwatch_count=breachwatch_count) class CheckEnforcementsCommand(Command): diff --git a/keepercommander/constants.py b/keepercommander/constants.py index 6b532a99d..303d77279 100644 --- a/keepercommander/constants.py +++ b/keepercommander/constants.py @@ -402,6 +402,30 @@ def get_abbrev_by_host(host): return None +def get_router_host(server_hostname): + """ + Get the router hostname for a given Keeper server hostname. + + GovCloud environments use a different URL pattern where 'govcloud.' is + replaced with 'connect.' rather than prepended. + + Args: + server_hostname: The Keeper server hostname (e.g., 'keepersecurity.com', + 'govcloud.keepersecurity.us', 'govcloud.dev.keepersecurity.us') + + Returns: + The router hostname: + - 'keepersecurity.com' -> 'connect.keepersecurity.com' + - 'govcloud.keepersecurity.us' -> 'connect.keepersecurity.us' + - 'govcloud.dev.keepersecurity.us' -> 'connect.dev.keepersecurity.us' + - 'govcloud.qa.keepersecurity.us' -> 'connect.qa.keepersecurity.us' + """ + # GovCloud environments (.keepersecurity.us) replace 'govcloud.' with 'connect.' + if server_hostname and server_hostname.startswith('govcloud.'): + return 'connect.' + server_hostname[len('govcloud.'):] + return f'connect.{server_hostname}' + + # Messages # Account Transfer ACCOUNT_TRANSFER_MSG = """ diff --git a/keepercommander/keeper_dag/connection/commander.py b/keepercommander/keeper_dag/connection/commander.py index be023c1ce..e69f18fb3 100644 --- a/keepercommander/keeper_dag/connection/commander.py +++ b/keepercommander/keeper_dag/connection/commander.py @@ -63,17 +63,10 @@ def get_key_bytes(record: KeeperRecord) -> bytes: @property def hostname(self) -> str: - # The host is connect.keepersecurity.com, connect.dev.keepersecurity.com, etc. Append "connect" in front - # of host used for Commander. + # The host is connect.keepersecurity.com, connect.dev.keepersecurity.com, etc. + from ...constants import get_router_host server = self.params.config.get("server") - - # Only PROD GovCloud strips the subdomain (workaround for prod infrastructure). - # DEV/QA GOV (govcloud.dev.keepersecurity.us, govcloud.qa.keepersecurity.us) keep govcloud. - if server == 'govcloud.keepersecurity.us': - configured_host = 'connect.keepersecurity.us' - else: - configured_host = f'connect.{server}' - + configured_host = get_router_host(server) return os.environ.get("ROUTER_HOST", configured_host) @property diff --git a/keepercommander/keeper_dag/connection/ksm.py b/keepercommander/keeper_dag/connection/ksm.py index ed89697dc..f6112ee69 100644 --- a/keepercommander/keeper_dag/connection/ksm.py +++ b/keepercommander/keeper_dag/connection/ksm.py @@ -115,12 +115,8 @@ def app_key(self) -> str: return self.get_config_value(ConfigKeys.KEY_APP_KEY) def router_url_from_ksm_config(self) -> str: - hostname = self.hostname - # Only PROD GovCloud strips the subdomain (workaround for prod infrastructure). - # DEV/QA GOV (govcloud.dev.keepersecurity.us, govcloud.qa.keepersecurity.us) keep govcloud. - if hostname == 'govcloud.keepersecurity.us': - hostname = 'keepersecurity.us' - return f'connect.{hostname}' + from ...constants import get_router_host + return get_router_host(self.hostname) def ws_router_url_from_ksm_config(self, is_ws: bool = False) -> str: diff --git a/keepercommander/versioning.py b/keepercommander/versioning.py index 13cbe7545..05f5bb46c 100644 --- a/keepercommander/versioning.py +++ b/keepercommander/versioning.py @@ -110,16 +110,9 @@ def welcome_print_version(params): logging.debug(display.bcolors.WARNING + "It appears that the internet connection is offline." + display.bcolors.ENDC) elif not ver_info.get('is_up_to_date'): - print(display.bcolors.WARNING + - (" Your version of the Commander CLI is %s, the current version is %s.\n Use the β€˜version’ " - "command for more details.\n") % (this_app_version, ver_info.get('current_github_version')) + display.bcolors.ENDC - ) - if is_binary_app(): - print(display.bcolors.WARNING + - " Please visit https://github.com/Keeper-Security/Commander/releases to download the latest version.\n" + display.bcolors.ENDC) - else: - print(display.bcolors.WARNING + - " Please run β€˜pip3 install --upgrade keepercommanderβ€˜ to upgrade to the latest version.\n" + display.bcolors.ENDC) + from colorama import Fore + current = ver_info.get('current_github_version') + print(f"{Fore.YELLOW}Update available: v{current} (you have v{this_app_version}). Type 'version' for details.{Fore.RESET}\n") else: pass # print("Your version of the Commander CLI is %s." % this_app_version) From f41731c968b0a31ac02b26f3cd8f0c1e7d102e4d Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 6 Jan 2026 12:04:53 -0800 Subject: [PATCH 3/3] Release 17.2.4 --- .gitignore | 1 + keepercommander/__init__.py | 2 +- keepercommander/auth/console_ui.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9d787adc8..0e3650881 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ dr-logs *.swp CLAUDE.md AGENTS.md +keeper_db.sqlite diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index c19fe142f..a1bba2d35 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: ops@keepersecurity.com # -__version__ = '17.2.3' +__version__ = '17.2.4' diff --git a/keepercommander/auth/console_ui.py b/keepercommander/auth/console_ui.py index c10e7e496..e930cc829 100644 --- a/keepercommander/auth/console_ui.py +++ b/keepercommander/auth/console_ui.py @@ -270,7 +270,7 @@ def on_two_factor(self, step): def on_password(self, step): if self._show_password_help: - print(f'{Fore.CYAN}Enter master password for {Fore.WHITE}{step.username}{Fore.RESET}') + logging.info(f'{Fore.CYAN}Enter master password for {Fore.WHITE}{step.username}{Fore.RESET}') if self._failed_password_attempt > 0: print(f'{Fore.YELLOW}Forgot password? Type "recover"{Fore.RESET}')