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.'
)