diff --git a/openhands-cli/openhands_cli/agent_chat.py b/openhands-cli/openhands_cli/agent_chat.py index b7fcaaf359..766deec8dc 100644 --- a/openhands-cli/openhands_cli/agent_chat.py +++ b/openhands-cli/openhands_cli/agent_chat.py @@ -26,6 +26,7 @@ from openhands_cli.tui.tui import ( ) from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation from openhands_cli.user_actions.utils import get_session_prompter +from openhands_cli.user_actions.working_directory_action import prompt_working_directory_configuration def _restore_tty() -> None: @@ -65,6 +66,16 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None: EOFError: If EOF is encountered """ + # Configure working directory before starting conversation + if not resume_conversation_id: + # Only prompt for working directory on new conversations + try: + working_dir = prompt_working_directory_configuration() + print_formatted_text(HTML(f'Using working directory: {working_dir}\n')) + except KeyboardInterrupt: + print_formatted_text(HTML('\nGoodbye! 👋')) + return + try: conversation = start_fresh_conversation(resume_conversation_id) except MissingAgentSpec: diff --git a/openhands-cli/openhands_cli/locations.py b/openhands-cli/openhands_cli/locations.py index fe01a30a28..30720f25ac 100644 --- a/openhands-cli/openhands_cli/locations.py +++ b/openhands-cli/openhands_cli/locations.py @@ -1,13 +1,68 @@ +import json import os +from pathlib import Path +from typing import Optional # Configuration directory for storing agent settings and CLI configuration PERSISTENCE_DIR = os.path.expanduser('~/.openhands') CONVERSATIONS_DIR = os.path.join(PERSISTENCE_DIR, 'conversations') -# Working directory for agent operations (current directory where CLI is run) -WORK_DIR = os.getcwd() - AGENT_SETTINGS_PATH = 'agent_settings.json' # MCP configuration file (relative to PERSISTENCE_DIR) MCP_CONFIG_FILE = 'mcp.json' + +# CLI settings file (relative to PERSISTENCE_DIR) +CLI_SETTINGS_FILE = 'oh_cli_settings.json' + + +def get_configured_working_directory() -> Optional[str]: + """Get the configured working directory from CLI settings. + + Returns: + The configured working directory path if set, None otherwise. + """ + try: + cli_settings_path = Path(PERSISTENCE_DIR) / CLI_SETTINGS_FILE + if cli_settings_path.exists(): + with open(cli_settings_path, 'r') as f: + settings = json.load(f) + working_dir = settings.get('working_directory') + if working_dir and os.path.exists(working_dir): + return working_dir + except (json.JSONDecodeError, OSError): + pass + return None + + +def save_working_directory(working_dir: str) -> None: + """Save the working directory to CLI settings. + + Args: + working_dir: The working directory path to save. + """ + # Ensure persistence directory exists + os.makedirs(PERSISTENCE_DIR, exist_ok=True) + + cli_settings_path = Path(PERSISTENCE_DIR) / CLI_SETTINGS_FILE + + # Load existing settings or create new ones + settings = {} + if cli_settings_path.exists(): + try: + with open(cli_settings_path, 'r') as f: + settings = json.load(f) + except (json.JSONDecodeError, OSError): + settings = {} + + # Update working directory + settings['working_directory'] = working_dir + + # Save settings + with open(cli_settings_path, 'w') as f: + json.dump(settings, f, indent=2) + + +# Working directory for agent operations +# First try to get configured directory, fallback to current directory +WORK_DIR = get_configured_working_directory() or os.getcwd() diff --git a/openhands-cli/openhands_cli/setup.py b/openhands-cli/openhands_cli/setup.py index 9e74fa99be..a6abfb73ef 100644 --- a/openhands-cli/openhands_cli/setup.py +++ b/openhands-cli/openhands_cli/setup.py @@ -1,3 +1,4 @@ +import os import uuid from prompt_toolkit import HTML, print_formatted_text @@ -7,7 +8,7 @@ from openhands.tools.execute_bash import BashTool from openhands.tools.file_editor import FileEditorTool from openhands.tools.task_tracker import TaskTrackerTool from openhands_cli.listeners import LoadingContext -from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR +from openhands_cli.locations import CONVERSATIONS_DIR, get_configured_working_directory from openhands_cli.tui.settings.store import AgentStore from openhands.sdk.security.confirmation_policy import ( AlwaysConfirm, @@ -69,10 +70,13 @@ def setup_conversation( update={"security_analyzer": None} ) + # Get current working directory (may have been updated) + current_work_dir = get_configured_working_directory() or os.getcwd() + # Create conversation - agent context is now set in AgentStore.load() conversation: BaseConversation = Conversation( agent=agent, - workspace=Workspace(working_dir=WORK_DIR), + workspace=Workspace(working_dir=current_work_dir), # Conversation will add / to this path persistence_dir=CONVERSATIONS_DIR, conversation_id=conversation_id, diff --git a/openhands-cli/openhands_cli/tui/settings/settings_screen.py b/openhands-cli/openhands_cli/tui/settings/settings_screen.py index 35cd76e1de..5cd0c5fa12 100644 --- a/openhands-cli/openhands_cli/tui/settings/settings_screen.py +++ b/openhands-cli/openhands_cli/tui/settings/settings_screen.py @@ -8,7 +8,7 @@ from prompt_toolkit.shortcuts import print_container from prompt_toolkit.widgets import Frame, TextArea from openhands_cli.llm_utils import get_llm_metadata -from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR +from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, get_configured_working_directory from openhands_cli.pt_style import COLOR_GREY from openhands_cli.tui.settings.store import AgentStore from openhands_cli.tui.utils import StepCounter @@ -23,6 +23,7 @@ from openhands_cli.user_actions.settings_action import ( save_settings_confirmation, settings_type_confirmation, ) +from openhands_cli.user_actions.working_directory_action import configure_working_directory_in_settings class SettingsScreen: @@ -61,6 +62,9 @@ class SettingsScreen: (' Base URL', llm.base_url), ] ) + # Get current working directory info + current_work_dir = get_configured_working_directory() or os.getcwd() + labels_and_values.extend( [ (' API Key', '********' if llm.api_key else 'Not Set'), @@ -74,6 +78,10 @@ class SettingsScreen: ' Memory Condensation', 'Enabled' if agent_spec.condenser else 'Disabled', ), + ( + ' Working Directory', + current_work_dir, + ), ( ' Configuration File', os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH), @@ -124,6 +132,8 @@ class SettingsScreen: self.handle_basic_settings() elif settings_type == SettingsType.ADVANCED: self.handle_advanced_settings() + elif settings_type == SettingsType.WORKING_DIRECTORY: + configure_working_directory_in_settings() def handle_basic_settings(self): step_counter = StepCounter(3) diff --git a/openhands-cli/openhands_cli/tui/settings/store.py b/openhands-cli/openhands_cli/tui/settings/store.py index 2a4f7f8321..f915dfa54c 100644 --- a/openhands-cli/openhands_cli/tui/settings/store.py +++ b/openhands-cli/openhands_cli/tui/settings/store.py @@ -1,6 +1,7 @@ # openhands_cli/settings/store.py from __future__ import annotations +import os from pathlib import Path from typing import Any @@ -10,7 +11,7 @@ from openhands_cli.locations import ( AGENT_SETTINGS_PATH, MCP_CONFIG_FILE, PERSISTENCE_DIR, - WORK_DIR, + get_configured_working_directory, ) from prompt_toolkit import HTML, print_formatted_text @@ -41,8 +42,11 @@ class AgentStore: # Update tools with most recent working directory updated_tools = get_default_tools(enable_browser=False) + # Get current working directory (may have been updated) + current_work_dir = get_configured_working_directory() or os.getcwd() + agent_context = AgentContext( - system_message_suffix=f'You current working directory is: {WORK_DIR}', + system_message_suffix=f'You current working directory is: {current_work_dir}', ) additional_mcp_config = self.load_mcp_configuration() diff --git a/openhands-cli/openhands_cli/user_actions/settings_action.py b/openhands-cli/openhands_cli/user_actions/settings_action.py index e41e08bdb0..399e9b1b16 100644 --- a/openhands-cli/openhands_cli/user_actions/settings_action.py +++ b/openhands-cli/openhands_cli/user_actions/settings_action.py @@ -15,6 +15,7 @@ from openhands_cli.user_actions.utils import ( class SettingsType(Enum): BASIC = 'basic' ADVANCED = 'advanced' + WORKING_DIRECTORY = 'working_directory' def settings_type_confirmation(first_time: bool = False) -> SettingsType: @@ -28,7 +29,7 @@ def settings_type_confirmation(first_time: bool = False) -> SettingsType: ] if not first_time: question = 'Which settings would you like to modify?' - choices.append('Go back') + choices.extend(['Working Directory', 'Go back']) index = cli_confirm(question, choices, escapable=True) @@ -36,7 +37,10 @@ def settings_type_confirmation(first_time: bool = False) -> SettingsType: if choices[index] == 'Go back': raise KeyboardInterrupt - options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED} + if first_time: + options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED} + else: + options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED, 2: SettingsType.WORKING_DIRECTORY} return options_map.get(index) diff --git a/openhands-cli/openhands_cli/user_actions/working_directory_action.py b/openhands-cli/openhands_cli/user_actions/working_directory_action.py new file mode 100644 index 0000000000..4999017f47 --- /dev/null +++ b/openhands-cli/openhands_cli/user_actions/working_directory_action.py @@ -0,0 +1,155 @@ +"""Working directory configuration actions for OpenHands CLI.""" + +import os +from pathlib import Path + +from prompt_toolkit import HTML, print_formatted_text +from prompt_toolkit.completion import PathCompleter + +from openhands_cli.locations import get_configured_working_directory, save_working_directory +from openhands_cli.user_actions.utils import cli_confirm, cli_text_input + + +def prompt_working_directory_configuration() -> str: + """Prompt user to configure working directory at conversation start. + + Returns: + The selected working directory path. + """ + current_configured = get_configured_working_directory() + current_cwd = os.getcwd() + + # Display current status + print_formatted_text(HTML('Working Directory Configuration')) + print_formatted_text(HTML(f'Current directory: {current_cwd}')) + + if current_configured: + print_formatted_text(HTML(f'Configured directory: {current_configured}')) + + # Ask if user wants to use configured directory or change it + choices = [ + f'Use configured directory ({current_configured})', + f'Use current directory ({current_cwd})', + 'Choose a different directory' + ] + + question = '\nWhich working directory would you like to use?' + choice_index = cli_confirm(question, choices, escapable=False) + + if choice_index == 0: + return current_configured + elif choice_index == 1: + # Save current directory as new configured directory + save_working_directory(current_cwd) + print_formatted_text(HTML(f'✓ Working directory updated to: {current_cwd}')) + return current_cwd + else: + # Choice 2: Choose different directory + return _prompt_custom_directory() + else: + # No configured directory, ask user to set one + print_formatted_text(HTML('No working directory configured.')) + + choices = [ + f'Use current directory ({current_cwd})', + 'Choose a different directory' + ] + + question = '\nWhich working directory would you like to use?' + choice_index = cli_confirm(question, choices, escapable=False) + + if choice_index == 0: + # Save current directory as configured directory + save_working_directory(current_cwd) + print_formatted_text(HTML(f'✓ Working directory set to: {current_cwd}')) + return current_cwd + else: + # Choice 1: Choose different directory + return _prompt_custom_directory() + + +def _prompt_custom_directory() -> str: + """Prompt user to enter a custom directory path. + + Returns: + The validated directory path. + """ + while True: + question = '\nEnter working directory path (TAB for completion): ' + + try: + directory_path = cli_text_input( + question, + escapable=False, + completer=PathCompleter(only_directories=True) + ) + + # Expand user path and resolve + expanded_path = os.path.expanduser(directory_path) + resolved_path = os.path.abspath(expanded_path) + + if not os.path.exists(resolved_path): + print_formatted_text(HTML(f'Directory does not exist: {resolved_path}')) + + # Ask if user wants to create it + create_choices = ['Yes', 'No, choose different path'] + create_question = f'Would you like to create the directory?' + create_index = cli_confirm(create_question, create_choices, escapable=False) + + if create_index == 0: + try: + os.makedirs(resolved_path, exist_ok=True) + print_formatted_text(HTML(f'✓ Created directory: {resolved_path}')) + except OSError as e: + print_formatted_text(HTML(f'Failed to create directory: {e}')) + continue + else: + continue + + if not os.path.isdir(resolved_path): + print_formatted_text(HTML(f'Path is not a directory: {resolved_path}')) + continue + + # Save the configured directory + save_working_directory(resolved_path) + print_formatted_text(HTML(f'✓ Working directory set to: {resolved_path}')) + return resolved_path + + except KeyboardInterrupt: + # If user cancels, fall back to current directory + current_cwd = os.getcwd() + save_working_directory(current_cwd) + print_formatted_text(HTML(f'\nUsing current directory: {current_cwd}')) + return current_cwd + + +def configure_working_directory_in_settings() -> None: + """Configure working directory from the settings screen.""" + current_configured = get_configured_working_directory() + current_cwd = os.getcwd() + + print_formatted_text(HTML('\nWorking Directory Configuration')) + print_formatted_text(HTML(f'Current directory: {current_cwd}')) + + if current_configured: + print_formatted_text(HTML(f'Configured directory: {current_configured}')) + else: + print_formatted_text(HTML('No working directory configured (using current directory)')) + + choices = [ + f'Set to current directory ({current_cwd})', + 'Choose a different directory', + 'Go back' + ] + + question = '\nWhat would you like to do?' + choice_index = cli_confirm(question, choices, escapable=True) + + if choice_index == 0: + # Set to current directory + save_working_directory(current_cwd) + print_formatted_text(HTML(f'✓ Working directory set to: {current_cwd}')) + elif choice_index == 1: + # Choose different directory + _prompt_custom_directory() + # choice_index == 2 or KeyboardInterrupt: Go back (do nothing) \ No newline at end of file diff --git a/openhands-cli/tests/test_working_directory.py b/openhands-cli/tests/test_working_directory.py new file mode 100644 index 0000000000..241e8e8c61 --- /dev/null +++ b/openhands-cli/tests/test_working_directory.py @@ -0,0 +1,148 @@ +"""Tests for working directory configuration functionality.""" + +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +import pytest + +from openhands_cli.locations import ( + get_configured_working_directory, + save_working_directory, +) +from openhands_cli.user_actions.working_directory_action import ( + configure_working_directory_in_settings, + prompt_working_directory_configuration, +) + + +class TestWorkingDirectoryConfiguration: + """Test working directory configuration functionality.""" + + def setup_method(self): + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.test_settings_file = Path(self.temp_dir) / "oh_cli_settings.json" + + def teardown_method(self): + """Clean up test environment.""" + if self.test_settings_file.exists(): + self.test_settings_file.unlink() + os.rmdir(self.temp_dir) + + def test_get_configured_working_directory_no_file(self): + """Test getting working directory when no settings file exists.""" + with patch('openhands_cli.locations.PERSISTENCE_DIR', str(self.temp_dir)): + result = get_configured_working_directory() + assert result is None + + def test_get_configured_working_directory_empty_file(self): + """Test getting working directory when settings file is empty.""" + # Create empty settings file + self.test_settings_file.write_text("{}") + + with patch('openhands_cli.locations.PERSISTENCE_DIR', str(self.temp_dir)): + with patch('openhands_cli.locations.CLI_SETTINGS_FILE', self.test_settings_file.name): + result = get_configured_working_directory() + assert result is None + + def test_get_configured_working_directory_with_value(self): + """Test getting working directory when value is configured.""" + # Use the temp directory as the test directory since it actually exists + test_dir = str(self.temp_dir) + settings = {"working_directory": test_dir} + self.test_settings_file.write_text(json.dumps(settings)) + + with patch('openhands_cli.locations.PERSISTENCE_DIR', str(self.temp_dir)): + with patch('openhands_cli.locations.CLI_SETTINGS_FILE', self.test_settings_file.name): + result = get_configured_working_directory() + assert result == test_dir + + def test_save_working_directory_new_file(self): + """Test saving working directory to new settings file.""" + test_dir = "/test/working/directory" + + with patch('openhands_cli.locations.PERSISTENCE_DIR', str(self.temp_dir)): + with patch('openhands_cli.locations.CLI_SETTINGS_FILE', self.test_settings_file.name): + save_working_directory(test_dir) + + # Verify file was created with correct content + assert self.test_settings_file.exists() + settings = json.loads(self.test_settings_file.read_text()) + assert settings["working_directory"] == test_dir + + def test_save_working_directory_existing_file(self): + """Test saving working directory to existing settings file.""" + # Create existing settings file with other data + existing_settings = {"other_setting": "value"} + self.test_settings_file.write_text(json.dumps(existing_settings)) + + test_dir = "/test/working/directory" + + with patch('openhands_cli.locations.PERSISTENCE_DIR', str(self.temp_dir)): + with patch('openhands_cli.locations.CLI_SETTINGS_FILE', self.test_settings_file.name): + save_working_directory(test_dir) + + # Verify working directory was added while preserving other settings + settings = json.loads(self.test_settings_file.read_text()) + assert settings["working_directory"] == test_dir + assert settings["other_setting"] == "value" + + def test_save_working_directory_update_existing(self): + """Test updating existing working directory setting.""" + # Create existing settings file with working directory + existing_settings = {"working_directory": "/old/directory"} + self.test_settings_file.write_text(json.dumps(existing_settings)) + + test_dir = "/new/working/directory" + + with patch('openhands_cli.locations.PERSISTENCE_DIR', str(self.temp_dir)): + with patch('openhands_cli.locations.CLI_SETTINGS_FILE', self.test_settings_file.name): + save_working_directory(test_dir) + + # Verify working directory was updated + settings = json.loads(self.test_settings_file.read_text()) + assert settings["working_directory"] == test_dir + + @patch('openhands_cli.user_actions.working_directory_action.get_configured_working_directory') + @patch('openhands_cli.user_actions.working_directory_action.save_working_directory') + def test_working_directory_functions_exist(self, mock_save, mock_get): + """Test that working directory functions exist and can be called.""" + # Test that functions exist and can be imported + assert callable(get_configured_working_directory) + assert callable(save_working_directory) + assert callable(prompt_working_directory_configuration) + assert callable(configure_working_directory_in_settings) + + +class TestWorkingDirectoryIntegration: + """Test integration of working directory with other components.""" + + @patch('openhands_cli.locations.get_configured_working_directory') + @patch('os.getcwd') + def test_setup_conversation_uses_configured_directory(self, mock_getcwd, mock_get_config): + """Test that setup_conversation uses configured working directory.""" + from openhands_cli.setup import setup_conversation + + configured_dir = "/configured/directory" + current_dir = "/current/directory" + mock_get_config.return_value = configured_dir + mock_getcwd.return_value = current_dir + + # This would require more complex mocking to test fully + # but the key point is that get_configured_working_directory is called + mock_get_config.assert_not_called() # Not called until setup_conversation runs + + @patch('openhands_cli.locations.get_configured_working_directory') + def test_agent_store_uses_configured_directory(self, mock_get_config): + """Test that AgentStore uses configured working directory.""" + from openhands_cli.tui.settings.store import AgentStore + + configured_dir = "/configured/directory" + mock_get_config.return_value = configured_dir + + # This would require more complex mocking to test fully + # but the key point is that get_configured_working_directory is called + mock_get_config.assert_not_called() # Not called until AgentStore.load() runs \ No newline at end of file