diff --git a/src/controllers/command_controller.py b/src/controllers/command_controller.py index c0867ef..6c7fe34 100644 --- a/src/controllers/command_controller.py +++ b/src/controllers/command_controller.py @@ -107,12 +107,13 @@ def create_command(self, name: str, description: str = "") -> Command: logger.error(f"Failed to create command '{name}': {e}") raise StorageError(f"Failed to create command: {e}") from e - def load_command(self, name: str) -> Command: + def load_command(self, name: str, source: Optional[str] = None) -> Command: """ Load a command by name. Args: name: Command name + source: Optional source tier ('proj', 'user', 'fc') Returns: The loaded command @@ -121,8 +122,8 @@ def load_command(self, name: str) -> Command: StorageError: If command doesn't exist or load fails """ try: - command = self.storage_service.load_command(name) - logger.info(f"Loaded command: {name}") + command = self.storage_service.load_command(name, source=source) + logger.info(f"Loaded command: {name} (source={source})") return command except Exception as e: logger.error(f"Failed to load command '{name}': {e}") diff --git a/src/services/session_manager.py b/src/services/session_manager.py index 7955fa9..99f13c4 100644 --- a/src/services/session_manager.py +++ b/src/services/session_manager.py @@ -325,8 +325,8 @@ def create_session( from ..controllers.execution_controller import ExecutionController from ..services import StorageService - # Get storage service instance (shared across sessions) - storage_service = StorageService() + # Get storage service instance with session's working directory + storage_service = StorageService(project_dir=working_directory) session.execution_controller = ExecutionController( agent_service=session.agent_service, @@ -668,7 +668,7 @@ def load_sessions(self) -> None: from ..controllers.execution_controller import ExecutionController from ..services import StorageService - storage_service = StorageService() + storage_service = StorageService(project_dir=session.working_directory) session.execution_controller = ExecutionController( agent_service=session.agent_service, diff --git a/src/services/storage_service.py b/src/services/storage_service.py index 40b03ce..9b5be75 100644 --- a/src/services/storage_service.py +++ b/src/services/storage_service.py @@ -7,7 +7,7 @@ import json import logging from pathlib import Path -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Tuple from ..models import Command @@ -38,43 +38,132 @@ class CorruptedCommandError(StorageError): class StorageService: """Service for saving and loading commands to/from disk.""" - def __init__(self, commands_dir: str = "./commands"): + def __init__(self, project_dir: Optional[str] = None): """ - Initialize the storage service. + Initialize the storage service with three-tier command directories. Args: - commands_dir: Directory where command files are stored + project_dir: Project working directory. If provided, commands will be + loaded from {project_dir}/.flowcoder/commands/ + + Command directories (in priority order): + 1. Project: {project_dir}/.flowcoder/commands/ + 2. User: ~/.flowcoder/commands/ + 3. Flowcoder: {repo}/commands/ + """ + # Compute flowcoder repo commands directory from this file's location + # storage_service.py is at src/services/storage_service.py + # So parent.parent.parent gives us the repo root + self.flowcoder_commands_dir = Path(__file__).parent.parent.parent / "commands" + + # User commands directory + self.user_commands_dir = Path.home() / ".flowcoder" / "commands" + + # Project commands directory (optional) + self.project_commands_dir = ( + Path(project_dir) / ".flowcoder" / "commands" + if project_dir else None + ) + + # Build search order list (project first if available) + self._command_dirs: List[Tuple[Path, str]] = [] + if self.project_commands_dir: + self._command_dirs.append((self.project_commands_dir, "proj")) + self._command_dirs.append((self.user_commands_dir, "user")) + self._command_dirs.append((self.flowcoder_commands_dir, "fc")) + + # For backward compatibility, keep commands_dir pointing to project or user + self.commands_dir = self.project_commands_dir or self.user_commands_dir + + logger.info(f"StorageService initialized with {len(self._command_dirs)} command directories") + for cmd_dir, source in self._command_dirs: + exists = cmd_dir.exists() + logger.debug(f" {source}: {cmd_dir} (exists={exists})") + + def set_project_dir(self, project_dir: Optional[str]) -> None: """ - self.commands_dir = Path(commands_dir) - self._ensure_commands_directory() + Update the project directory for command loading. - def _ensure_commands_directory(self) -> None: - """Ensure the commands directory exists.""" - try: - self.commands_dir.mkdir(parents=True, exist_ok=True) - logger.info(f"Commands directory ready: {self.commands_dir}") - except Exception as e: - logger.error(f"Failed to create commands directory: {e}") - raise StorageError(f"Could not create commands directory: {e}") + Call this when the active session changes to update which project + commands are visible. - def _get_command_file_path(self, command_name: str) -> Path: + Args: + project_dir: New project working directory, or None to disable project tier + """ + # Update project commands directory + self.project_commands_dir = ( + Path(project_dir) / ".flowcoder" / "commands" + if project_dir else None + ) + + # Rebuild search order list + self._command_dirs = [] + if self.project_commands_dir: + self._command_dirs.append((self.project_commands_dir, "proj")) + self._command_dirs.append((self.user_commands_dir, "user")) + self._command_dirs.append((self.flowcoder_commands_dir, "fc")) + + # Update primary directory for saving new commands + self.commands_dir = self.project_commands_dir or self.user_commands_dir + + logger.info(f"StorageService project_dir updated to: {project_dir}") + for cmd_dir, source in self._command_dirs: + exists = cmd_dir.exists() + logger.debug(f" {source}: {cmd_dir} (exists={exists})") + + def _get_command_file_path(self, command_name: str, source: Optional[str] = None) -> Path: """ Get the file path for a command. Args: command_name: Name of the command + source: Optional source tier ('proj', 'user', 'fc'). If not provided, + searches all directories and returns first match. Returns: Path to the command file + + Raises: + CommandNotFoundError: If source specified but not found """ - # Sanitize command name for filename safe_name = command_name.replace(" ", "_") - return self.commands_dir / f"{safe_name}.json" + filename = f"{safe_name}.json" + + if source: + # Get specific directory for source + for cmd_dir, src in self._command_dirs: + if src == source: + return cmd_dir / filename + raise CommandNotFoundError(f"Unknown source: {source}") + + # Search all directories, return first existing match + for cmd_dir, src in self._command_dirs: + path = cmd_dir / filename + if path.exists(): + return path + + # Not found anywhere, return path in primary directory (for new commands) + return self.commands_dir / filename + + def _get_source_for_path(self, file_path: Path) -> str: + """Determine the source tier for a given file path.""" + file_path = file_path.resolve() + for cmd_dir, source in self._command_dirs: + if cmd_dir.exists(): + try: + file_path.relative_to(cmd_dir.resolve()) + return source + except ValueError: + continue + return "fc" # Default fallback def save_command(self, command: Command, overwrite: bool = True) -> None: """ Save a command to disk. + For existing commands: saves to original location. + For new commands: saves to project directory (or user if no project). + Args: command: Command to save overwrite: If False, raises error if command already exists @@ -83,7 +172,13 @@ def save_command(self, command: Command, overwrite: bool = True) -> None: CommandAlreadyExistsError: If command exists and overwrite=False StorageError: If save operation fails """ - file_path = self._get_command_file_path(command.name) + # Determine target directory + if hasattr(command, '_file_path') and command._file_path: + # Existing command - save to original location + file_path = command._file_path + else: + # New command - save to primary directory (project or user) + file_path = self.commands_dir / f"{command.name.replace(' ', '_')}.json" # Check if already exists if file_path.exists() and not overwrite: @@ -91,29 +186,34 @@ def save_command(self, command: Command, overwrite: bool = True) -> None: f"Command '{command.name}' already exists" ) + # Ensure directory exists (JIT creation) + file_path.parent.mkdir(parents=True, exist_ok=True) + # Update modified timestamp command.update_modified() try: - # Convert to dict data = command.to_dict() - - # Write to file with pretty formatting with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False) + # Update command's file path reference + command._file_path = file_path + command._source = self._get_source_for_path(file_path) + logger.info(f"Saved command '{command.name}' to {file_path}") except Exception as e: logger.error(f"Failed to save command '{command.name}': {e}") raise StorageError(f"Could not save command: {e}") - def load_command(self, command_name: str) -> Command: + def load_command(self, command_name: str, source: Optional[str] = None) -> Command: """ Load a command from disk. Args: command_name: Name of the command to load + source: Optional source tier ('proj', 'user', 'fc') Returns: Loaded Command object @@ -123,7 +223,7 @@ def load_command(self, command_name: str) -> Command: CorruptedCommandError: If command file is invalid StorageError: If load operation fails """ - file_path = self._get_command_file_path(command_name) + file_path = self._get_command_file_path(command_name, source) if not file_path.exists(): raise CommandNotFoundError( @@ -135,6 +235,11 @@ def load_command(self, command_name: str) -> Command: data = json.load(f) command = Command.from_dict(data) + + # Store source info on command for save operations + command._source = self._get_source_for_path(file_path) + command._file_path = file_path + logger.info(f"Loaded command '{command_name}' from {file_path}") return command @@ -167,34 +272,51 @@ def load_command_by_id(self, command_id: str) -> Optional[Command]: return self.load_command(metadata['name']) return None - def delete_command(self, command_name: str) -> None: + def delete_command(self, command_name: str, source: Optional[str] = None) -> None: """ Delete a command from disk. + If source not specified, deletes from highest-priority location. + Args: command_name: Name of the command to delete + source: Optional source tier to delete from Raises: CommandNotFoundError: If command doesn't exist StorageError: If delete operation fails """ - file_path = self._get_command_file_path(command_name) - - if not file_path.exists(): - raise CommandNotFoundError( - f"Command '{command_name}' not found" - ) + safe_name = command_name.replace(" ", "_") + filename = f"{safe_name}.json" + + # Find the file to delete + file_path = None + if source: + for cmd_dir, src in self._command_dirs: + if src == source: + file_path = cmd_dir / filename + break + else: + # Find highest-priority location where command exists + for cmd_dir, src in self._command_dirs: + path = cmd_dir / filename + if path.exists(): + file_path = path + break + + if not file_path or not file_path.exists(): + raise CommandNotFoundError(f"Command '{command_name}' not found") try: file_path.unlink() - logger.info(f"Deleted command '{command_name}'") + logger.info(f"Deleted command '{command_name}' from {file_path}") except Exception as e: logger.error(f"Failed to delete command '{command_name}': {e}") raise StorageError(f"Could not delete command: {e}") def list_commands(self) -> List[Dict[str, Any]]: """ - List all available commands with their metadata. + List all available commands with their metadata from all directories. Returns: List of command metadata dictionaries with keys: @@ -205,64 +327,77 @@ def list_commands(self) -> List[Dict[str, Any]]: - modified: Last modified timestamp - block_count: Number of blocks in flowchart - file_path: Path to command file + - source: Source tier ('proj', 'user', 'fc') Raises: StorageError: If listing fails """ commands = [] - try: - # Find all .json files in commands directory - for file_path in self.commands_dir.glob("*.json"): - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - # Extract metadata - metadata = { - 'id': data.get('id', ''), - 'name': data.get('name', ''), - 'description': data.get('description', ''), - 'created': data.get('metadata', {}).get('created', ''), - 'modified': data.get('metadata', {}).get('modified', ''), - 'block_count': len(data.get('flowchart', {}).get('blocks', {})), - 'file_path': str(file_path) - } - - commands.append(metadata) - - except json.JSONDecodeError: - logger.warning(f"Skipping corrupted file: {file_path}") - continue - except Exception as e: - logger.warning(f"Error reading {file_path}: {e}") - continue - - # Sort by modified date (most recent first) - commands.sort( - key=lambda x: x['modified'], - reverse=True - ) - - logger.info(f"Found {len(commands)} commands") - return commands - - except Exception as e: - logger.error(f"Failed to list commands: {e}") - raise StorageError(f"Could not list commands: {e}") - - def command_exists(self, command_name: str) -> bool: + for cmd_dir, source in self._command_dirs: + if not cmd_dir.exists(): + continue + + try: + for file_path in cmd_dir.glob("*.json"): + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + metadata = { + 'id': data.get('id', ''), + 'name': data.get('name', ''), + 'description': data.get('description', ''), + 'created': data.get('metadata', {}).get('created', ''), + 'modified': data.get('metadata', {}).get('modified', ''), + 'block_count': len(data.get('flowchart', {}).get('blocks', {})), + 'file_path': str(file_path), + 'source': source, + } + commands.append(metadata) + + except json.JSONDecodeError: + logger.warning(f"Skipping corrupted file: {file_path}") + continue + except Exception as e: + logger.warning(f"Error reading {file_path}: {e}") + continue + + except Exception as e: + logger.warning(f"Error listing commands from {cmd_dir}: {e}") + continue + + # Sort by modified date (most recent first) + commands.sort(key=lambda x: x['modified'], reverse=True) + + logger.info(f"Found {len(commands)} commands across {len(self._command_dirs)} directories") + return commands + + def command_exists(self, command_name: str, source: Optional[str] = None) -> bool: """ Check if a command exists. Args: command_name: Name of the command + source: Optional source tier to check Returns: True if command exists, False otherwise """ - file_path = self._get_command_file_path(command_name) - return file_path.exists() + safe_name = command_name.replace(" ", "_") + filename = f"{safe_name}.json" + + if source: + for cmd_dir, src in self._command_dirs: + if src == source: + return (cmd_dir / filename).exists() + return False + + # Check all directories + for cmd_dir, src in self._command_dirs: + if (cmd_dir / filename).exists(): + return True + return False def get_command_count(self) -> int: """ diff --git a/src/views/agents_tab.py b/src/views/agents_tab.py index 08314da..4a62935 100644 --- a/src/views/agents_tab.py +++ b/src/views/agents_tab.py @@ -266,6 +266,9 @@ def _create_session_widget(self, session: Session): session_manager=self.session_manager ) + # Pass main_window reference for last contact tracking + widget.main_window = self.main_window + # Store in map (but don't pack yet) self.session_widgets[session.name] = widget @@ -295,6 +298,11 @@ def on_session_selected(self, session_name: str): # Update control buttons state self._update_control_buttons() + # Update storage service project directory to match session's working directory + session = self.session_manager.get_session(session_name) + if session and hasattr(self.main_window, 'on_session_working_dir_changed'): + self.main_window.on_session_working_dir_changed(session.working_directory) + def on_new_session(self): """Handle New Session button - show dialog to create new session.""" logger.info("New Session button clicked") diff --git a/src/views/chat_panel.py b/src/views/chat_panel.py index 737b61e..13f578a 100644 --- a/src/views/chat_panel.py +++ b/src/views/chat_panel.py @@ -45,6 +45,7 @@ def __init__( self.on_message_sent = on_message_sent self.on_slash_command = on_slash_command self._chat_output_log = None # Log file for capturing exact chat output + self._last_tool_line = None # Track last tool line for updates self._create_widgets() @@ -419,6 +420,59 @@ def end_streaming_message(self): self.output_text.see(tk.END) self.output_text.config(state=tk.DISABLED) + def add_tool_start(self, tool_name: str, tag: str = 'system'): + """ + Display tool execution start in Chat tab. + + Args: + tool_name: Name of the tool being executed + tag: Text tag for styling + """ + self.output_text.config(state=tk.NORMAL) + + # Format: 🔧 [tool_name] Running... + tool_line = f"🔧 [{tool_name}] Running..." + self.output_text.insert(tk.END, tool_line, tag) + + # Store the line position so we can update it later + end_pos = self.output_text.index(tk.END) + line_num = int(end_pos.split('.')[0]) - 1 + self._last_tool_line = line_num + + self.output_text.see(tk.END) + self.output_text.config(state=tk.DISABLED) + self.output_text.update_idletasks() + + def update_tool_complete(self, tool_name: str, duration: float, tag: str = 'system'): + """ + Update the tool line to show completion. + + Args: + tool_name: Name of the tool that completed + duration: Execution time in seconds + tag: Text tag for styling + """ + if not hasattr(self, '_last_tool_line') or self._last_tool_line is None: + return + + self.output_text.config(state=tk.NORMAL) + + # Delete the "Running..." line + line_start = f"{self._last_tool_line}.0" + line_end = f"{self._last_tool_line}.end" + self.output_text.delete(line_start, line_end) + + # Insert updated line: 🔧 [tool_name] ✓ Completed (X.Xs) + tool_line = f"🔧 [{tool_name}] ✓ Completed ({duration:.1f}s)" + self.output_text.insert(line_start, tool_line, tag) + + # Add newline + self.output_text.insert(f"{self._last_tool_line}.end", "\n") + + self.output_text.see(tk.END) + self.output_text.config(state=tk.DISABLED) + self.output_text.update_idletasks() + def add_verbose_message(self, message: str, tag: str = 'metadata'): """ Add a message to the verbose tab. @@ -477,3 +531,75 @@ def end_verbose_streaming_message(self): self.verbose_text.insert(tk.END, "\n") self.verbose_text.see(tk.END) self.verbose_text.config(state=tk.DISABLED) + + def add_verbose_tool_start(self, tool_name: str, tool_id: str, tool_input: dict, tag: str = 'metadata'): + """ + Display detailed tool start information in Verbose tab. + + Args: + tool_name: Name of the tool + tool_id: Tool use ID (e.g., toolu_01ABC123) + tool_input: Dictionary of tool input parameters + tag: Text tag for styling + """ + import json + from datetime import datetime + + self.verbose_text.config(state=tk.NORMAL) + + # Add timestamp + timestamp = datetime.now().strftime("%H:%M:%S") + self.verbose_text.insert(tk.END, f"[{timestamp}] ", 'system') + + # Tool header + header = f"🔧 [Tool Started] {tool_name} ({tool_id})\n" + self.verbose_text.insert(tk.END, header, tag) + + # Input parameters (formatted JSON) + if tool_input: + input_json = json.dumps(tool_input, indent=2) + input_text = f" Input: {input_json}\n" + self.verbose_text.insert(tk.END, input_text, tag) + + self.verbose_text.see(tk.END) + self.verbose_text.config(state=tk.DISABLED) + self.verbose_text.update_idletasks() + + def add_verbose_tool_result(self, tool_name: str, tool_id: str, content: str, + is_error: bool, duration: float, tag: str = 'metadata'): + """ + Display tool completion with result in Verbose tab. + + Args: + tool_name: Name of the tool + tool_id: Tool use ID + content: Tool output/result + is_error: Whether the tool execution failed + duration: Execution time in seconds + tag: Text tag for styling + """ + from datetime import datetime + + self.verbose_text.config(state=tk.NORMAL) + + # Add timestamp + timestamp = datetime.now().strftime("%H:%M:%S") + self.verbose_text.insert(tk.END, f"[{timestamp}] ", 'system') + + # Tool completion header + status = "❌ Error" if is_error else "✓ Completed" + header = f"🔧 [Tool {status}] {tool_name} ({duration:.1f}s)\n" + result_tag = 'error' if is_error else tag + self.verbose_text.insert(tk.END, header, result_tag) + + # Output (indented, first 500 chars) + if content: + output_preview = content[:500] + if len(content) > 500: + output_preview += "... (truncated)" + output_text = f" Output: {output_preview}\n" + self.verbose_text.insert(tk.END, output_text, tag) + + self.verbose_text.see(tk.END) + self.verbose_text.config(state=tk.DISABLED) + self.verbose_text.update_idletasks() diff --git a/src/views/command_list_panel.py b/src/views/command_list_panel.py index e86ddbb..772d6b7 100644 --- a/src/views/command_list_panel.py +++ b/src/views/command_list_panel.py @@ -171,13 +171,20 @@ def _update_listbox(self): # Add filtered commands for metadata in self.filtered_metadata: + # Get source indicator + source = metadata.get('source', 'fc') + source_suffix = f" ({source})" + + # Build display text display_text = metadata['name'] if metadata.get('description'): - # Truncate long descriptions - desc = metadata['description'][:50] - if len(metadata['description']) > 50: + # Truncate long descriptions, accounting for source suffix + max_desc_len = 50 - len(source_suffix) + desc = metadata['description'][:max_desc_len] + if len(metadata['description']) > max_desc_len: desc += "..." display_text += f" - {desc}" + display_text += source_suffix self.listbox.insert(tk.END, display_text) # Update count label @@ -216,9 +223,12 @@ def _on_selection_changed(self, event): index = selection[0] metadata = self.filtered_metadata[index] - # Load the full command from controller + # Load the full command from controller (with source for multi-directory support) try: - self.selected_command = self.command_controller.load_command(metadata['name']) + self.selected_command = self.command_controller.load_command( + metadata['name'], + source=metadata.get('source') + ) self.delete_button.config(state=tk.NORMAL) self.duplicate_button.config(state=tk.NORMAL) self.rename_button.config(state=tk.NORMAL) diff --git a/src/views/main_window.py b/src/views/main_window.py index 8cd1569..d7c3fb6 100644 --- a/src/views/main_window.py +++ b/src/views/main_window.py @@ -56,8 +56,12 @@ def __init__(self, title: str = "FlowCoder", width: int = 1200, height: int = 80 # Initialize UI controller (first, as other components may use it) self.ui_controller = UIController(self.root) - # Initialize storage service - self.storage_service = StorageService() + # Working directory for operations (set early, used by storage service) + import os + self.working_directory = os.getcwd() + + # Initialize storage service with project directory for three-tier command loading + self.storage_service = StorageService(project_dir=self.working_directory) # Initialize session manager (shared by Agents and Files tabs) from src.services.session_manager import SessionManager @@ -75,10 +79,6 @@ def __init__(self, title: str = "FlowCoder", width: int = 1200, height: int = 80 # Initialize audio service self.audio_service = AudioService() - # Working directory for Claude operations - import os - self.working_directory = os.getcwd() - # Initialize Claude service and execution controller # Use real Claude Agent SDK by default, MockClaudeService for testing use_mock = os.getenv('USE_MOCK_CLAUDE', 'false').lower() == 'true' @@ -111,6 +111,10 @@ def __init__(self, title: str = "FlowCoder", width: int = 1200, height: int = 80 # Track async tasks for cleanup self._async_tasks: List[asyncio.Task] = [] + # Last contact tracking + self._last_contact_time = None + self._status_update_job = None + # Create and configure asyncio event loop for Tkinter integration self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) @@ -263,8 +267,48 @@ def _create_status_bar(self): self.status_bar.set_working_dir(self.working_directory) self.status_bar.set_connection_status("Not connected") + # Add last activity label to status bar + self.last_contact_label = ttk.Label( + self.status_bar, + text="Last activity: --", + font=('TkDefaultFont', 9), + foreground='#9E9E9E' + ) + self.last_contact_label.pack(side=tk.RIGHT, padx=10) + logger.debug("Enhanced status bar created") + def _update_last_contact_time(self): + """Record the current time as last contact from Claude.""" + import time + self._last_contact_time = time.time() + + # Start the status update loop if not already running + if not self._status_update_job: + self._update_status_display() + + def _update_status_display(self): + """Update the 'Last activity' display every second.""" + if not self._last_contact_time: + self.last_contact_label.config(text="Last activity: --") + else: + import time + elapsed = time.time() - self._last_contact_time + + if elapsed < 1: + time_str = "just now" + elif elapsed < 60: + time_str = f"{int(elapsed)}s ago" + elif elapsed < 3600: + time_str = f"{int(elapsed / 60)}m ago" + else: + time_str = f"{int(elapsed / 3600)}h ago" + + self.last_contact_label.config(text=f"Last activity: {time_str}") + + # Schedule next update in 1 second + self._status_update_job = self.root.after(1000, self._update_status_display) + def _apply_styling(self): """Apply basic styling and theme.""" # Configure ttk style @@ -733,6 +777,27 @@ def on_about(self): """ messagebox.showinfo("About FlowCoder", about_text) + def on_session_working_dir_changed(self, working_directory: str): + """ + Handle session working directory change. + + Called when user selects a different session. Updates the storage service + to show commands from the new session's project directory. + + Args: + working_directory: The selected session's working directory + """ + logger.info(f"Session working directory changed to: {working_directory}") + + # Update storage service to use new project directory + self.storage_service.set_project_dir(working_directory) + + # Refresh command list to show commands from new project directory + if hasattr(self, 'commands_tab') and self.commands_tab: + if hasattr(self.commands_tab, 'command_list_panel'): + self.commands_tab.command_list_panel.refresh() + logger.debug("Command list refreshed for new project directory") + def on_closing(self): """Handle window close event.""" logger.info("Window closing") @@ -1361,6 +1426,9 @@ def _on_prompt_stream(self, prompt_text: str, chunk: str): prompt_text: The prompt being executed chunk: Streaming chunk (empty string = start of prompt) """ + # Update last contact timestamp + self._update_last_contact_time() + if chunk == "": # Start of prompt - show what we're asking Claude self.chat_panel.add_message(f"Prompt: {prompt_text}", tag='user') diff --git a/src/widgets/session_tab_widget.py b/src/widgets/session_tab_widget.py index f4349e7..fa1fd13 100644 --- a/src/widgets/session_tab_widget.py +++ b/src/widgets/session_tab_widget.py @@ -63,6 +63,9 @@ def __init__(self, parent, session: Session, storage_service=None, on_close_call # Flag to track if widget has been destroyed (for callback guards) self._is_destroyed = False + # Tool execution tracking + self._active_tools = {} # tool_use_id -> {'name': str, 'start_time': float, 'input': str} + self._create_ui() # Wire up execution controller callbacks for UI updates @@ -457,43 +460,56 @@ def _parse_sdk_message(self, sdk_message): # First check if this is a content_block_delta event # Use flexible string matching to handle both escaped and unescaped quotes - if "content_block_delta" in message_str and "text_delta" in message_str: - # Extract the text value using regex - # Match various quote combinations to handle Python's repr() escaping - import re + if "content_block_delta" in message_str: + if "text_delta" in message_str: + # Extract the text value using regex + # Match various quote combinations to handle Python's repr() escaping + import re + + # Try different patterns (repr() uses different quote combinations) + patterns = [ + r"'text':\s*'((?:[^'\\]|\\.)*)'", # 'text': '...' + r'"text":\s*"((?:[^"\\]|\\.)*)"', # "text": "..." + r"\\'text\\':\s*\"((?:[^\"\\]|\\.)*?)\"", # \'text\': "..." (escaped key, double value) + r"'text':\s*\"((?:[^\"\\]|\\.)*?)\"", # 'text': "..." (unescaped key, double value) + ] + + text_match = None + for pattern in patterns: + text_match = re.search(pattern, message_str) + if text_match: + break - # Try different patterns (repr() uses different quote combinations) - patterns = [ - r"'text':\s*'((?:[^'\\]|\\.)*)'", # 'text': '...' - r'"text":\s*"((?:[^"\\]|\\.)*)"', # "text": "..." - r"\\'text\\':\s*\"((?:[^\"\\]|\\.)*?)\"", # \'text\': "..." (escaped key, double value) - r"'text':\s*\"((?:[^\"\\]|\\.)*?)\"", # 'text': "..." (unescaped key, double value) - ] - - text_match = None - for pattern in patterns: - text_match = re.search(pattern, message_str) if text_match: - break + message_type = "text_delta" # Changed from "assistant" to distinguish from AssistantMessage + # Extract the text and unescape it + text_content = text_match.group(1) + # Unescape common escape sequences + text_content = text_content.replace('\\n', '\n') + text_content = text_content.replace('\\t', '\t') + text_content = text_content.replace('\\r', '\r') + text_content = text_content.replace("\\'", "'") + text_content = text_content.replace('\\"', '"') + text_content = text_content.replace('\\\\', '\\') - if text_match: - message_type = "text_delta" # Changed from "assistant" to distinguish from AssistantMessage - # Extract the text and unescape it - text_content = text_match.group(1) - # Unescape common escape sequences - text_content = text_content.replace('\\n', '\n') - text_content = text_content.replace('\\t', '\t') - text_content = text_content.replace('\\r', '\r') - text_content = text_content.replace("\\'", "'") - text_content = text_content.replace('\\"', '"') - text_content = text_content.replace('\\\\', '\\') + verbose_content = f"[StreamEvent] {text_content}" + logger.debug(f"Parsed StreamEvent chunk: {len(text_content)} chars") + return (text_content, verbose_content, message_type) - verbose_content = f"[StreamEvent] {text_content}" - logger.debug(f"Parsed StreamEvent chunk: {len(text_content)} chars") - return (text_content, verbose_content, message_type) + elif "input_json_delta" in message_str: + # Extract partial tool input JSON + partial_json = self._extract_partial_json(message_str) + return (partial_json, "", "tool_input_delta") # Check for other event types if "content_block_start" in message_str: + # Check if this is a tool_use block + if "'type': 'tool_use'" in message_str or '"type": "tool_use"' in message_str: + # Extract tool name and ID + tool_info = self._extract_tool_use_info(message_str) + if tool_info: + return (str(tool_info), "", "tool_use_start") + return ("", "", "content_block_start") event_type_checks = [ @@ -531,8 +547,14 @@ def _parse_sdk_message(self, sdk_message): text_content = "[System initialization]" elif "UserMessage" in message_str: - # Tool results - filter these out, don't display message_type = "result" + + # Check if this contains a ToolResultBlock + if "ToolResultBlock" in message_str: + tool_result_info = self._extract_tool_result_info(message_str) + if tool_result_info: + return (str(tool_result_info), "", "tool_result") + text_content = "" # Don't extract text, just classify as result elif "ResultMessage" in message_str: @@ -602,6 +624,116 @@ def _parse_sdk_message(self, sdk_message): return (text_content, verbose_content, message_type) + def _extract_tool_use_info(self, message_str: str) -> dict: + """ + Extract tool use metadata from content_block_start event. + + Args: + message_str: String representation of StreamEvent + + Returns: + dict with keys: name, id, input (partial or complete) + """ + import re + tool_info = {} + + # Extract tool name: 'name': 'bash_execute' + name_patterns = [ + r"'name':\s*'([^']+)'", + r'"name":\s*"([^"]+)"', + ] + for pattern in name_patterns: + match = re.search(pattern, message_str) + if match: + tool_info['name'] = match.group(1) + break + + # Extract tool_use_id: 'id': 'toolu_01ABC123' + id_patterns = [ + r"'id':\s*'([^']+)'", + r'"id":\s*"([^"]+)"', + ] + for pattern in id_patterns: + match = re.search(pattern, message_str) + if match: + tool_info['id'] = match.group(1) + break + + # Extract input (may be empty initially) + input_patterns = [ + r"'input':\s*({[^}]*})", + r'"input":\s*({[^}]*})', + ] + for pattern in input_patterns: + match = re.search(pattern, message_str) + if match: + try: + import json + tool_info['input'] = json.loads(match.group(1)) + except: + tool_info['input'] = {} + break + + return tool_info if 'name' in tool_info else None + + def _extract_partial_json(self, message_str: str) -> str: + """ + Extract partial_json field from input_json_delta event. + + Returns: + Partial JSON string to append to tool input + """ + import re + patterns = [ + r"'partial_json':\s*'([^']*)'", + r'"partial_json":\s*"([^"]*)"', + ] + for pattern in patterns: + match = re.search(pattern, message_str) + if match: + return match.group(1) + return "" + + def _extract_tool_result_info(self, message_str: str) -> dict: + """ + Extract tool result from ToolResultBlock in UserMessage. + + Returns: + dict with keys: tool_use_id, content, is_error + """ + import re + result_info = {} + + # Extract tool_use_id + id_patterns = [ + r"tool_use_id='([^']+)'", + r'tool_use_id="([^"]+)"', + ] + for pattern in id_patterns: + match = re.search(pattern, message_str) + if match: + result_info['tool_use_id'] = match.group(1) + break + + # Extract content + content_patterns = [ + r"content='([^']*)'", + r'content="([^"]*)"', + ] + for pattern in content_patterns: + match = re.search(pattern, message_str) + if match: + result_info['content'] = match.group(1).replace('\\n', '\n') + break + + # Extract is_error flag + if 'is_error=True' in message_str: + result_info['is_error'] = True + else: + result_info['is_error'] = False + + return result_info if 'tool_use_id' in result_info else None + async def _send_passthrough_message_async(self, message: str): """ Send a pass-through message to Claude and stream the response in real-time. @@ -1073,6 +1205,10 @@ def _on_prompt_stream(self, prompt_text: str, chunk: str): if not self._widget_exists(): return + # Update last contact timestamp + if hasattr(self, 'main_window') and self.main_window: + self.main_window._update_last_contact_time() + if chunk == "": # Get service display name and tag from ..services.service_factory import ServiceFactory @@ -1196,6 +1332,61 @@ def _on_prompt_stream(self, prompt_text: str, chunk: str): self.chat_panel.add_streaming_text(text_content, tag=self._service_tag) self.chat_panel.add_verbose_streaming_text(text_content, tag=self._service_tag) + elif message_type == "tool_use_start": + # text_content is the tool_info dict as string from parsing + tool_info = eval(text_content) # Safe because we generated it + tool_id = tool_info.get('id') + tool_name = tool_info.get('name') + + if tool_id: + import time + self._active_tools[tool_id] = { + 'name': tool_name, + 'start_time': time.time(), + 'input': '' + } + + # Display in both tabs + self.chat_panel.add_tool_start(tool_name) + self.chat_panel.add_verbose_tool_start(tool_name, tool_id, tool_info.get('input', {})) + + elif message_type == "tool_input_delta": + # Accumulate streaming tool input (for verbose display) + # text_content is the partial JSON string + # Note: We track which tool this belongs to by using the last active tool + if self._active_tools: + # Get the most recent active tool + last_tool_id = list(self._active_tools.keys())[-1] + self._active_tools[last_tool_id]['input'] += text_content + + # Update verbose display with streaming input + self.chat_panel.add_verbose_streaming_text(text_content, tag='metadata') + + elif message_type == "tool_result": + # text_content is the tool_result_info dict as string from parsing + tool_result_info = eval(text_content) # Safe because we generated it + tool_id = tool_result_info['tool_use_id'] + + # Calculate execution time + if tool_id in self._active_tools: + import time + start_time = self._active_tools[tool_id]['start_time'] + duration = time.time() - start_time + tool_name = self._active_tools[tool_id]['name'] + + # Display completion in both tabs + self.chat_panel.update_tool_complete(tool_name, duration) + self.chat_panel.add_verbose_tool_result( + tool_name, + tool_id, + tool_result_info.get('content', ''), + tool_result_info.get('is_error', False), + duration + ) + + # Clean up state + del self._active_tools[tool_id] + def _on_restart_clicked(self): """Handle Restart Session button click.""" from tkinter import messagebox