From b320d45878a5f74a020af3d77ba0a79ea0e72e59 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 2 Jan 2026 10:51:23 -0600 Subject: [PATCH 1/2] Add real-time streaming output with tool execution tracking - Display Claude responses as tokens stream in for immediate feedback - Show tool execution status with running/completed states and timing - Add "Last activity" timestamp to status bar for session monitoring - Track tool inputs/outputs in verbose panel for debugging - Parse streaming events for tool_use_start, input_delta, and results --- src/views/agents_tab.py | 3 + src/views/chat_panel.py | 126 +++++++++++++++ src/views/main_window.py | 47 ++++++ src/widgets/session_tab_widget.py | 253 ++++++++++++++++++++++++++---- 4 files changed, 398 insertions(+), 31 deletions(-) diff --git a/src/views/agents_tab.py b/src/views/agents_tab.py index 08314da..745264d 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 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/main_window.py b/src/views/main_window.py index 8cd1569..8e54dfa 100644 --- a/src/views/main_window.py +++ b/src/views/main_window.py @@ -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 @@ -1361,6 +1405,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 From 5e25d692508fdfd56a0300db433728ba8671ec19 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 29 Jan 2026 23:07:46 -0600 Subject: [PATCH 2/2] Add three-tier command storage system (project, user, repo) Refactor StorageService to support loading commands from multiple directories with priority: project (.flowcoder/commands/), user (~/.flowcoder/commands/), and repo (commands/). Commands are saved back to their original location, with new commands saved to the project or user directory. Update UI to display source indicators and refresh command list when switching sessions. Co-Authored-By: Claude Opus 4.5 --- src/controllers/command_controller.py | 7 +- src/services/session_manager.py | 6 +- src/services/storage_service.py | 285 +++++++++++++++++++------- src/views/agents_tab.py | 5 + src/views/command_list_panel.py | 20 +- src/views/main_window.py | 33 ++- 6 files changed, 264 insertions(+), 92 deletions(-) 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 745264d..4a62935 100644 --- a/src/views/agents_tab.py +++ b/src/views/agents_tab.py @@ -298,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/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 8e54dfa..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' @@ -777,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")