diff --git a/cli.py b/cli.py index bdae39474..e295fd9cc 100755 --- a/cli.py +++ b/cli.py @@ -28,18 +28,13 @@ os.environ["HERMES_QUIET"] = "1" # Our own modules import yaml # prompt_toolkit for fixed input area TUI -from prompt_toolkit import PromptSession from prompt_toolkit.history import FileHistory from prompt_toolkit.styles import Style as PTStyle -from prompt_toolkit.formatted_text import HTML from prompt_toolkit.patch_stdout import patch_stdout -from prompt_toolkit.application import Application, get_app -from prompt_toolkit.buffer import Buffer +from prompt_toolkit.application import Application from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl -from prompt_toolkit.layout.processors import BeforeInput from prompt_toolkit.widgets import TextArea from prompt_toolkit.key_binding import KeyBindings -import asyncio import threading import queue @@ -498,6 +493,8 @@ COMMANDS = { "/clear": "Clear screen and reset conversation (fresh start)", "/history": "Show conversation history", "/reset": "Reset conversation only (keep screen)", + "/retry": "Retry the last message (resend to agent)", + "/undo": "Remove the last user/assistant exchange", "/save": "Save the current conversation", "/config": "Show current configuration", "/cron": "Manage scheduled tasks (list, add, remove)", @@ -508,7 +505,11 @@ COMMANDS = { def save_config_value(key_path: str, value: any) -> bool: """ - Save a value to cli-config.yaml at the specified key path. + Save a value to the active config file at the specified key path. + + Respects the same lookup order as load_cli_config(): + 1. ~/.hermes/config.yaml (user config - preferred, used if it exists) + 2. ./cli-config.yaml (project config - fallback) Args: key_path: Dot-separated path like "agent.system_prompt" @@ -517,9 +518,15 @@ def save_config_value(key_path: str, value: any) -> bool: Returns: True if successful, False otherwise """ - config_path = Path(__file__).parent / 'cli-config.yaml' + # Use the same precedence as load_cli_config: user config first, then project config + user_config_path = Path.home() / '.hermes' / 'config.yaml' + project_config_path = Path(__file__).parent / 'cli-config.yaml' + config_path = user_config_path if user_config_path.exists() else project_config_path try: + # Ensure parent directory exists (for ~/.hermes/config.yaml on first use) + config_path.parent.mkdir(parents=True, exist_ok=True) + # Load existing config if config_path.exists(): with open(config_path, 'r') as f: @@ -631,26 +638,8 @@ class HermesCLI: short_uuid = uuid.uuid4().hex[:6] self.session_id = f"{timestamp_str}_{short_uuid}" - # Setup prompt_toolkit session with history - self._setup_prompt_session() - - def _setup_prompt_session(self): - """Setup prompt_toolkit session with history and styling.""" - history_file = Path.home() / ".hermes_history" - - # Custom style for the prompt - self.prompt_style = PTStyle.from_dict({ - 'prompt': '#FFD700 bold', - 'input': '#FFF8DC', - }) - - # Create prompt session with file history - # Note: multiline disabled - Enter submits, use \ at end of line for continuation - self.prompt_session = PromptSession( - history=FileHistory(str(history_file)), - style=self.prompt_style, - enable_history_search=True, - ) + # History file for persistent input recall across sessions + self._history_file = Path.home() / ".hermes_history" def _init_agent(self) -> bool: """ @@ -931,6 +920,67 @@ class HermesCLI: except Exception as e: print(f"(x_x) Failed to save: {e}") + def retry_last(self): + """Retry the last user message by removing the last exchange and re-sending. + + Removes the last assistant response (and any tool-call messages) and + the last user message, then re-sends that user message to the agent. + Returns the message to re-send, or None if there's nothing to retry. + """ + if not self.conversation_history: + print("(._.) No messages to retry.") + return None + + # Walk backwards to find the last user message + last_user_idx = None + for i in range(len(self.conversation_history) - 1, -1, -1): + if self.conversation_history[i].get("role") == "user": + last_user_idx = i + break + + if last_user_idx is None: + print("(._.) No user message found to retry.") + return None + + # Extract the message text and remove everything from that point forward + last_message = self.conversation_history[last_user_idx].get("content", "") + self.conversation_history = self.conversation_history[:last_user_idx] + + print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"") + return last_message + + def undo_last(self): + """Remove the last user/assistant exchange from conversation history. + + Walks backwards and removes all messages from the last user message + onward (including assistant responses, tool calls, etc.). + """ + if not self.conversation_history: + print("(._.) No messages to undo.") + return + + # Walk backwards to find the last user message + last_user_idx = None + for i in range(len(self.conversation_history) - 1, -1, -1): + if self.conversation_history[i].get("role") == "user": + last_user_idx = i + break + + if last_user_idx is None: + print("(._.) No user message found to undo.") + return + + # Count how many messages we're removing + removed_count = len(self.conversation_history) - last_user_idx + removed_msg = self.conversation_history[last_user_idx].get("content", "") + + # Truncate history to before the last user message + self.conversation_history = self.conversation_history[:last_user_idx] + + print(f"(^_^)b Undid {removed_count} message(s). Removed: \"{removed_msg[:60]}{'...' if len(removed_msg) > 60 else ''}\"") + remaining = len(self.conversation_history) + print(f" {remaining} message(s) remaining in history.") + def _handle_prompt_command(self, cmd: str): """Handle the /prompt command to view or set system prompt.""" parts = cmd.split(maxsplit=1) @@ -1268,6 +1318,13 @@ class HermesCLI: elif cmd_lower.startswith("/personality"): # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) + elif cmd_lower == "/retry": + retry_msg = self.retry_last() + if retry_msg and hasattr(self, '_pending_input'): + # Re-queue the message so process_loop sends it to the agent + self._pending_input.put(retry_msg) + elif cmd_lower == "/undo": + self.undo_last() elif cmd_lower == "/save": self.save_conversation() elif cmd_lower.startswith("/cron"): @@ -1302,8 +1359,9 @@ class HermesCLI: # Add user message to history self.conversation_history.append({"role": "user", "content": message}) - # Visual separator after user input - print("─" * 60, flush=True) + # Visual separator after user input (adapt to terminal width, capped for readability) + term_width = min(self.console.width, 120) + print("─" * term_width, flush=True) try: # Run the conversation with interrupt monitoring @@ -1361,14 +1419,20 @@ class HermesCLI: if response: # Use simple print for compatibility with prompt_toolkit's patch_stdout + # Adapt box width to terminal (cap at 120 for readability) + box_width = min(self.console.width, 120) + inner = box_width - 2 # account for border chars ╭/╰ and ╮/╯ + label = "⚕ Hermes" + padding = inner - len(label) - 1 # -1 for the leading space + print() - print("╭" + "─" * 58 + "╮") - print("│ ⚕ Hermes" + " " * 49 + "│") - print("╰" + "─" * 58 + "╯") + print("╭" + "─" * inner + "╮") + print("│ " + label + " " * max(padding, 0) + "│") + print("╰" + "─" * inner + "╯") print() print(response) print() - print("─" * 60) + print("─" * box_width) # If we have a pending message from interrupt, re-queue it for process_loop # instead of recursing (avoids unbounded recursion from rapid interrupts) @@ -1382,37 +1446,6 @@ class HermesCLI: print(f"Error: {e}") return None - def get_input(self) -> Optional[str]: - """ - Get user input using prompt_toolkit. - - Enter submits. For multiline, end line with \\ to continue. - - Returns: - The user's input, or None if EOF/interrupt - """ - try: - # Get first line - line = self.prompt_session.prompt( - HTML(''), - style=self.prompt_style, - ) - - # Handle multi-line input (lines ending with \) - lines = [line] - while line.endswith("\\"): - lines[-1] = line[:-1] # Remove trailing backslash - line = self.prompt_session.prompt( - HTML(' '), # Continuation prompt - style=self.prompt_style, - ) - lines.append(line) - - return "\n".join(lines).strip() - - except (EOFError, KeyboardInterrupt): - return None - def run(self): """Run the interactive CLI loop with persistent input at bottom.""" self.show_banner() @@ -1426,9 +1459,6 @@ class HermesCLI: self._should_exit = False self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit - # Create a persistent input area using prompt_toolkit Application - input_buffer = Buffer() - # Key bindings for the input area kb = KeyBindings() @@ -1486,13 +1516,14 @@ class HermesCLI: self._should_exit = True event.app.exit() - # Create the input area widget + # Create the input area widget with persistent history across sessions input_area = TextArea( height=1, prompt='❯ ', style='class:input-area', multiline=False, wrap_lines=False, + history=FileHistory(str(self._history_file)), ) # Create a status line that shows when agent is working