diff --git a/keepercommander/auth/console_ui.py b/keepercommander/auth/console_ui.py index e930cc829..0145d10b1 100644 --- a/keepercommander/auth/console_ui.py +++ b/keepercommander/auth/console_ui.py @@ -23,38 +23,38 @@ def __init__(self): def on_device_approval(self, step): if self._show_device_approval_help: - 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") - print() - print(f" {Fore.GREEN}c{Fore.RESET}. Enter code - Enter a verification code") - print(f" {Fore.GREEN}q{Fore.RESET}. Cancel login") - print() + logging.info(f"\n{Fore.YELLOW}Device Approval Required{Fore.RESET}\n") + logging.info(f"{Fore.CYAN}Select an approval method:{Fore.RESET}") + logging.info(f" {Fore.GREEN}1{Fore.RESET}. Email - Send approval link to your email") + logging.info(f" {Fore.GREEN}2{Fore.RESET}. Keeper Push - Send notification to an approved device") + logging.info(f" {Fore.GREEN}3{Fore.RESET}. 2FA Push - Send code via your 2FA method") + logging.info("") + logging.info(f" {Fore.GREEN}c{Fore.RESET}. Enter code - Enter a verification code") + logging.info(f" {Fore.GREEN}q{Fore.RESET}. Cancel login") + logging.info("") self._show_device_approval_help = False else: - 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") + logging.info(f"\n{Fore.YELLOW}Waiting for device approval.{Fore.RESET}") + logging.info(f"{Fore.CYAN}Check email, SMS, or push notification on the approved device.{Fore.RESET}") + logging.info(f"Enter {Fore.GREEN}c {Fore.RESET} to submit a verification code.\n") try: 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) - print(f"\n{Fore.GREEN}Email sent to {step.username}{Fore.RESET}") - print("Click the approval link in the email, then press Enter.\n") + logging.info(f"\n{Fore.GREEN}Email sent to {step.username}{Fore.RESET}") + logging.info("Click the approval link in the email, then press Enter.\n") elif selection == '2' or selection == 'keeper_push' or selection == 'kp': step.send_push(login_steps.DeviceApprovalChannel.KeeperPush) - print(f"\n{Fore.GREEN}Push notification sent.{Fore.RESET}") - print("Approve on your device, then press Enter.\n") + logging.info(f"\n{Fore.GREEN}Push notification sent.{Fore.RESET}") + logging.info("Approve on your device, then press Enter.\n") elif selection == '3' or selection == '2fa_send' or selection == '2fs': step.send_push(login_steps.DeviceApprovalChannel.TwoFactor) - print(f"\n{Fore.GREEN}2FA code sent.{Fore.RESET}") - print("Enter the code using option 'c'.\n") + logging.info(f"\n{Fore.GREEN}2FA code sent.{Fore.RESET}") + logging.info("Enter the code using option 'c'.\n") elif selection == 'c' or selection.startswith('c '): # Support both "c" (prompts for code) and "c " (code inline) @@ -67,23 +67,23 @@ def on_device_approval(self, step): # Try email code first, then 2FA try: step.send_code(login_steps.DeviceApprovalChannel.Email, code_input) - print(f"{Fore.GREEN}Successfully verified email code.{Fore.RESET}") + logging.info(f"{Fore.GREEN}Successfully verified email code.{Fore.RESET}") except KeeperApiError: try: step.send_code(login_steps.DeviceApprovalChannel.TwoFactor, code_input) - print(f"{Fore.GREEN}Successfully verified 2FA code.{Fore.RESET}") + logging.info(f"{Fore.GREEN}Successfully verified 2FA code.{Fore.RESET}") except KeeperApiError as e: - print(f"{Fore.YELLOW}Invalid code. Please try again.{Fore.RESET}") + logging.warning(f"{Fore.YELLOW}Invalid code. Please try again.{Fore.RESET}") elif selection.startswith("email_code="): code = selection.replace("email_code=", "") step.send_code(login_steps.DeviceApprovalChannel.Email, code) - print(f"{Fore.GREEN}Successfully verified email code.{Fore.RESET}") + logging.info(f"{Fore.GREEN}Successfully verified email code.{Fore.RESET}") elif selection.startswith("2fa_code="): code = selection.replace("2fa_code=", "") step.send_code(login_steps.DeviceApprovalChannel.TwoFactor, code) - print(f"{Fore.GREEN}Successfully verified 2FA code.{Fore.RESET}") + logging.info(f"{Fore.GREEN}Successfully verified 2FA code.{Fore.RESET}") elif selection == 'q': step.cancel() @@ -92,14 +92,12 @@ def on_device_approval(self, step): step.resume() else: - print(f"{Fore.YELLOW}Invalid selection. Enter 1, 2, 3, c, q, or press Enter.{Fore.RESET}") + logging.warning(f"{Fore.YELLOW}Invalid selection. Enter 1, 2, 3, c, q, or press Enter.{Fore.RESET}") except KeyboardInterrupt: step.cancel() except KeeperApiError as kae: - print() - print(f'{Fore.YELLOW}{kae.message}{Fore.RESET}') - pass + logging.warning(f'{Fore.YELLOW}{kae.message}{Fore.RESET}') @staticmethod def two_factor_channel_to_desc(channel): # type: (login_steps.TwoFactorChannel) -> str @@ -122,13 +120,13 @@ def on_two_factor(self, step): channels = step.get_channels() if self._show_two_factor_help: - print(f"\n{Fore.YELLOW}Two-Factor Authentication Required{Fore.RESET}\n") - print(f"{Fore.CYAN}Select your 2FA method:{Fore.RESET}") + logging.info(f"\n{Fore.YELLOW}Two-Factor Authentication Required{Fore.RESET}\n") + logging.info(f"{Fore.CYAN}Select your 2FA method:{Fore.RESET}") for i in range(len(channels)): channel = channels[i] - 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() + logging.info(f" {Fore.GREEN}{i+1}{Fore.RESET}. {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)} {channel.channel_name} {channel.phone}") + logging.info(f" {Fore.GREEN}q{Fore.RESET}. Cancel login") + logging.info("") self._show_device_approval_help = False channel = None # type: Optional[login_steps.TwoFactorChannelInfo] @@ -143,9 +141,9 @@ def on_two_factor(self, step): channel = channels[idx-1] logging.debug(f"Selected {idx}. {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)}") else: - print("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") + logging.warning("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") else: - print("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") + logging.warning("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") mfa_prompt = False @@ -155,9 +153,9 @@ def on_two_factor(self, step): mfa_prompt = True try: step.send_push(channel.channel_uid, login_steps.TwoFactorPushAction.TextMessage) - print(f'\n{Fore.GREEN}SMS sent successfully.{Fore.RESET}\n') + logging.info(f'\n{Fore.GREEN}SMS sent successfully.{Fore.RESET}\n') except KeeperApiError: - print("Was unable to send SMS.") + logging.warning("Was unable to send SMS.") elif channel.channel_type == login_steps.TwoFactorChannel.SecurityKey: try: from ..yubikey.yubikey import yubikey_authenticate @@ -178,14 +176,14 @@ def on_two_factor(self, step): } step.duration = login_steps.TwoFactorDuration.EveryLogin step.send_code(channel.channel_uid, json.dumps(signature)) - print(f'{Fore.GREEN}Security key verified.{Fore.RESET}') + logging.info(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(f'{Fore.RED}Unable to verify security key.{Fore.RESET}') + logging.error(f'{Fore.RED}Unable to verify security key.{Fore.RESET}') except Exception as e: logging.error(e) @@ -228,7 +226,7 @@ def on_two_factor(self, step): 'Ask Every 24 hours' if mfa_expiration == login_steps.TwoFactorDuration.Every24Hours else 'Ask Every 30 days', "|".join(allowed_expirations)) - print(prompt_exp) + logging.info(prompt_exp) try: answer = input(f'\n{Fore.GREEN}Enter 2FA Code: {Fore.RESET}') @@ -240,7 +238,7 @@ def on_two_factor(self, step): if m_duration: answer = m_duration.group(1).strip().lower() if answer not in allowed_expirations: - print(f'Invalid 2FA Duration: {answer}') + logging.warning(f'Invalid 2FA Duration: {answer}') answer = '' if answer == 'login': @@ -264,16 +262,16 @@ def on_two_factor(self, step): step.duration = mfa_expiration try: step.send_code(channel.channel_uid, otp_code) - print(f'{Fore.GREEN}2FA code verified.{Fore.RESET}') + logging.info(f'{Fore.GREEN}2FA code verified.{Fore.RESET}') except KeeperApiError: - print(f'{Fore.YELLOW}Invalid 2FA code. Please try again.{Fore.RESET}') + logging.warning(f'{Fore.YELLOW}Invalid 2FA code. Please try again.{Fore.RESET}') def on_password(self, step): if self._show_password_help: 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}') + logging.info(f'{Fore.YELLOW}Forgot password? Type "recover"{Fore.RESET}') password = getpass.getpass(prompt=f'{Fore.GREEN}Password: {Fore.RESET}', stream=None) if not password: @@ -284,7 +282,7 @@ def on_password(self, step): try: step.verify_password(password) except KeeperApiError as kae: - print(kae.message) + logging.warning(kae.message) except KeyboardInterrupt: step.cancel() @@ -300,19 +298,19 @@ def on_sso_redirect(self, step): wb = None sp_url = step.sso_login_url - print(f'\n{Fore.CYAN}SSO Login URL:{Fore.RESET}\n{sp_url}\n') + logging.info(f'\n{Fore.CYAN}SSO Login URL:{Fore.RESET}\n{sp_url}\n') if self._show_sso_redirect_help: - 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(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') + logging.info(f'{Fore.CYAN}Navigate to SSO Login URL with your browser and complete login.{Fore.RESET}') + logging.info(f'{Fore.CYAN}Copy the returned SSO Token and paste it here.{Fore.RESET}') + logging.info(f'{Fore.YELLOW}TIP: Click "Copy login token" button on the SSO Connect page.{Fore.RESET}') + logging.info('') + logging.info(f' {Fore.GREEN}a{Fore.RESET}. SSO User with a Master Password') + logging.info(f' {Fore.GREEN}c{Fore.RESET}. Copy SSO Login URL to clipboard') if wb: - 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() + logging.info(f' {Fore.GREEN}o{Fore.RESET}. Open SSO Login URL in web browser') + logging.info(f' {Fore.GREEN}p{Fore.RESET}. Paste SSO Token from clipboard') + logging.info(f' {Fore.GREEN}q{Fore.RESET}. Cancel SSO login') + logging.info('') self._show_sso_redirect_help = False while True: @@ -331,25 +329,25 @@ def on_sso_redirect(self, step): token = None try: pyperclip.copy(sp_url) - print('SSO Login URL is copied to clipboard.') + logging.info('SSO Login URL is copied to clipboard.') except: - print('Failed to copy SSO Login URL to clipboard.') + logging.warning('Failed to copy SSO Login URL to clipboard.') elif token == 'o': token = None if wb: try: wb.open_new_tab(sp_url) except: - print('Failed to open web browser.') + logging.warning('Failed to open web browser.') elif token == 'p': try: token = pyperclip.paste() except: token = '' - logging.info('Failed to paste from clipboard') + logging.warning('Failed to paste from clipboard') else: if len(token) < 10: - print(f'Unsupported menu option: {token}') + logging.warning(f'Unsupported menu option: {token}') token = None if token: step.set_sso_token(token) @@ -357,14 +355,14 @@ def on_sso_redirect(self, step): def on_sso_data_key(self, step): if self._show_sso_data_key_help: - 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(f' {Fore.GREEN}r{Fore.RESET}. Resume SSO login after device is approved') - print(f' {Fore.GREEN}q{Fore.RESET}. Cancel SSO login') - print() + logging.info(f'\n{Fore.YELLOW}Device Approval Required for SSO{Fore.RESET}\n') + logging.info(f'{Fore.CYAN}Select an approval method:{Fore.RESET}') + logging.info(f' {Fore.GREEN}1{Fore.RESET}. Keeper Push - Send a push notification to your device') + logging.info(f' {Fore.GREEN}2{Fore.RESET}. Admin Approval - Request your admin to approve this device') + logging.info('') + logging.info(f' {Fore.GREEN}r{Fore.RESET}. Resume SSO login after device is approved') + logging.info(f' {Fore.GREEN}q{Fore.RESET}. Cancel SSO login') + logging.info('') self._show_sso_data_key_help = False while True: @@ -384,4 +382,4 @@ def on_sso_data_key(self, step): elif answer == '2': step.request_data_key(login_steps.DataKeyShareChannel.AdminApproval) else: - print(f'Action \"{answer}\" is not supported.') + logging.warning(f'Action \"{answer}\" is not supported.') diff --git a/keepercommander/commands/_supershell_impl.py b/keepercommander/commands/_supershell_impl.py index 8c0fc8e92..c5d6be4a5 100644 --- a/keepercommander/commands/_supershell_impl.py +++ b/keepercommander/commands/_supershell_impl.py @@ -39,7 +39,7 @@ from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical, VerticalScroll, Center, Middle -from textual.widgets import Tree, DataTable, Footer, Header, Static, Input, Label, Button +from textual.widgets import Tree, DataTable, Footer, Header, Static, Input, Label, Button, TextArea from textual.binding import Binding from textual.screen import Screen, ModalScreen from textual.reactive import reactive @@ -47,7 +47,58 @@ from textual.message import Message from textual.timer import Timer from rich.text import Text -from textual.events import Click, MouseDown, Paste +from textual.events import Click, MouseDown, MouseUp, MouseMove, Paste + +# === DEBUG EVENT LOGGING === +# Set to True to log all mouse/keyboard events to /tmp/supershell_debug.log +# tail -f /tmp/supershell_debug.log to watch events in real-time +DEBUG_EVENTS = False +_debug_log_file = None + +def _debug_log(msg: str): + """Log debug message to /tmp/supershell_debug.log if DEBUG_EVENTS is True.""" + if not DEBUG_EVENTS: + return + global _debug_log_file + try: + if _debug_log_file is None: + _debug_log_file = open('/tmp/supershell_debug.log', 'a') + import datetime + timestamp = datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3] + _debug_log_file.write(f"[{timestamp}] {msg}\n") + _debug_log_file.flush() + except Exception as e: + pass # Silently fail if logging fails +# === END DEBUG EVENT LOGGING === + + +class AutoCopyTextArea(TextArea): + """TextArea that auto-copies selected text to clipboard on mouse release.""" + + def _on_mouse_up(self, event: MouseUp) -> None: + """Handle mouse up - auto-copy any selected text.""" + _debug_log(f"AutoCopyTextArea._on_mouse_up: x={event.x} y={event.y}") + # Let parent handle the event first + super()._on_mouse_up(event) + # Then check for selection and copy + self._auto_copy_if_selected() + + def _auto_copy_if_selected(self) -> None: + """Copy selected text to clipboard if any.""" + try: + selected = self.selected_text + _debug_log(f"AutoCopyTextArea: selected_text={selected!r}") + if selected and selected.strip(): + import pyperclip + pyperclip.copy(selected) + preview = selected[:40] + ('...' if len(selected) > 40 else '') + preview = preview.replace('\n', ' ') + # Use app.notify() instead of widget's notify() + self.app.notify(f"Copied: {preview}", severity="information") + _debug_log(f"AutoCopyTextArea: Copied to clipboard") + except Exception as e: + _debug_log(f"AutoCopyTextArea: Error: {e}") + from ..commands.base import Command @@ -309,16 +360,12 @@ class SuperShellApp(App): border-bottom: solid #333333; } - #shell_output { - height: 1fr; - overflow-y: auto; - padding: 0 1; - background: #000000; - } - #shell_output_content { + height: 1fr; background: #000000; color: #ffffff; + border: none; + padding: 0 1; } #shell_input_line { @@ -400,6 +447,7 @@ def __init__(self, params): self.shell_input_active = False self.shell_command_history = [] # For up/down arrow navigation self.shell_history_index = -1 + # Shell output uses TextArea widget with built-in selection support # Load color theme from preferences prefs = load_preferences() self.color_theme = prefs.get('color_theme', 'green') @@ -547,8 +595,8 @@ def compose(self) -> ComposeResult: # 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") + # AutoCopyTextArea auto-copies selected text on mouse release + yield AutoCopyTextArea("", id="shell_output_content", read_only=True) yield Static("", id="shell_input_line") # Status bar at very bottom @@ -3100,43 +3148,55 @@ def _update_shortcuts_bar(self, record_selected: bool = False, folder_selected: @on(Click, "#search_bar, #search_display") def on_search_bar_click(self, event: Click) -> None: """Activate search mode when search bar is clicked""" + _debug_log(f"CLICK: search_bar x={event.x} y={event.y} button={event.button} " + f"shift={event.shift} ctrl={event.ctrl} meta={event.meta}") tree = self.query_one("#folder_tree", Tree) 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 navigate | Ctrl+U to clear") event.stop() + _debug_log(f"CLICK: search_bar -> stopped") @on(Click, "#user_info") def on_user_info_click(self, event: Click) -> None: """Show whoami info when user info is clicked""" + _debug_log(f"CLICK: user_info x={event.x} y={event.y}") self._display_whoami_info() event.stop() + _debug_log(f"CLICK: user_info -> stopped") @on(Click, "#device_status_info") def on_device_status_click(self, event: Click) -> None: """Show device info when Stay Logged In / Logout section is clicked""" + _debug_log(f"CLICK: device_status_info x={event.x} y={event.y}") self._display_device_info() event.stop() + _debug_log(f"CLICK: device_status_info -> stopped") - @on(Click, "#shell_pane, #shell_output, #shell_output_content, #shell_input_line, #shell_header") + @on(Click, "#shell_pane, #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. + """Handle clicks in shell pane (not output area) - activate shell input.""" + _debug_log(f"CLICK: shell_pane x={event.x} y={event.y} button={event.button}") - 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). - """ + # Normal click - activate shell input 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() + _debug_log(f"CLICK: shell_pane -> stopped") 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: + _debug_log(f"PASTE: text={event.text!r} shell_input_active={self.shell_input_active}") + if self.shell_input_active and event.text: + # Append pasted text to shell input (only first line, strip whitespace) + first_line = event.text.split('\n')[0].strip() + self.shell_input_text += first_line + self._update_shell_input_display() + event.stop() + elif self.search_input_active and event.text: # Append pasted text to search input (strip newlines) pasted_text = event.text.replace('\n', ' ').replace('\r', '') self.search_input_text += pasted_text @@ -3286,8 +3346,14 @@ def on_key(self, event): Keyboard handling is delegated to specialized handlers in supershell/handlers/keyboard.py for better organization and testing. """ + _debug_log(f"KEY: key={event.key!r} char={event.character!r} " + f"shell_visible={self.shell_pane_visible} shell_input_active={self.shell_input_active} " + f"search_active={self.search_input_active}") + # Dispatch to the keyboard handler chain - if keyboard_dispatcher.dispatch(event, self): + handled = keyboard_dispatcher.dispatch(event, self) + _debug_log(f"KEY: handled={handled}") + if handled: return # Event was not handled by any handler - let it propagate @@ -3391,7 +3457,7 @@ def _open_shell_pane(self, command: str = None): if command: self._execute_shell_command(command) - self._update_status("Shell open | Enter to run | quit/q/Ctrl+D to close") + self._update_status("Shell open | Enter to run | Select+Ctrl+C to copy | quit/q/Ctrl+D to close") def _close_shell_pane(self): """Close the shell pane and return to normal view""" @@ -3475,32 +3541,37 @@ def _execute_shell_command(self, command: str): # Scroll to bottom (defer to ensure content is rendered) def scroll_to_bottom(): try: - shell_output = self.query_one("#shell_output", VerticalScroll) + shell_output = self.query_one("#shell_output_content", TextArea) + # Move cursor to end to scroll to bottom + shell_output.action_cursor_line_end() 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""" + """Update the shell output area with command history (using TextArea for selection support)""" try: - shell_output_content = self.query_one("#shell_output_content", Static) + shell_output_content = self.query_one("#shell_output_content", TextArea) except Exception: return - t = self.theme_colors lines = [] for cmd, output in self.shell_history: - # Show prompt and command + # Show prompt and command (plain text - TextArea doesn't support Rich markup) prompt = self._get_shell_prompt() - lines.append(f"[{t['primary']}]{prompt}[/{t['primary']}]{rich_escape(cmd)}") + lines.append(f"{prompt}{cmd}") # Show output (with blank line separator only if there's output) if output.strip(): - lines.append(output) + # Strip Rich markup from output + plain_output = re.sub(r'\[[^\]]*\]', '', output) + lines.append(plain_output) lines.append("") # Blank line after output - shell_output_content.update("\n".join(lines)) + # TextArea uses .text property, not .update() + shell_output_content.text = "\n".join(lines) + _debug_log(f"_update_shell_output_display: updated TextArea with {len(lines)} lines") def _update_shell_input_display(self): """Update the shell input line with prompt and current input text""" diff --git a/keepercommander/commands/supershell/handlers/keyboard.py b/keepercommander/commands/supershell/handlers/keyboard.py index 6ef3d03e1..f0eef4dfb 100644 --- a/keepercommander/commands/supershell/handlers/keyboard.py +++ b/keepercommander/commands/supershell/handlers/keyboard.py @@ -10,6 +10,25 @@ from rich.markup import escape as rich_escape +# Debug logging - writes to /tmp/supershell_debug.log when enabled +DEBUG_EVENTS = False +_debug_log_file = None + +def _debug_log(msg: str): + """Log debug message to /tmp/supershell_debug.log if DEBUG_EVENTS is True.""" + if not DEBUG_EVENTS: + return + global _debug_log_file + try: + if _debug_log_file is None: + _debug_log_file = open('/tmp/supershell_debug.log', 'a') + import datetime + timestamp = datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3] + _debug_log_file.write(f"[{timestamp}] {msg}\n") + _debug_log_file.flush() + except Exception: + pass + if TYPE_CHECKING: from textual.events import Key from textual.widgets import Tree @@ -144,9 +163,7 @@ 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 + # Only handle keys when shell input is actually active (focused) return app.shell_pane_visible and app.shell_input_active def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: @@ -158,10 +175,6 @@ 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 = "" @@ -183,6 +196,21 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: self._stop_event(event) return True + if event.key == "ctrl+v": + # Ctrl+V pastes from clipboard + try: + import pyperclip + clipboard_text = pyperclip.paste() + if clipboard_text: + # Only take first line if multiline, strip whitespace + first_line = clipboard_text.split('\n')[0].strip() + app.shell_input_text += first_line + app._update_shell_input_display() + except Exception: + pass # Silently fail if clipboard unavailable + self._stop_event(event) + return True + if event.key == "escape": app.shell_input_active = False tree.focus() @@ -254,6 +282,80 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: return True +class ShellCopyHandler(KeyHandler): + """Handles Ctrl+C/Cmd+C to copy selected text, Ctrl+Shift+C/Cmd+Shift+C to copy all shell output.""" + + # Keys that trigger copy selected text + COPY_KEYS = ("ctrl+c", "cmd+c") + # Keys that trigger copy all output + COPY_ALL_KEYS = ("ctrl+shift+c", "cmd+shift+c") + + def can_handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + all_copy_keys = self.COPY_KEYS + self.COPY_ALL_KEYS + result = app.shell_pane_visible and event.key in all_copy_keys + _debug_log(f"ShellCopyHandler.can_handle: shell_visible={app.shell_pane_visible} " + f"key={event.key!r} result={result}") + return result + + def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: + _debug_log(f"ShellCopyHandler.handle: key={event.key!r}") + import pyperclip + + # Ctrl+C or Cmd+C: Copy selected text from TextArea + if event.key in self.COPY_KEYS: + try: + from textual.widgets import TextArea + shell_output = app.query_one("#shell_output_content", TextArea) + selected = shell_output.selected_text + _debug_log(f"ShellCopyHandler.handle: selected_text={selected!r}") + + if selected and selected.strip(): + pyperclip.copy(selected) + preview = selected[:40] + ('...' if len(selected) > 40 else '') + preview = preview.replace('\n', ' ') + app.notify(f"Copied: {preview}", severity="information") + _debug_log(f"ShellCopyHandler.handle: Copied selected text") + self._stop_event(event) + return True + else: + _debug_log(f"ShellCopyHandler.handle: No text selected, not handling") + return False # Let event propagate if nothing selected + except Exception as e: + _debug_log(f"ShellCopyHandler.handle: Error getting selection: {e}") + return False + + # Ctrl+Shift+C or Cmd+Shift+C: Copy all shell output + if event.key in self.COPY_ALL_KEYS: + import re + lines = [] + _debug_log(f"ShellCopyHandler.handle: shell_history has {len(app.shell_history)} entries") + for cmd, output in app.shell_history: + lines.append(f"> {cmd}") + if output.strip(): + lines.append(output) + lines.append("") + + raw_text = '\n'.join(lines) + clean_text = re.sub(r'\x1b\[[0-9;]*m', '', raw_text) + clean_text = re.sub(r'\[[^\]]*\]', '', clean_text) + + if clean_text.strip(): + try: + pyperclip.copy(clean_text.strip()) + app.notify("All shell output copied", severity="information") + _debug_log(f"ShellCopyHandler.handle: Copied all output") + except Exception as e: + _debug_log(f"ShellCopyHandler.handle: Copy failed: {e}") + app.notify("Copy failed", severity="warning") + else: + app.notify("No output to copy", severity="information") + + self._stop_event(event) + return True + + return False + + class SearchInputTabHandler(KeyHandler): """Handles Tab/Shift+Tab when in search input mode.""" @@ -410,7 +512,10 @@ def handle(self, event: 'Key', app: 'SuperShellApp') -> bool: # Tab switches to detail pane if event.key == "tab": detail_scroll.focus() - app._update_status("Detail pane | Tab to search | Shift+Tab to tree") + if app.shell_pane_visible: + app._update_status("Detail pane | Tab to shell | Shift+Tab to tree") + else: + app._update_status("Detail pane | Tab to search | Shift+Tab to tree") self._stop_event(event) return True @@ -585,6 +690,7 @@ def __init__(self): CommandModeHandler(), ShellInputHandler(), ShellPaneCloseHandler(), + ShellCopyHandler(), # Tab cycling handlers SearchInputTabHandler(), @@ -610,9 +716,14 @@ def dispatch(self, event: 'Key', app: 'SuperShellApp') -> bool: True if the event was handled """ for handler in self.handlers: - if handler.can_handle(event, app): - if handler.handle(event, app): + can_handle = handler.can_handle(event, app) + if can_handle: + _debug_log(f"DISPATCH: {handler.__class__.__name__} can_handle=True for key={event.key!r}") + result = handler.handle(event, app) + _debug_log(f"DISPATCH: {handler.__class__.__name__} handle returned {result}") + if result: return True + _debug_log(f"DISPATCH: No handler for key={event.key!r}") return False diff --git a/keepercommander/commands/supershell/screens/help.py b/keepercommander/commands/supershell/screens/help.py index 0e13b95b0..e963aaecf 100644 --- a/keepercommander/commands/supershell/screens/help.py +++ b/keepercommander/commands/supershell/screens/help.py @@ -81,7 +81,7 @@ def compose(self) -> ComposeResult: Up/Down Command history quit/q Close shell pane Ctrl+D Close shell pane - Shift+drag Select text (native) + Select text Auto-copies to clipboard [green]General:[/green] ? Help diff --git a/keepercommander/service/util/tunneling.py b/keepercommander/service/util/tunneling.py index 5c650de9f..afc8c95d7 100644 --- a/keepercommander/service/util/tunneling.py +++ b/keepercommander/service/util/tunneling.py @@ -185,6 +185,13 @@ def generate_ngrok_url(port, auth_token, ngrok_custom_domain, run_mode): if not port or not auth_token: raise ValueError("Both 'port' and 'ngrok_auth_token' must be provided.") + # Strip .ngrok.io suffix if user provided full domain (e.g., "mycompany.ngrok.io" -> "mycompany") + if ngrok_custom_domain: + for suffix in ['.ngrok.io', '.ngrok.app', '.ngrok-free.app']: + if ngrok_custom_domain.lower().endswith(suffix): + ngrok_custom_domain = ngrok_custom_domain[:-len(suffix)] + break + logging.getLogger("ngrok").setLevel(logging.CRITICAL) logging.getLogger("pyngrok").setLevel(logging.CRITICAL)