Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 131 additions & 5 deletions openhands_cli/agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
Provides a conversation interface with an AI agent using OpenHands patterns.
"""

from __future__ import annotations

import logging
import os
import sys
import traceback
from typing import Any

# Ensure we use the agent-sdk openhands package, not the main OpenHands package
# Remove the main OpenHands code path if it exists
Expand All @@ -19,6 +22,11 @@
from prompt_toolkit.shortcuts import clear
from pydantic import SecretStr

from openhands_cli.confirmation import (
confirmation_mode,
display_action_info,
read_confirmation_input,
)
from openhands_cli.tui import CommandCompleter, display_banner, display_help

try:
Expand All @@ -44,6 +52,102 @@
logger = logging.getLogger(__name__)


async def confirm_action_if_needed(
action_type: str, action_data: dict[str, Any]
) -> bool:
"""Check if an action needs confirmation and get user approval if needed.

Returns True if the action should proceed, False if it should be cancelled.
"""
# Check if confirmation is needed
if not confirmation_mode.should_confirm():
return True

# Display action information
display_action_info(action_type, action_data)

# Get user confirmation
confirmation_result = await read_confirmation_input()

# Handle the user's choice
if confirmation_result == "yes":
return True
elif confirmation_result == "no":
print_formatted_text(
HTML(
"<yellow>Action cancelled. Please provide alternative instructions.</yellow>"
)
)
return False
elif confirmation_result == "always":
confirmation_mode.set_enabled(False)
print_formatted_text(
HTML(
"<yellow>Confirmation mode disabled. All actions will proceed automatically.</yellow>"
)
)
return True

return False


def display_confirmation_help() -> None:
"""Display help for confirmation mode commands."""
print_formatted_text(HTML("<gold>Confirmation Mode Commands:</gold>"))
print_formatted_text(
HTML(" <green>/confirm status</green> - Show current confirmation mode")
)
print_formatted_text(
HTML(
" <green>/confirm on</green> - Enable confirmation before executing commands"
)
)
print_formatted_text(
HTML(
" <green>/confirm off</green> - Disable confirmation (commands execute automatically)"
)
)
print_formatted_text("")


def handle_confirmation_command(command: str) -> None:
"""Handle confirmation mode commands."""
parts = command.split()
if len(parts) < 2:
display_confirmation_help()
return

subcommand = parts[1].lower()

if subcommand == "status":
if confirmation_mode.enabled:
print_formatted_text(HTML("<yellow>Confirmation Mode: Enabled</yellow>"))
else:
print_formatted_text(HTML("<yellow>Confirmation Mode: Disabled</yellow>"))

elif subcommand == "on":
confirmation_mode.set_enabled(True)
print_formatted_text(
HTML(
"<green>✓ Confirmation mode enabled (will ask before executing commands)</green>"
)
)

elif subcommand == "off":
confirmation_mode.set_enabled(False)
print_formatted_text(
HTML(
"<yellow>⚠️ Confirmation mode disabled (commands will execute automatically)</yellow>"
)
)

else:
print_formatted_text(
HTML(f"<red>Unknown confirmation command: {subcommand}</red>")
)
display_confirmation_help()


def setup_agent() -> tuple[LLM | None, CodeActAgent | None, Conversation | None]:
"""Setup the agent with environment variables."""
try:
Expand Down Expand Up @@ -71,14 +175,16 @@ def setup_agent() -> tuple[LLM | None, CodeActAgent | None, Conversation | None]

llm = LLM(config=llm_config)

# Setup tools
# Setup tools with confirmation wrapper
cwd = os.getcwd()
bash = BashExecutor(working_dir=cwd)
file_editor = FileEditorExecutor()
tools: list[Tool] = [
execute_bash_tool.set_executor(executor=bash),
str_replace_editor_tool.set_executor(executor=file_editor),
]

# Create confirmation-aware tool wrappers
bash_tool = execute_bash_tool.set_executor(executor=bash)
editor_tool = str_replace_editor_tool.set_executor(executor=file_editor)

tools: list[Tool] = [bash_tool, editor_tool]

# Create agent
agent = CodeActAgent(llm=llm, tools=tools)
Expand Down Expand Up @@ -110,6 +216,11 @@ def display_welcome(session_id: str = "chat") -> None:
"<green>What do you want to build? <grey>Type /help for help</grey></green>"
)
)
print_formatted_text(
HTML(
"<yellow>🔒 Confirmation mode is enabled. Use /confirm to manage settings.</yellow>"
)
)
print()


Expand Down Expand Up @@ -156,6 +267,15 @@ def run_agent_chat() -> None:
elif command == "/status":
print_formatted_text(HTML(f"<grey>Session ID: {session_id}</grey>"))
print_formatted_text(HTML("<grey>Status: Active</grey>"))
# Display confirmation mode status
if confirmation_mode.enabled:
print_formatted_text(
HTML("<grey>Confirmation Mode: Enabled</grey>")
)
else:
print_formatted_text(
HTML("<grey>Confirmation Mode: Disabled</grey>")
)
continue
elif command == "/new":
print_formatted_text(
Expand All @@ -164,6 +284,12 @@ def run_agent_chat() -> None:
session_id = str(uuid.uuid4())[:8]
display_welcome(session_id)
continue
elif command == "/confirm":
display_confirmation_help()
continue
elif command.startswith("/confirm "):
handle_confirmation_command(command)
continue

# Send message to agent
print_formatted_text(HTML("<green>Agent: </green>"), end="")
Expand Down
155 changes: 155 additions & 0 deletions openhands_cli/confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Confirmation mode functionality for OpenHands CLI.
Provides user confirmation prompts before executing commands.
"""

from __future__ import annotations

import asyncio
from typing import Any, cast

from prompt_toolkit import Application, print_formatted_text
from prompt_toolkit.formatted_text import HTML
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 openhands_cli.pt_style import get_cli_style


class ConfirmationMode:
"""Manages confirmation mode settings and behavior."""

def __init__(self) -> None:
self.enabled = True # Whether confirmation is enabled

def should_confirm(self) -> bool:
"""Determine if actions should require confirmation."""
return self.enabled

def set_enabled(self, enabled: bool) -> None:
"""Enable or disable confirmation mode."""
self.enabled = enabled


class UserCancelledError(Exception):
"""Raised when the user cancels an operation."""

pass


def cli_confirm(
question: str = "Are you sure?",
choices: list[str] | None = None,
initial_selection: int = 0,
) -> 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

def get_choice_text() -> list:
return [
("class:question", f"{question}\n\n"),
] + [
(
"class:selected" if i == selected[0] else "class:unselected",
f"{'>' if i == selected[0] else ' '} {choice}\n",
)
for i, choice in enumerate(choices)
]

kb = KeyBindings()

@kb.add("up")
def _handle_up(event: KeyPressEvent) -> None:
selected[0] = (selected[0] - 1) % len(choices)

@kb.add("k") # Vi-style up
def _handle_k(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("j") # Vi-style down
def _handle_j(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])

@kb.add("escape")
def _handle_escape(event: KeyPressEvent) -> None:
event.app.exit(exception=UserCancelledError())

@kb.add("c-c")
def _handle_ctrl_c(event: KeyPressEvent) -> None:
event.app.exit(exception=UserCancelledError())

# Create layout
content_window = Window(
FormattedTextControl(get_choice_text),
always_hide_cursor=True,
height=Dimension(max=8), # Limit height to prevent screen takeover
)

layout = Layout(HSplit([content_window]))

app: Application[int] = Application(
layout=layout,
key_bindings=kb,
style=get_cli_style(),
full_screen=False,
)

return cast(int, app.run())


async def read_confirmation_input() -> str:
"""Read user confirmation input."""
try:
question = "The agent wants to execute a command. Do you want to proceed?"
choices = [
"Yes, proceed",
"No (and allow to enter instructions)",
"Always proceed (don't ask again)",
]
choice_mapping = {0: "yes", 1: "no", 2: "always"}

# Run the confirmation dialog in a thread to keep the event loop responsive
index = await asyncio.to_thread(cli_confirm, question, choices, 0)

return choice_mapping.get(index, "no")

except (KeyboardInterrupt, EOFError, UserCancelledError):
return "no"


def display_action_info(action_type: str, action_data: dict[str, Any]) -> None:
"""Display information about the action to be executed."""
if action_type == "execute_bash":
command = action_data.get("command", "Unknown command")
print_formatted_text(HTML(f"<yellow>Command to execute: {command}</yellow>"))
elif action_type == "str_replace_editor":
command = action_data.get("command", "unknown")
path = action_data.get("path", "unknown file")
print_formatted_text(
HTML(f"<yellow>File operation: {command} on {path}</yellow>")
)
else:
print_formatted_text(HTML(f"<yellow>Action: {action_type}</yellow>"))


# Global confirmation mode instance
confirmation_mode = ConfirmationMode()
18 changes: 18 additions & 0 deletions openhands_cli/tui.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from collections.abc import Generator

from prompt_toolkit import print_formatted_text
Expand All @@ -17,6 +19,7 @@
"/clear": "Clear the screen",
"/status": "Display conversation details",
"/new": "Create a new conversation",
"/confirm": "Manage confirmation mode settings",
}


Expand Down Expand Up @@ -68,6 +71,21 @@ def display_help() -> None:
for command, description in COMMANDS.items():
print_formatted_text(HTML(f" <white>{command}</white> - {description}"))

print_formatted_text("")
print_formatted_text(HTML("<gold>🔒 Confirmation Mode</gold>"))
print_formatted_text(
" OpenHands CLI includes confirmation mode to ask before executing commands."
)
print_formatted_text(
" Use <white>/confirm</white> to manage confirmation settings:"
)
print_formatted_text(" • <white>/confirm status</white> - Show current mode")
print_formatted_text(
" • <white>/confirm on</white> - Enable confirmation before executing commands"
)
print_formatted_text(
" • <white>/confirm off</white> - Disable confirmation (commands execute automatically)"
)
print_formatted_text("")
print_formatted_text(HTML("<grey>Tips:</grey>"))
print_formatted_text(" • Type / and press Tab to see command suggestions")
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ scripts.openhands-cli = "openhands_cli.simple_main:main"
dev = [
"pre-commit>=4.3",
"pyinstaller>=6.15",
"pytest-asyncio>=1.1",
]

[tool.poetry]
Expand Down
Loading