feat(cli): Add vi mode support (#9287)

Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
This commit is contained in:
Ikuo Matsumura 2025-06-24 02:39:38 +09:00 committed by GitHub
parent 63c7815823
commit 9ec94737ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 188 additions and 39 deletions

View File

@ -52,6 +52,7 @@ async def handle_commands(
if command == '/exit':
close_repl = handle_exit_command(
config,
event_stream,
usage_metrics,
sid,
@ -66,7 +67,7 @@ async def handle_commands(
handle_status_command(usage_metrics, sid)
elif command == '/new':
close_repl, new_session_requested = handle_new_command(
event_stream, usage_metrics, sid
config, event_stream, usage_metrics, sid
)
elif command == '/settings':
await handle_settings_command(config, settings_store)
@ -81,12 +82,16 @@ async def handle_commands(
def handle_exit_command(
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
config: OpenHandsConfig,
event_stream: EventStream,
usage_metrics: UsageMetrics,
sid: str,
) -> bool:
close_repl = False
confirm_exit = (
cli_confirm('\nTerminate session?', ['Yes, proceed', 'No, dismiss']) == 0
cli_confirm(config, '\nTerminate session?', ['Yes, proceed', 'No, dismiss'])
== 0
)
if confirm_exit:
@ -119,7 +124,7 @@ async def handle_init_command(
reload_microagents = False
if config.runtime == 'local':
init_repo = await init_repository(current_dir)
init_repo = await init_repository(config, current_dir)
if init_repo:
event_stream.add_event(
MessageAction(content=REPO_MD_CREATE_PROMPT),
@ -140,13 +145,17 @@ def handle_status_command(usage_metrics: UsageMetrics, sid: str) -> None:
def handle_new_command(
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
config: OpenHandsConfig,
event_stream: EventStream,
usage_metrics: UsageMetrics,
sid: str,
) -> tuple[bool, bool]:
close_repl = False
new_session_requested = False
new_session_requested = (
cli_confirm(
config,
'\nCurrent session will be terminated and you will lose the conversation history.\n\nContinue?',
['Yes, proceed', 'No, dismiss'],
)
@ -171,6 +180,7 @@ async def handle_settings_command(
) -> None:
display_settings(config)
modify_settings = cli_confirm(
config,
'\nWhich settings would you like to modify?',
[
'Basic',
@ -207,7 +217,7 @@ async def handle_resume_command(
return close_repl, new_session_requested
async def init_repository(current_dir: str) -> bool:
async def init_repository(config: OpenHandsConfig, current_dir: str) -> bool:
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
init_repo = False
@ -237,6 +247,7 @@ async def init_repository(current_dir: str) -> bool:
init_repo = (
cli_confirm(
config,
'Do you want to re-initialize?',
['Yes, re-initialize', 'No, dismiss'],
)
@ -255,6 +266,7 @@ async def init_repository(current_dir: str) -> bool:
init_repo = (
cli_confirm(
config,
'Do you want to proceed?',
['Yes, create', 'No, dismiss'],
)
@ -297,7 +309,10 @@ def check_folder_security_agreement(config: OpenHandsConfig, current_dir: str) -
print_formatted_text('')
confirm = (
cli_confirm('Do you wish to continue?', ['Yes, proceed', 'No, exit']) == 0
cli_confirm(
config, 'Do you wish to continue?', ['Yes, proceed', 'No, exit']
)
== 0
)
if confirm:

View File

@ -155,7 +155,7 @@ async def run_session(
nonlocal reload_microagents, new_session_requested
while True:
next_message = await read_prompt_input(
agent_state, multiline=config.cli_multiline_input
config, agent_state, multiline=config.cli_multiline_input
)
if not next_message.strip():
@ -214,7 +214,7 @@ async def run_session(
)
return
confirmation_status = await read_confirmation_input()
confirmation_status = await read_confirmation_input(config)
if confirmation_status == 'yes' or confirmation_status == 'always':
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),

View File

@ -135,9 +135,10 @@ async def get_validated_input(
return value
def save_settings_confirmation() -> bool:
def save_settings_confirmation(config: OpenHandsConfig) -> bool:
return (
cli_confirm(
config,
'\nSave new settings? (They will take effect after restart)',
['Yes, save', 'No, discard'],
)
@ -173,6 +174,7 @@ async def modify_llm_settings_basic(
# Show verified providers plus "Select another provider" option
provider_choices = verified_providers + ['Select another provider']
provider_choice = cli_confirm(
config,
'(Step 1/3) Select LLM Provider:',
provider_choices,
)
@ -255,6 +257,7 @@ async def modify_llm_settings_basic(
)
change_model = (
cli_confirm(
config,
'Do you want to use a different model?',
[f'Use {default_model}', 'Select another model'],
)
@ -307,7 +310,7 @@ async def modify_llm_settings_basic(
# The try-except block above ensures we either have valid inputs or we've already returned
# No need to check for None values here
save_settings = save_settings_confirmation()
save_settings = save_settings_confirmation(config)
if not save_settings:
return
@ -382,6 +385,7 @@ async def modify_llm_settings_advanced(
enable_confirmation_mode = (
cli_confirm(
config,
question='(Step 5/6) Confirmation Mode (CTRL-c to cancel):',
choices=['Enable', 'Disable'],
)
@ -390,6 +394,7 @@ async def modify_llm_settings_advanced(
enable_memory_condensation = (
cli_confirm(
config,
question='(Step 6/6) Memory Condensation (CTRL-c to cancel):',
choices=['Enable', 'Disable'],
)
@ -406,7 +411,7 @@ async def modify_llm_settings_advanced(
# The try-except block above ensures we either have valid inputs or we've already returned
# No need to check for None values here
save_settings = save_settings_confirmation()
save_settings = save_settings_confirmation(config)
if not save_settings:
return

View File

@ -520,13 +520,16 @@ class CommandCompleter(Completer):
)
def create_prompt_session() -> PromptSession[str]:
return PromptSession(style=DEFAULT_STYLE)
def create_prompt_session(config: OpenHandsConfig) -> PromptSession[str]:
"""Creates a prompt session with VI mode enabled if specified in the config."""
return PromptSession(style=DEFAULT_STYLE, vi_mode=config.cli.vi_mode)
async def read_prompt_input(agent_state: str, multiline: bool = False) -> str:
async def read_prompt_input(
config: OpenHandsConfig, agent_state: str, multiline: bool = False
) -> str:
try:
prompt_session = create_prompt_session()
prompt_session = create_prompt_session(config)
prompt_session.completer = (
CommandCompleter(agent_state) if not multiline else None
)
@ -558,9 +561,9 @@ async def read_prompt_input(agent_state: str, multiline: bool = False) -> str:
return '/exit'
async def read_confirmation_input() -> str:
async def read_confirmation_input(config: OpenHandsConfig) -> str:
try:
prompt_session = create_prompt_session()
prompt_session = create_prompt_session(config)
with patch_stdout():
print_formatted_text('')
@ -606,7 +609,9 @@ async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) ->
def cli_confirm(
question: str = 'Are you sure?', choices: list[str] | None = None
config: OpenHandsConfig,
question: str = 'Are you sure?',
choices: list[str] | None = None,
) -> int:
"""Display a confirmation prompt with the given question and choices.
@ -630,15 +635,27 @@ def cli_confirm(
kb = KeyBindings()
@kb.add('up')
def _(event: KeyPressEvent) -> None:
def _handle_up(event: KeyPressEvent) -> None:
selected[0] = (selected[0] - 1) % len(choices)
if config.cli.vi_mode:
@kb.add('k')
def _handle_k(event: KeyPressEvent) -> None:
selected[0] = (selected[0] - 1) % len(choices)
@kb.add('down')
def _(event: KeyPressEvent) -> None:
def _handle_down(event: KeyPressEvent) -> None:
selected[0] = (selected[0] + 1) % len(choices)
if config.cli.vi_mode:
@kb.add('j')
def _handle_j(event: KeyPressEvent) -> None:
selected[0] = (selected[0] + 1) % len(choices)
@kb.add('enter')
def _(event: KeyPressEvent) -> None:
def _handle_enter(event: KeyPressEvent) -> None:
event.app.exit(result=selected[0])
style = Style.from_dict({'selected': COLOR_GOLD, 'unselected': ''})

View File

@ -1,4 +1,5 @@
from openhands.core.config.agent_config import AgentConfig
from openhands.core.config.cli_config import CLIConfig
from openhands.core.config.config_utils import (
OH_DEFAULT_AGENT,
OH_MAX_ITERATIONS,
@ -26,6 +27,7 @@ __all__ = [
'OH_DEFAULT_AGENT',
'OH_MAX_ITERATIONS',
'AgentConfig',
'CLIConfig',
'OpenHandsConfig',
'MCPConfig',
'LLMConfig',

View File

@ -0,0 +1,9 @@
from pydantic import BaseModel, Field
class CLIConfig(BaseModel):
"""Configuration for CLI-specific settings."""
vi_mode: bool = Field(default=False)
model_config = {'extra': 'forbid'}

View File

@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, SecretStr
from openhands.core import logger
from openhands.core.config.agent_config import AgentConfig
from openhands.core.config.cli_config import CLIConfig
from openhands.core.config.config_utils import (
OH_DEFAULT_AGENT,
OH_MAX_ITERATIONS,
@ -109,6 +110,7 @@ class OpenHandsConfig(BaseModel):
mcp_host: str = Field(default=f'localhost:{os.getenv("port", 3000)}')
mcp: MCPConfig = Field(default_factory=MCPConfig)
kubernetes: KubernetesConfig = Field(default_factory=KubernetesConfig)
cli: CLIConfig = Field(default_factory=CLIConfig)
defaults_dict: ClassVar[dict] = {}

View File

@ -50,6 +50,7 @@ class TestHandleCommands:
)
mock_handle_exit.assert_called_once_with(
mock_dependencies['config'],
mock_dependencies['event_stream'],
mock_dependencies['usage_metrics'],
mock_dependencies['sid'],
@ -116,6 +117,7 @@ class TestHandleCommands:
)
mock_handle_new.assert_called_once_with(
mock_dependencies['config'],
mock_dependencies['event_stream'],
mock_dependencies['usage_metrics'],
mock_dependencies['sid'],
@ -166,6 +168,7 @@ class TestHandleExitCommand:
@patch('openhands.cli.commands.cli_confirm')
@patch('openhands.cli.commands.display_shutdown_message')
def test_exit_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
config = MagicMock(spec=OpenHandsConfig)
event_stream = MagicMock(spec=EventStream)
usage_metrics = MagicMock(spec=UsageMetrics)
sid = 'test-session-id'
@ -174,7 +177,7 @@ class TestHandleExitCommand:
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
# Call the function under test
result = handle_exit_command(event_stream, usage_metrics, sid)
result = handle_exit_command(config, event_stream, usage_metrics, sid)
# Verify correct behavior
mock_cli_confirm.assert_called_once()
@ -191,6 +194,7 @@ class TestHandleExitCommand:
@patch('openhands.cli.commands.cli_confirm')
@patch('openhands.cli.commands.display_shutdown_message')
def test_exit_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
config = MagicMock(spec=OpenHandsConfig)
event_stream = MagicMock(spec=EventStream)
usage_metrics = MagicMock(spec=UsageMetrics)
sid = 'test-session-id'
@ -199,7 +203,7 @@ class TestHandleExitCommand:
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
# Call the function under test
result = handle_exit_command(event_stream, usage_metrics, sid)
result = handle_exit_command(config, event_stream, usage_metrics, sid)
# Verify correct behavior
mock_cli_confirm.assert_called_once()
@ -230,6 +234,7 @@ class TestHandleNewCommand:
@patch('openhands.cli.commands.cli_confirm')
@patch('openhands.cli.commands.display_shutdown_message')
def test_new_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
config = MagicMock(spec=OpenHandsConfig)
event_stream = MagicMock(spec=EventStream)
usage_metrics = MagicMock(spec=UsageMetrics)
sid = 'test-session-id'
@ -238,7 +243,9 @@ class TestHandleNewCommand:
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
# Call the function under test
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
close_repl, new_session = handle_new_command(
config, event_stream, usage_metrics, sid
)
# Verify correct behavior
mock_cli_confirm.assert_called_once()
@ -256,6 +263,7 @@ class TestHandleNewCommand:
@patch('openhands.cli.commands.cli_confirm')
@patch('openhands.cli.commands.display_shutdown_message')
def test_new_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
config = MagicMock(spec=OpenHandsConfig)
event_stream = MagicMock(spec=EventStream)
usage_metrics = MagicMock(spec=UsageMetrics)
sid = 'test-session-id'
@ -264,7 +272,9 @@ class TestHandleNewCommand:
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
# Call the function under test
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
close_repl, new_session = handle_new_command(
config, event_stream, usage_metrics, sid
)
# Verify correct behavior
mock_cli_confirm.assert_called_once()
@ -292,7 +302,7 @@ class TestHandleInitCommand:
)
# Verify correct behavior
mock_init_repository.assert_called_once_with(current_dir)
mock_init_repository.assert_called_once_with(config, current_dir)
event_stream.add_event.assert_called_once()
# Check event is the right type
args, kwargs = event_stream.add_event.call_args
@ -320,7 +330,7 @@ class TestHandleInitCommand:
)
# Verify correct behavior
mock_init_repository.assert_called_once_with(current_dir)
mock_init_repository.assert_called_once_with(config, current_dir)
event_stream.add_event.assert_not_called()
assert close_repl is False

View File

@ -281,7 +281,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'y'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
@pytest.mark.asyncio
@ -291,7 +291,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'yes'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
@pytest.mark.asyncio
@ -301,7 +301,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'n'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@ -311,7 +311,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'no'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@ -321,7 +321,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'a'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'always'
@pytest.mark.asyncio
@ -331,7 +331,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'always'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'always'
@pytest.mark.asyncio
@ -341,7 +341,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = 'invalid'
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@ -351,7 +351,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = ''
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@ -361,7 +361,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.return_value = None
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@ -373,7 +373,7 @@ class TestReadConfirmationInput:
mock_session.prompt_async.side_effect = KeyboardInterrupt
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
@pytest.mark.asyncio
@ -383,5 +383,5 @@ class TestReadConfirmationInput:
mock_session.prompt_async.side_effect = EOFError
mock_create_session.return_value = mock_session
result = await read_confirmation_input()
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'

View File

@ -0,0 +1,89 @@
import os
from unittest.mock import ANY, MagicMock, patch
from openhands.core.config import CLIConfig, OpenHandsConfig
class TestCliViMode:
"""Test the VI mode feature."""
@patch('openhands.cli.tui.PromptSession')
def test_create_prompt_session_vi_mode_enabled(self, mock_prompt_session):
"""Test that vi_mode can be enabled."""
from openhands.cli.tui import create_prompt_session
config = OpenHandsConfig(cli=CLIConfig(vi_mode=True))
create_prompt_session(config)
mock_prompt_session.assert_called_with(
style=ANY,
vi_mode=True,
)
@patch('openhands.cli.tui.PromptSession')
def test_create_prompt_session_vi_mode_disabled(self, mock_prompt_session):
"""Test that vi_mode is disabled by default."""
from openhands.cli.tui import create_prompt_session
config = OpenHandsConfig(cli=CLIConfig(vi_mode=False))
create_prompt_session(config)
mock_prompt_session.assert_called_with(
style=ANY,
vi_mode=False,
)
@patch('openhands.cli.tui.Application')
def test_cli_confirm_vi_keybindings_are_added(self, mock_app_class):
"""Test that vi keybindings are added to the KeyBindings object."""
from openhands.cli.tui import cli_confirm
config = OpenHandsConfig(cli=CLIConfig(vi_mode=True))
with patch('openhands.cli.tui.KeyBindings', MagicMock()) as mock_key_bindings:
cli_confirm(
config, 'Test question', choices=['Choice 1', 'Choice 2', 'Choice 3']
)
# here we are checking if the key bindings are being created
assert mock_key_bindings.call_count == 1
# then we check that the key bindings are being added
mock_kb_instance = mock_key_bindings.return_value
assert mock_kb_instance.add.call_count > 0
@patch('openhands.cli.tui.Application')
def test_cli_confirm_vi_keybindings_are_not_added(self, mock_app_class):
"""Test that vi keybindings are not added when vi_mode is False."""
from openhands.cli.tui import cli_confirm
config = OpenHandsConfig(cli=CLIConfig(vi_mode=False))
with patch('openhands.cli.tui.KeyBindings', MagicMock()) as mock_key_bindings:
cli_confirm(
config, 'Test question', choices=['Choice 1', 'Choice 2', 'Choice 3']
)
# here we are checking if the key bindings are being created
assert mock_key_bindings.call_count == 1
# then we check that the key bindings are being added
mock_kb_instance = mock_key_bindings.return_value
# and here we check that the vi key bindings are not being added
for call in mock_kb_instance.add.call_args_list:
assert call[0][0] not in ('j', 'k')
@patch.dict(os.environ, {}, clear=True)
def test_vi_mode_disabled_by_default(self):
"""Test that vi_mode is disabled by default when no env var is set."""
from openhands.core.config.utils import load_from_env
config = OpenHandsConfig()
load_from_env(config, os.environ)
assert config.cli.vi_mode is False, 'vi_mode should be False by default'
@patch.dict(os.environ, {'CLI_VI_MODE': 'True'})
def test_vi_mode_enabled_from_env(self):
"""Test that vi_mode can be enabled from an environment variable."""
from openhands.core.config.utils import load_from_env
config = OpenHandsConfig()
load_from_env(config, os.environ)
assert config.cli.vi_mode is True, (
'vi_mode should be True when CLI_VI_MODE is set'
)