from prompt_toolkit import HTML, PromptSession from prompt_toolkit.application import Application from prompt_toolkit.completion import Completer from prompt_toolkit.input.base import Input from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.layout.containers import HSplit, Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.layout.layout import Layout from prompt_toolkit.output.base import Output from prompt_toolkit.shortcuts import prompt from prompt_toolkit.validation import ValidationError, Validator from openhands_cli.tui import DEFAULT_STYLE from openhands_cli.tui.tui import CommandCompleter def build_keybindings( choices: list[str], selected: list[int], escapable: bool ) -> KeyBindings: """Create keybindings for the confirm UI. Split for testability.""" kb = KeyBindings() @kb.add('up') def _handle_up(event: KeyPressEvent) -> None: selected[0] = (selected[0] - 1) % len(choices) @kb.add('down') def _handle_down(event: KeyPressEvent) -> None: selected[0] = (selected[0] + 1) % len(choices) @kb.add('enter') def _handle_enter(event: KeyPressEvent) -> None: event.app.exit(result=selected[0]) if escapable: @kb.add('c-c') # Ctrl+C def _handle_hard_interrupt(event: KeyPressEvent) -> None: event.app.exit(exception=KeyboardInterrupt()) @kb.add('c-p') # Ctrl+P def _handle_pause_interrupt(event: KeyPressEvent) -> None: event.app.exit(exception=KeyboardInterrupt()) @kb.add('escape') # Escape key def _handle_escape(event: KeyPressEvent) -> None: event.app.exit(exception=KeyboardInterrupt()) return kb def build_layout(question: str, choices: list[str], selected_ref: list[int]) -> Layout: """Create the layout for the confirm UI. Split for testability.""" def get_choice_text() -> list[tuple[str, str]]: lines: list[tuple[str, str]] = [] lines.append(('class:question', f'{question}\n\n')) for i, choice in enumerate(choices): is_selected = i == selected_ref[0] prefix = '> ' if is_selected else ' ' style = 'class:selected' if is_selected else 'class:unselected' lines.append((style, f'{prefix}{choice}\n')) return lines content_window = Window( FormattedTextControl(get_choice_text), always_hide_cursor=True, height=Dimension(max=8), ) return Layout(HSplit([content_window])) def cli_confirm( question: str = 'Are you sure?', choices: list[str] | None = None, initial_selection: int = 0, escapable: bool = False, input: Input | None = None, # strictly for unit testing output: Output | None = None, # strictly for unit testing ) -> int: """Display a confirmation prompt with the given question and choices. Returns the index of the selected choice. """ if choices is None: choices = ['Yes', 'No'] selected = [initial_selection] # Using list to allow modification in closure kb = build_keybindings(choices, selected, escapable) layout = build_layout(question, choices, selected) app = Application( layout=layout, key_bindings=kb, style=DEFAULT_STYLE, full_screen=False, input=input, output=output, ) return int(app.run(in_thread=True)) def cli_text_input( question: str, escapable: bool = True, completer: Completer | None = None, validator: Validator = None, is_password: bool = False, ) -> str: """Prompt user to enter text input with optional validation. Args: question: The prompt question to display escapable: Whether the user can escape with Ctrl+C or Ctrl+P completer: Optional completer for tab completion validator: Optional callable that takes a string and returns True if valid. If validation fails, the callable should display error messages and the user will be reprompted. Returns: The validated user input string (stripped of whitespace) """ kb = KeyBindings() if escapable: @kb.add('c-c') def _(event: KeyPressEvent) -> None: event.app.exit(exception=KeyboardInterrupt()) @kb.add('c-p') def _(event: KeyPressEvent) -> None: event.app.exit(exception=KeyboardInterrupt()) @kb.add('enter') def _handle_enter(event: KeyPressEvent): event.app.exit(result=event.current_buffer.text) reason = str( prompt( question, style=DEFAULT_STYLE, key_bindings=kb, completer=completer, is_password=is_password, validator=validator, ) ) return reason.strip() def get_session_prompter( input: Input | None = None, # strictly for unit testing output: Output | None = None, # strictly for unit testing ) -> PromptSession: bindings = KeyBindings() @bindings.add('\\', 'enter') def _(event: KeyPressEvent) -> None: # Typing '\' + Enter forces a newline regardless event.current_buffer.insert_text('\n') @bindings.add('enter') def _handle_enter(event: KeyPressEvent): event.app.exit(result=event.current_buffer.text) @bindings.add('c-c') def _keyboard_interrupt(event: KeyPressEvent): event.app.exit(exception=KeyboardInterrupt()) session = PromptSession( completer=CommandCompleter(), key_bindings=bindings, prompt_continuation=lambda width, line_number, is_soft_wrap: '...', multiline=True, input=input, output=output, style=DEFAULT_STYLE, placeholder=HTML( '' 'Type your message… (tip: press \\ + Enter to insert a newline)' '' ), ) return session class NonEmptyValueValidator(Validator): def validate(self, document): text = document.text if not text: raise ValidationError( message='API key cannot be empty. Please enter a valid API key.' )