mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
200 lines
6.2 KiB
Python
200 lines
6.2 KiB
Python
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(
|
|
'<placeholder>'
|
|
'Type your message… (tip: press <b>\\</b> + <b>Enter</b> to insert a newline)'
|
|
'</placeholder>'
|
|
),
|
|
)
|
|
|
|
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.'
|
|
)
|