""" Dear PyGui-based GUI for Orchestrated Discussions. Provides a graphical interface with native image viewing for: - Browsing and selecting discussions - Viewing discussion content and comments - Adding human comments with votes - Running discussion turns - Viewing diagrams (PlantUML, Mermaid, OpenSCAD, DOT, SVG) """ import dearpygui.dearpygui as dpg from pathlib import Path from typing import Optional, Callable import subprocess import threading import json import os import tempfile import uuid from datetime import datetime # Import project modules try: from ..discussion import Discussion, Comment from ..voting import format_vote_summary from ..markers import extract_diagrams from ..participant import ( get_registry, AVAILABLE_VOICES, DEFAULT_VOICE, DEFAULT_PROVIDER, DEFAULT_COLOR, get_available_providers, save_participant_settings ) from .formats import detect_format, render_to_png, get_renderer, get_format_info except ImportError: # Allow running standalone for testing Discussion = None format_vote_summary = None extract_diagrams = None get_registry = None detect_format = None render_to_png = None get_renderer = None get_format_info = None class DiscussionGUI: """ Main GUI application using Dear PyGui. """ @staticmethod def _copy_to_clipboard(text: str) -> bool: """Copy text to clipboard using available methods. Returns True on success.""" # Try xclip first try: subprocess.run(['xclip', '-selection', 'clipboard'], input=text.encode(), check=True, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired): pass # Try xsel try: subprocess.run(['xsel', '--clipboard', '--input'], input=text.encode(), check=True, capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired): pass # Try pyperclip try: import pyperclip pyperclip.copy(text) return True except (ImportError, Exception): pass # Try tkinter as last resort try: import tkinter as tk root = tk.Tk() root.withdraw() root.clipboard_clear() root.clipboard_append(text) root.update() root.destroy() return True except Exception: pass return False def _read_aloud_clicked(self, sender, app_data, user_data): """Handle read/stop button click.""" button_tag, text, author = user_data # If we're currently reading and this is the active button, stop if self._reading_session_id and self._reading_button_tag == button_tag: self._stop_reading() return # If we're reading something else, stop that first if self._reading_session_id: self._stop_reading() # Look up participant's voice from author name voice = self._get_voice_for_author(author) # Start new reading self._start_reading(text, button_tag, voice) def _get_voice_for_author(self, author: str) -> str: """Look up the voice for a participant by author name.""" # Default voice default = DEFAULT_VOICE if DEFAULT_VOICE else "en-US-Neural2-J" # Try to extract alias from author name (e.g., "AI-Architect" -> "architect") if author.startswith("AI-"): alias = author[3:].lower() elif author.startswith("ai-"): alias = author[3:].lower() else: # Human or unknown - use default return default # Look up participant if get_registry: participant = get_registry().get(alias) if participant and participant.voice: return participant.voice return default def _get_color_for_author(self, author: str) -> tuple: """Look up the display color for a participant by author name.""" # Default color (light blue for backwards compatibility) default = (100, 200, 255) # Try to extract alias from author name (e.g., "AI-Architect" -> "architect") if author.startswith("AI-"): alias = author[3:].lower() elif author.startswith("ai-"): alias = author[3:].lower() else: # Human - use a neutral gray return (180, 180, 180) # Look up participant if get_registry: participant = get_registry().get(alias) if participant and participant.color: return participant.color return default def _start_reading(self, text: str, button_tag: str, voice: str = None): """Start reading text aloud.""" if voice is None: voice = DEFAULT_VOICE if DEFAULT_VOICE else "en-US-Neural2-J" session_id = str(uuid.uuid4())[:8] self._reading_session_id = session_id self._reading_button_tag = button_tag # Update button to show "Stop" if dpg.does_item_exist(button_tag): dpg.set_item_label(button_tag, "Stop") def run_tts(): try: # Use full path since ~/.local/bin may not be in PATH read_aloud_cmd = os.path.expanduser("~/.local/bin/read-aloud") # Extract language from voice name (e.g., "en-GB-Neural2-A" -> "en-GB") lang = voice[:5] if len(voice) >= 5 else "en-US" result = subprocess.run( [read_aloud_cmd, "--strip-md", "true", "--voice", voice, "--lang", lang, "--session-id", session_id], input=text, capture_output=True, text=True, timeout=300 ) except Exception: pass finally: # Reset state and button when done (in main thread) self._on_reading_complete(button_tag) thread = threading.Thread(target=run_tts, daemon=True) thread.start() def _stop_reading(self): """Stop current reading by creating stop signal file.""" if self._reading_session_id: stop_file = f"/tmp/read-aloud-{self._reading_session_id}.stop" try: with open(stop_file, 'w') as f: f.write("stop") except Exception: pass # Reset button immediately if self._reading_button_tag and dpg.does_item_exist(self._reading_button_tag): dpg.set_item_label(self._reading_button_tag, "Read") self._reading_session_id = None self._reading_button_tag = None def _on_reading_complete(self, button_tag: str): """Called when reading completes (from background thread).""" # Only reset if this is still the active session if self._reading_button_tag == button_tag: self._reading_session_id = None self._reading_button_tag = None if dpg.does_item_exist(button_tag): dpg.set_item_label(button_tag, "Read") def _dictate_clicked(self): """Handle dictate button click - toggle recording.""" if self._dictation_process is not None: # Currently recording - stop and transcribe self._stop_dictation() else: # Start recording self._start_dictation() def _start_dictation(self): """Start recording audio from microphone.""" import tempfile import signal # Create temp file for audio self._dictation_audio_file = tempfile.mktemp(suffix=".wav") # Start arecord without duration limit (records until interrupted) try: self._dictation_process = subprocess.Popen( [ "arecord", "-D", "default", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav", self._dictation_audio_file ], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) # Update button to show recording state if dpg.does_item_exist("dictate_btn"): dpg.set_item_label("dictate_btn", "Stop Recording") except FileNotFoundError: self._show_error("arecord not found. Install alsa-utils: sudo apt install alsa-utils") self._dictation_process = None self._dictation_audio_file = None except Exception as e: self._show_error(f"Failed to start recording: {e}") self._dictation_process = None self._dictation_audio_file = None def _stop_dictation(self): """Stop recording and transcribe the audio.""" import signal if self._dictation_process is None: return # Send SIGINT to stop arecord gracefully try: self._dictation_process.send_signal(signal.SIGINT) self._dictation_process.wait(timeout=2) except Exception: # Force kill if needed try: self._dictation_process.kill() self._dictation_process.wait(timeout=1) except Exception: pass self._dictation_process = None # Update button back to Dictate if dpg.does_item_exist("dictate_btn"): dpg.set_item_label("dictate_btn", "Transcribing...") # Transcribe in background thread audio_file = self._dictation_audio_file self._dictation_audio_file = None def transcribe(): transcript = "" try: if audio_file and os.path.exists(audio_file): transcribe_cmd = os.path.expanduser("~/.local/bin/transcribe") result = subprocess.run( [transcribe_cmd], input=audio_file, capture_output=True, text=True, timeout=60 ) transcript = result.stdout.strip() # Clean up audio file os.remove(audio_file) except Exception as e: transcript = f"[Transcription error: {e}]" # Update UI in main thread self._on_transcription_complete(transcript) thread = threading.Thread(target=transcribe, daemon=True) thread.start() def _on_transcription_complete(self, transcript: str): """Called when transcription completes - append to comment text.""" # Reset button if dpg.does_item_exist("dictate_btn"): dpg.set_item_label("dictate_btn", "Dictate") if transcript and not transcript.startswith("["): # Append to existing comment text if dpg.does_item_exist("comment_text"): current = dpg.get_value("comment_text") or "" separator = " " if current.strip() else "" dpg.set_value("comment_text", current + separator + transcript) def __init__(self, discussions_dir: Path = None): if discussions_dir is None: discussions_dir = Path.cwd() self.discussions_dir = Path(discussions_dir) self.current_discussion: Optional[Discussion] = None self.discussions_list: list[tuple[Path, Discussion]] = [] # UI state self._turn_running = False self._output_lines = [] self._diagram_textures = {} # Cache for loaded textures # Read-aloud state self._reading_session_id: Optional[str] = None self._reading_button_tag: Optional[str] = None self._comment_counter = 0 # For generating unique button tags # Dictation state self._dictation_process: Optional[subprocess.Popen] = None self._dictation_audio_file: Optional[str] = None # Initialize Dear PyGui dpg.create_context() dpg.create_viewport(title="Orchestrated Discussions", width=1400, height=900) # Set up theme self._setup_theme() # Build the UI self._build_ui() def _setup_theme(self): """Set up the application theme.""" with dpg.theme() as self.global_theme: with dpg.theme_component(dpg.mvAll): dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 4) dpg.add_theme_style(dpg.mvStyleVar_WindowRounding, 4) dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 8, 4) dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 8, 4) dpg.bind_theme(self.global_theme) # Vote color themes with dpg.theme() as self.ready_theme: with dpg.theme_component(dpg.mvText): dpg.add_theme_color(dpg.mvThemeCol_Text, (100, 255, 100)) with dpg.theme() as self.changes_theme: with dpg.theme_component(dpg.mvText): dpg.add_theme_color(dpg.mvThemeCol_Text, (255, 200, 100)) with dpg.theme() as self.reject_theme: with dpg.theme_component(dpg.mvText): dpg.add_theme_color(dpg.mvThemeCol_Text, (255, 100, 100)) # Selection theme for buttons with dpg.theme() as self.selected_theme: with dpg.theme_component(dpg.mvButton): dpg.add_theme_color(dpg.mvThemeCol_Button, (60, 100, 160)) dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (80, 120, 180)) dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (100, 140, 200)) def _build_ui(self): """Build the main UI layout.""" # File dialogs (must be created before main window) with dpg.file_dialog(directory_selector=False, show=False, callback=self._on_file_selected, tag="file_dialog", width=700, height=400): dpg.add_file_extension(".discussion.md", color=(150, 255, 150)) dpg.add_file_extension(".md", color=(150, 150, 255)) with dpg.file_dialog(directory_selector=True, show=False, callback=self._on_folder_selected, tag="folder_dialog", width=700, height=400): pass # Main window with dpg.window(label="Main", tag="main_window", no_title_bar=True, no_move=True, no_resize=True, no_collapse=True): # Menu bar with dpg.menu_bar(): with dpg.menu(label="File"): dpg.add_menu_item(label="Open File...", callback=self._show_open_file_dialog) dpg.add_menu_item(label="Open Folder...", callback=self._show_open_folder_dialog) dpg.add_separator() dpg.add_menu_item(label="New Discussion...", callback=self._show_new_discussion_dialog) dpg.add_menu_item(label="Manage Templates...", callback=self._show_manage_templates_dialog) dpg.add_menu_item(label="Manage Participants...", callback=self._show_manage_participants_dialog) dpg.add_separator() dpg.add_menu_item(label="Refresh", callback=self._refresh_discussions) dpg.add_separator() dpg.add_menu_item(label="Quit", callback=lambda: dpg.stop_dearpygui()) with dpg.menu(label="Discussion"): dpg.add_menu_item(label="Run Turn", callback=self._run_turn, tag="menu_run_turn") dpg.add_menu_item(label="Add Comment", callback=self._show_comment_dialog, tag="menu_comment") dpg.add_separator() dpg.add_menu_item(label="View Diagrams", callback=self._show_diagram_dialog, tag="menu_diagrams") with dpg.menu(label="Help"): dpg.add_menu_item(label="Keyboard Shortcuts", callback=self._show_shortcuts) # Main content area with splitter with dpg.group(horizontal=True): # Left panel: Discussion browser with dpg.child_window(width=350, tag="browser_panel"): with dpg.group(horizontal=True): dpg.add_text("Discussions", color=(150, 200, 255)) dpg.add_spacer(width=80) dpg.add_button(label="+ New", callback=self._show_new_discussion_dialog, width=80) dpg.add_separator() # Discussion list with dpg.child_window(tag="discussion_list", height=-1): dpg.add_text("Loading...", tag="loading_text") # Right panel: Discussion view with dpg.child_window(tag="content_panel"): # Header with dpg.group(horizontal=True): dpg.add_text("Select a discussion", tag="discussion_title", color=(200, 200, 255)) dpg.add_spacer(width=20) dpg.add_text("", tag="discussion_status") dpg.add_text("", tag="discussion_phase") dpg.add_separator() # Content tabs with dpg.tab_bar(tag="content_tabs"): with dpg.tab(label="Discussion", tag="tab_discussion"): with dpg.child_window(tag="discussion_content", height=-80): dpg.add_text("Select a discussion from the left panel.", tag="content_placeholder") with dpg.tab(label="Diagrams", tag="tab_diagrams"): with dpg.child_window(tag="diagram_panel", height=-80): dpg.add_text("No diagrams in this discussion.", tag="diagram_placeholder") with dpg.tab(label="Output", tag="tab_output"): with dpg.child_window(tag="output_panel", height=-80): dpg.add_text("Turn output will appear here.", tag="output_placeholder") # Action buttons dpg.add_separator() with dpg.group(horizontal=True): dpg.add_button(label="Run Turn (T)", callback=self._run_turn, tag="btn_run_turn", enabled=False) dpg.add_button(label="Add Comment (C)", callback=self._show_comment_dialog, tag="btn_comment", enabled=False) dpg.add_button(label="View Diagrams (D)", callback=self._show_diagram_dialog, tag="btn_diagrams", enabled=False) dpg.add_button(label="Refresh (R)", callback=self._refresh_current) dpg.add_spacer(width=20) dpg.add_button(label="Edit", callback=self._show_edit_discussion_dialog, tag="btn_edit", enabled=False) dpg.add_button(label="Delete", callback=self._confirm_delete_discussion, tag="btn_delete", enabled=False) # Vote summary with dpg.group(horizontal=True, tag="vote_summary_group"): dpg.add_text("Votes: ", tag="votes_label") dpg.add_text("", tag="votes_ready") dpg.add_text("", tag="votes_changes") dpg.add_text("", tag="votes_reject") # Keyboard handler with dpg.handler_registry(): dpg.add_key_press_handler(dpg.mvKey_Q, callback=self._on_quit) dpg.add_key_press_handler(dpg.mvKey_R, callback=self._on_refresh) dpg.add_key_press_handler(dpg.mvKey_T, callback=self._on_turn) dpg.add_key_press_handler(dpg.mvKey_C, callback=self._on_comment) dpg.add_key_press_handler(dpg.mvKey_D, callback=self._on_diagrams) dpg.add_key_press_handler(dpg.mvKey_N, callback=self._on_new_discussion) dpg.add_key_press_handler(dpg.mvKey_Escape, callback=self._on_escape) # Set primary window dpg.set_primary_window("main_window", True) def _show_open_file_dialog(self): """Show the open file dialog.""" dpg.set_value("file_dialog", str(self.discussions_dir)) dpg.show_item("file_dialog") def _show_open_folder_dialog(self): """Show the open folder dialog.""" dpg.set_value("folder_dialog", str(self.discussions_dir)) dpg.show_item("folder_dialog") def _on_file_selected(self, sender, app_data): """Handle file selection from file dialog.""" selections = app_data.get("selections", {}) if selections: # Get the first selected file file_path = list(selections.values())[0] self._open_discussion(Path(file_path)) def _on_folder_selected(self, sender, app_data): """Handle folder selection from folder dialog.""" folder_path = app_data.get("file_path_name", "") if folder_path: self.discussions_dir = Path(folder_path) self._refresh_discussions() def _browse_for_folder(self, target_input_tag: str): """Show folder browser and update a target input field with selected path. Args: target_input_tag: Tag of the input_text widget to update with selection """ # Create a one-time folder dialog for this specific purpose dialog_tag = f"browse_folder_{target_input_tag}" def on_select(sender, app_data): folder_path = app_data.get("file_path_name", "") if folder_path and dpg.does_item_exist(target_input_tag): dpg.set_value(target_input_tag, folder_path) dpg.delete_item(dialog_tag) def on_cancel(sender, app_data): dpg.delete_item(dialog_tag) # Get current value to start from current = dpg.get_value(target_input_tag) if dpg.does_item_exist(target_input_tag) else str(Path.cwd()) with dpg.file_dialog( directory_selector=True, show=True, callback=on_select, cancel_callback=on_cancel, tag=dialog_tag, default_path=current, width=700, height=400 ): pass def _refresh_discussions(self): """Refresh the list of discussions.""" self.discussions_list = [] # Clear existing items dpg.delete_item("discussion_list", children_only=True) if not self.discussions_dir.exists(): dpg.add_text(f"Directory not found: {self.discussions_dir}", parent="discussion_list") return # Only scan for .discussion.md files md_files = sorted( self.discussions_dir.glob("**/*.discussion.md"), key=lambda p: p.stat().st_mtime, reverse=True ) for path in md_files: try: d = Discussion.load(path) self.discussions_list.append((path, d)) except Exception: pass # Build list UI if not self.discussions_list: dpg.add_text("No discussions found.", parent="discussion_list") return for path, d in self.discussions_list: votes = d.get_votes() vote_str = format_vote_summary(votes) if votes else "No votes" with dpg.group(parent="discussion_list"): # Clickable title dpg.add_button( label=d.title or path.stem, callback=lambda s, a, u: self._open_discussion(u), user_data=path, width=-1 ) # Status line with dpg.group(horizontal=True): dpg.add_text(f" [{d.status}]", color=(150, 150, 150)) dpg.add_text(f" {d.phase}", color=(100, 150, 200)) dpg.add_text(f" {vote_str}", color=(120, 120, 120)) dpg.add_spacer(height=5) def _open_discussion(self, path: Path): """Open a discussion for viewing.""" try: self.current_discussion = Discussion.load(path) self._show_discussion() except Exception as e: self._show_error(f"Error loading discussion: {e}") def _show_discussion(self): """Display the current discussion.""" if not self.current_discussion: return d = self.current_discussion # Update header dpg.set_value("discussion_title", d.title or "Untitled") dpg.set_value("discussion_status", f"[{d.status}]") dpg.set_value("discussion_phase", f"Phase: {d.phase}") # Update vote summary votes = d.get_votes() if votes: ready = sum(1 for v in votes.values() if v == "READY") changes = sum(1 for v in votes.values() if v == "CHANGES") reject = sum(1 for v in votes.values() if v == "REJECT") dpg.set_value("votes_ready", f"READY: {ready} ") dpg.set_value("votes_changes", f"CHANGES: {changes} ") dpg.set_value("votes_reject", f"REJECT: {reject}") dpg.bind_item_theme("votes_ready", self.ready_theme) dpg.bind_item_theme("votes_changes", self.changes_theme) dpg.bind_item_theme("votes_reject", self.reject_theme) else: dpg.set_value("votes_ready", "No votes yet") dpg.set_value("votes_changes", "") dpg.set_value("votes_reject", "") # Enable action buttons dpg.configure_item("btn_run_turn", enabled=True) dpg.configure_item("btn_comment", enabled=True) dpg.configure_item("btn_diagrams", enabled=True) dpg.configure_item("btn_edit", enabled=True) dpg.configure_item("btn_delete", enabled=True) # Clear and rebuild content dpg.delete_item("discussion_content", children_only=True) # Add context/preamble (selectable) context = d.get_context() if context: with dpg.group(parent="discussion_content"): dpg.add_text("Context", color=(150, 200, 255)) dpg.add_separator() # Selectable text area num_lines = min(context.count('\n') + 1, 15) dpg.add_input_text( default_value=context, multiline=True, readonly=True, width=-1, height=num_lines * 18 + 10, tab_input=False ) dpg.add_spacer(height=10) # Add comments for comment in d.comments: self._add_comment_widget(comment) # Update diagrams tab self._update_diagrams_panel() # Switch to discussion tab dpg.set_value("content_tabs", "tab_discussion") def _add_comment_widget(self, comment: Comment): """Add a comment widget to the discussion view.""" parent = "discussion_content" # Generate unique tag for this comment's read button self._comment_counter += 1 button_tag = f"read_btn_{self._comment_counter}" with dpg.group(parent=parent): dpg.add_separator() # Author and vote header with read-aloud button with dpg.group(horizontal=True): author_color = self._get_color_for_author(comment.author) dpg.add_text(comment.author, color=author_color) if comment.vote: vote_color = { "READY": (100, 255, 100), "CHANGES": (255, 200, 100), "REJECT": (255, 100, 100), }.get(comment.vote, (200, 200, 200)) dpg.add_text(f" [{comment.vote}]", color=vote_color) dpg.add_spacer(width=10) # Read aloud button with unique tag for state tracking dpg.add_button( label="Read", tag=button_tag, callback=self._read_aloud_clicked, user_data=(button_tag, comment.body, comment.author), width=50, height=20 ) # Comment body (selectable) dpg.add_spacer(height=5) body = comment.body num_lines = min(body.count('\n') + 1, 20) # Max 20 lines visible dpg.add_input_text( default_value=body, multiline=True, readonly=True, width=-1, height=max(num_lines * 18 + 10, 60), # Min height 60 tab_input=False ) dpg.add_spacer(height=10) def _update_diagrams_panel(self): """Update the diagrams panel with available diagrams.""" dpg.delete_item("diagram_panel", children_only=True) # Add "New Artifact" button at top with dpg.group(parent="diagram_panel", horizontal=True): dpg.add_button( label="New Artifact", callback=lambda: self._create_new_artifact(), width=100 ) dpg.add_text("(opens editor, adds to discussion)", color=(150, 150, 150)) dpg.add_separator(parent="diagram_panel") if not self.current_discussion: dpg.add_text("No discussion loaded.", parent="diagram_panel") return # Find diagrams using the marker system diagrams = self._find_diagrams() if not diagrams: dpg.add_text("No diagrams found in this discussion.", parent="diagram_panel") dpg.add_text("Diagrams are referenced with DIAGRAM: markers.", parent="diagram_panel", color=(150, 150, 150)) return dpg.add_text(f"Found {len(diagrams)} diagram(s):", parent="diagram_panel", color=(150, 200, 255)) dpg.add_separator(parent="diagram_panel") for diagram_path in diagrams: resolved = self._resolve_diagram_path(diagram_path) name = Path(diagram_path).name with dpg.group(parent="diagram_panel"): with dpg.group(horizontal=True): dpg.add_button( label=f"View: {name}", callback=lambda s, a, u: self._view_diagram(u), user_data=resolved ) dpg.add_button( label="Edit", callback=lambda s, a, u: self._launch_artifact_editor(u), user_data=resolved, width=50 ) if Path(resolved).exists(): dpg.add_text("(exists)", color=(100, 255, 100)) else: dpg.add_text("(not found)", color=(255, 100, 100)) dpg.add_spacer(height=5) def _find_diagrams(self) -> list[str]: """Find diagram references in current discussion.""" diagrams = [] if not self.current_discussion: return diagrams # Use the markers module content = self.current_discussion._raw_content # Look for DIAGRAM: markers import re for match in re.finditer(r'^DIAGRAM:\s*(.+)$', content, re.MULTILINE | re.IGNORECASE): path = match.group(1).strip() if path not in diagrams: diagrams.append(path) # Also check for **Diagram:** `path` format (legacy) for match in re.finditer(r'\*\*Diagram:\*\*\s*`([^`]+)`', content): path = match.group(1) if path not in diagrams: diagrams.append(path) return diagrams def _resolve_diagram_path(self, diagram_path: str) -> str: """Resolve a diagram path to an absolute path.""" if diagram_path.startswith('/'): return diagram_path candidates = [ Path.cwd() / diagram_path, self.current_discussion.path.parent / diagram_path, self.discussions_dir / diagram_path, ] for candidate in candidates: if candidate.exists(): return str(candidate) return str(Path.cwd() / diagram_path) def _get_artifact_editor_cmd(self) -> tuple[list[str], dict | None]: """Get the command and environment for launching artifact editor. Returns: Tuple of (command_list, env_dict or None) """ import shutil artifact_editor = shutil.which('artifact-editor') if artifact_editor: return [artifact_editor], None # Try direct path to project project_path = Path.home() / "PycharmProjects" / "artifact-editor" if (project_path / "src" / "artifact_editor" / "cli.py").exists(): env = os.environ.copy() env["PYTHONPATH"] = str(project_path / "src") return ["python3", "-m", "artifact_editor.cli"], env return None, None def _launch_artifact_editor(self, file_path: str = None): """Launch the stand-alone artifact editor (non-blocking). Args: file_path: Optional path to file to edit. If None, opens empty editor. """ cmd, env = self._get_artifact_editor_cmd() if not cmd: self._show_error( "Artifact Editor not installed.\n\n" "Install from ~/PycharmProjects/artifact-editor:\n" " cd ~/PycharmProjects/artifact-editor\n" " ./install.sh\n\n" "Or run directly with:\n" " PYTHONPATH=src python3 -m artifact_editor.cli" ) return if file_path: cmd.extend(["--output", str(file_path)]) # Launch in background (non-blocking) try: subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, env=env, ) self._add_output(f"Launched artifact editor" + (f" for {Path(file_path).name}" if file_path else "")) except Exception as e: self._show_error(f"Failed to launch artifact editor: {e}") def _launch_artifact_editor_blocking(self, file_path: str) -> str | None: """Launch artifact editor and wait for it to complete. Args: file_path: Suggested output path for the artifact. Returns: The actual saved file path from ARTIFACT_SAVED output, or None if cancelled/failed. """ cmd, env = self._get_artifact_editor_cmd() if not cmd: self._add_output("Error: Artifact editor not found") return None cmd.extend(["--output", str(file_path)]) try: result = subprocess.run( cmd, capture_output=True, text=True, env=env, ) # Log any stderr output for debugging if result.stderr: self._add_output(f"Artifact editor: {result.stderr[:200]}") # Check return code if result.returncode != 0: self._add_output(f"Artifact editor exited with code {result.returncode}") # Parse ARTIFACT_SAVED:path from stdout for line in result.stdout.splitlines(): if line.startswith("ARTIFACT_SAVED:"): return line.split(":", 1)[1].strip() return None except Exception as e: self._add_output(f"Error launching artifact editor: {e}") return None def _create_new_artifact(self): """Create a new artifact via the artifact editor and add it to the discussion. Opens the artifact editor, waits for save, then adds a comment to the discussion with the DIAGRAM reference. """ if not self.current_discussion: self._show_error("No discussion loaded") return # Check if artifact editor is available before proceeding cmd, env = self._get_artifact_editor_cmd() if not cmd: self._show_error( "Artifact Editor not installed.\n\n" "Install from ~/PycharmProjects/artifact-editor:\n" " cd ~/PycharmProjects/artifact-editor\n" " ./install.sh\n\n" "Or install with pip:\n" " pip install -e ~/PycharmProjects/artifact-editor" ) return # Create diagrams directory if needed diagrams_dir = self.current_discussion.path.parent / "diagrams" diagrams_dir.mkdir(exist_ok=True) # Generate suggested filename import re title = getattr(self.current_discussion, 'title', 'artifact') or 'artifact' safe_title = re.sub(r'[^a-z0-9]+', '-', title.lower())[:50] user = os.environ.get("USER", "human") # Find next number - check all formats existing = list(diagrams_dir.glob(f"{safe_title}_{user}_*.*")) next_num = len(existing) + 1 suggested_file = diagrams_dir / f"{safe_title}_{user}_{next_num}.puml" self._add_output("Opening artifact editor...") import threading def run_editor(): saved_path = self._launch_artifact_editor_blocking(str(suggested_file)) if saved_path: # Make path relative for the DIAGRAM marker try: rel_path = Path(saved_path).relative_to(self.current_discussion.path.parent) except ValueError: rel_path = Path(saved_path).name # Add a comment with the diagram reference author = os.environ.get("USER", "Human") comment_text = f"Added diagram.\n\nDIAGRAM: {rel_path}" self.current_discussion.add_comment(author, comment_text) self.current_discussion.save() # Refresh the view self.current_discussion = Discussion.load(self.current_discussion.path) self._show_discussion() self._add_output(f"Added artifact: {rel_path}") else: self._add_output("Artifact editor closed without saving") thread = threading.Thread(target=run_editor, daemon=True) thread.start() def _view_diagram(self, diagram_path: str): """View a diagram rendered as PNG. Supports multiple formats. Supported formats: - PlantUML (.puml, .plantuml) - Mermaid (.mmd, .mermaid) - Graphviz/DOT (.dot, .gv) - OpenSCAD (.scad) - SVG (.svg) - displayed directly """ if not Path(diagram_path).exists(): self._show_error(f"Diagram file not found: {diagram_path}") return # Detect format fmt = detect_format(diagram_path) if detect_format else None if not fmt: # Default to plantuml for .puml files (legacy) if diagram_path.endswith('.puml'): fmt = 'plantuml' else: self._show_error(f"Unknown diagram format: {diagram_path}") return # Check renderer availability renderer_info = get_renderer(fmt) if get_renderer else None if renderer_info and renderer_info.command and not renderer_info.available: self._show_error( f"Renderer for {fmt} not available.\n\n" f"Install with: {renderer_info.install_hint}" ) return # Handle SVG directly (no rendering needed) if fmt == 'svg': # Convert SVG to PNG for display success, result = render_to_png(diagram_path) if render_to_png else (False, "render_to_png not available") if success: self._show_image_window(result, Path(diagram_path).name, diagram_path, diagram_path) else: # Try displaying SVG directly by reading and showing info self._show_error(f"SVG display: {result}") return # Render to PNG using format-specific renderer success, result = render_to_png(diagram_path) if render_to_png else (False, "render_to_png not available") if not success: self._show_error(f"Render failed: {result}") return actual_png = result # For PlantUML, also try to generate SVG for element detection actual_svg = None if fmt == 'plantuml': try: subprocess.run( ['plantuml', '-tsvg', '-o', str(Path(actual_png).parent), diagram_path], capture_output=True, text=True, timeout=30 ) expected_svg = Path(diagram_path).with_suffix('.svg') if expected_svg.exists(): actual_svg = str(expected_svg) except Exception: pass # SVG is optional for click detection self._show_image_window(actual_png, Path(diagram_path).name, diagram_path, actual_svg) def _show_image_window(self, png_path: str, title: str, puml_path: str = None, svg_path: str = None): """Show diagram viewer/editor window with View and Edit modes.""" import time unique_id = int(time.time() * 1000) % 1000000 window_tag = f"diagram_window_{unique_id}" texture_tag = f"texture_{unique_id}" # Ensure we have a texture registry if not dpg.does_item_exist("__texture_registry"): dpg.add_texture_registry(tag="__texture_registry") # Track textures for cleanup if not hasattr(self, '_active_textures'): self._active_textures = [] if not hasattr(self, '_textures_to_delete'): self._textures_to_delete = [] # Track active diagram windows for keyboard handling if not hasattr(self, '_active_diagram_windows'): self._active_diagram_windows = set() self._active_diagram_windows.add(window_tag) # State for this editor instance editor_state = { 'puml_path': puml_path, 'svg_path': svg_path, 'original_source': Path(puml_path).read_text() if puml_path and Path(puml_path).exists() else '', 'current_source': '', 'texture_tag': texture_tag, 'edit_mode': False, 'has_changes': False, 'ai_running': False, 'image_size': (0, 0), } editor_state['current_source'] = editor_state['original_source'] def load_and_display_image(png_file): """Load image and update texture.""" try: width, height, channels, data = dpg.load_image(png_file) # Store image dimensions for click detection editor_state['image_size'] = (width, height) # Delete old texture if exists if dpg.does_item_exist(editor_state['texture_tag']): self._textures_to_delete.append(editor_state['texture_tag']) # Create new texture with new unique id new_texture_tag = f"texture_{int(time.time() * 1000) % 1000000}" dpg.add_static_texture(width, height, data, tag=new_texture_tag, parent="__texture_registry") self._active_textures.append(new_texture_tag) editor_state['texture_tag'] = new_texture_tag # Update image widget if dpg.does_item_exist(f"preview_image_{unique_id}"): dpg.configure_item(f"preview_image_{unique_id}", texture_tag=new_texture_tag) return True except Exception as e: return False def render_preview(): """Re-render the PlantUML and update preview.""" if not editor_state['puml_path']: return # Get current source from editor if dpg.does_item_exist(f"source_editor_{unique_id}"): editor_state['current_source'] = dpg.get_value(f"source_editor_{unique_id}") # Write to temp file and render both PNG and SVG try: with tempfile.NamedTemporaryFile(mode='w', suffix='.puml', delete=False) as tmp: tmp.write(editor_state['current_source']) tmp_puml = tmp.name # Generate PNG subprocess.run( ['plantuml', '-tpng', tmp_puml], capture_output=True, text=True, timeout=30 ) # Generate SVG for element detection subprocess.run( ['plantuml', '-tsvg', tmp_puml], capture_output=True, text=True, timeout=30 ) tmp_png = Path(tmp_puml).with_suffix('.png') tmp_svg = Path(tmp_puml).with_suffix('.svg') if tmp_png.exists(): load_and_display_image(str(tmp_png)) tmp_png.unlink() # Clean up if tmp_svg.exists(): tmp_svg.unlink() # Clean up Path(tmp_puml).unlink() # Clean up dpg.configure_item(f"status_{unique_id}", default_value="Preview updated", color=(100, 255, 100)) except Exception as e: dpg.configure_item(f"status_{unique_id}", default_value=f"Render error: {e}", color=(255, 100, 100)) def toggle_edit_mode(): """Switch between View and Edit modes.""" editor_state['edit_mode'] = not editor_state['edit_mode'] if editor_state['edit_mode']: # Show edit panels dpg.configure_item(f"edit_panel_{unique_id}", show=True) dpg.configure_item(f"prompt_panel_{unique_id}", show=True) dpg.configure_item(f"btn_edit_{unique_id}", label="View") dpg.configure_item(f"btn_save_{unique_id}", show=True) dpg.configure_item(f"btn_discard_{unique_id}", show=True) # Show edit mode hint dpg.configure_item(f"status_{unique_id}", default_value="Edit mode - modify source code on the left", color=(150, 200, 255)) else: # Hide edit panels dpg.configure_item(f"edit_panel_{unique_id}", show=False) dpg.configure_item(f"prompt_panel_{unique_id}", show=False) dpg.configure_item(f"btn_edit_{unique_id}", label="Edit") dpg.configure_item(f"btn_save_{unique_id}", show=False) dpg.configure_item(f"btn_discard_{unique_id}", show=False) dpg.configure_item(f"status_{unique_id}", default_value="", color=(150, 150, 150)) def on_source_changed(): """Called when source text changes.""" if dpg.does_item_exist(f"source_editor_{unique_id}"): new_source = dpg.get_value(f"source_editor_{unique_id}") editor_state['has_changes'] = (new_source != editor_state['original_source']) editor_state['current_source'] = new_source def apply_ai_edit(): """Apply AI-assisted edit based on prompt.""" if editor_state['ai_running']: return prompt_text = dpg.get_value(f"prompt_input_{unique_id}") if not prompt_text.strip(): dpg.configure_item(f"status_{unique_id}", default_value="Enter an instruction first", color=(255, 200, 100)) return editor_state['ai_running'] = True dpg.configure_item(f"status_{unique_id}", default_value="⏳ AI is processing your request...", color=(150, 150, 255)) dpg.configure_item(f"btn_apply_{unique_id}", enabled=False, label="Processing...") # Run in background thread def run_ai_edit(): try: current_source = editor_state['current_source'] result = subprocess.run( ['discussion-diagram-editor', '--instruction', prompt_text], input=current_source, capture_output=True, text=True, timeout=60 ) if result.returncode == 0 and result.stdout.strip(): # Queue the update for main thread editor_state['pending_source'] = result.stdout.strip() editor_state['ai_success'] = True else: editor_state['ai_error'] = result.stderr or "AI edit failed" editor_state['ai_success'] = False except Exception as e: editor_state['ai_error'] = str(e) editor_state['ai_success'] = False finally: editor_state['ai_running'] = False editor_state['ai_complete'] = True thread = threading.Thread(target=run_ai_edit, daemon=True) thread.start() def save_diagram(): """Save the modified diagram to file and optionally to discussion.""" if not editor_state['puml_path']: return try: # Save to file Path(editor_state['puml_path']).write_text(editor_state['current_source']) editor_state['original_source'] = editor_state['current_source'] editor_state['has_changes'] = False # Re-render to update the actual file's PNG subprocess.run( ['plantuml', '-tpng', editor_state['puml_path']], capture_output=True, timeout=30 ) dpg.configure_item(f"status_{unique_id}", default_value="Saved!", color=(100, 255, 100)) # Clear prompt dpg.set_value(f"prompt_input_{unique_id}", "") except Exception as e: dpg.configure_item(f"status_{unique_id}", default_value=f"Save error: {e}", color=(255, 100, 100)) def discard_changes(): """Discard changes and revert to original.""" editor_state['current_source'] = editor_state['original_source'] editor_state['has_changes'] = False if dpg.does_item_exist(f"source_editor_{unique_id}"): dpg.set_value(f"source_editor_{unique_id}", editor_state['original_source']) dpg.set_value(f"prompt_input_{unique_id}", "") render_preview() dpg.configure_item(f"status_{unique_id}", default_value="Changes discarded", color=(200, 200, 100)) def close_window(): """Clean up and close.""" try: if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) if editor_state['texture_tag'] in self._active_textures: self._active_textures.remove(editor_state['texture_tag']) self._textures_to_delete.append(editor_state['texture_tag']) # Remove from active diagram windows if hasattr(self, '_active_diagram_windows'): self._active_diagram_windows.discard(window_tag) except Exception: pass def poll_ai_completion(): """Check if AI edit completed (called from main poll loop).""" if editor_state.get('ai_complete'): editor_state['ai_complete'] = False dpg.configure_item(f"btn_apply_{unique_id}", enabled=True, label="Apply AI Edit") if editor_state.get('ai_success') and 'pending_source' in editor_state: # Update source editor dpg.set_value(f"source_editor_{unique_id}", editor_state['pending_source']) editor_state['current_source'] = editor_state['pending_source'] editor_state['has_changes'] = True del editor_state['pending_source'] # Re-render preview render_preview() dpg.configure_item(f"status_{unique_id}", default_value="AI edit applied!", color=(100, 255, 100)) else: error = editor_state.get('ai_error', 'Unknown error') dpg.configure_item(f"status_{unique_id}", default_value=f"AI error: {error}", color=(255, 100, 100)) # Store poll function for main loop if not hasattr(self, '_diagram_editors'): self._diagram_editors = {} self._diagram_editors[unique_id] = poll_ai_completion # Load initial image try: width, height, channels, data = dpg.load_image(png_path) editor_state['image_size'] = (width, height) dpg.add_static_texture(width, height, data, tag=texture_tag, parent="__texture_registry") self._active_textures.append(texture_tag) except Exception as e: self._show_error(f"Failed to load image: {e}") return # Window state editor_state['maximized'] = False editor_state['normal_size'] = (1000, 700) editor_state['normal_pos'] = (100, 100) def toggle_maximize(): """Toggle between normal and maximized window.""" if editor_state['maximized']: # Restore to normal size dpg.configure_item(window_tag, width=editor_state['normal_size'][0], height=editor_state['normal_size'][1], pos=editor_state['normal_pos']) dpg.configure_item(f"btn_maximize_{unique_id}", label="Maximize Window") editor_state['maximized'] = False else: # Save current size and position editor_state['normal_size'] = (dpg.get_item_width(window_tag), dpg.get_item_height(window_tag)) editor_state['normal_pos'] = dpg.get_item_pos(window_tag) # Maximize to viewport vp_width = dpg.get_viewport_width() vp_height = dpg.get_viewport_height() dpg.configure_item(window_tag, width=vp_width - 20, height=vp_height - 40, pos=(10, 30)) dpg.configure_item(f"btn_maximize_{unique_id}", label="Restore Window") editor_state['maximized'] = True def add_line_numbers(source): """Add line numbers to source code for display.""" lines = source.split('\n') max_digits = len(str(len(lines))) numbered = [] for i, line in enumerate(lines, 1): numbered.append(f"{i:>{max_digits}}| {line}") return '\n'.join(numbered) def remove_line_numbers(numbered_source): """Remove line numbers from source code for saving.""" import re lines = numbered_source.split('\n') cleaned = [] for line in lines: # Remove the " N| " prefix match = re.match(r'^\s*\d+\|\s?(.*)$', line) if match: cleaned.append(match.group(1)) else: cleaned.append(line) return '\n'.join(cleaned) # Build the window with dpg.window(label=f"Diagram: {title}", tag=window_tag, width=1000, height=700, pos=[100, 100], on_close=lambda: (self._diagram_editors.pop(unique_id, None), close_window())): # Top toolbar with window controls on the right with dpg.group(horizontal=True): dpg.add_button(label="Edit", tag=f"btn_edit_{unique_id}", callback=toggle_edit_mode) dpg.add_button(label="Refresh Preview", callback=render_preview) dpg.add_button(label="Save", tag=f"btn_save_{unique_id}", callback=save_diagram, show=False) dpg.add_button(label="Discard", tag=f"btn_discard_{unique_id}", callback=discard_changes, show=False) dpg.add_spacer(width=10) dpg.add_text("", tag=f"status_{unique_id}", color=(150, 150, 150)) dpg.add_spacer(width=20) dpg.add_button(label="Maximize Window", tag=f"btn_maximize_{unique_id}", callback=toggle_maximize) dpg.add_separator() # Main content area with dpg.group(horizontal=True, tag=f"main_content_{unique_id}"): # Left panel: Source editor (hidden by default) with dpg.child_window(width=400, tag=f"edit_panel_{unique_id}", show=False): dpg.add_text("PlantUML Source", color=(150, 200, 255)) dpg.add_input_text( tag=f"source_editor_{unique_id}", default_value=editor_state['current_source'], multiline=True, width=-1, height=-1, callback=on_source_changed, on_enter=False, tab_input=True ) # Right panel: Preview (always visible) with dpg.child_window(tag=f"preview_panel_{unique_id}"): dpg.add_text("Preview", color=(150, 200, 255)) with dpg.child_window(horizontal_scrollbar=True, tag=f"preview_scroll_{unique_id}"): dpg.add_image(texture_tag, tag=f"preview_image_{unique_id}") # Bottom panel: Prompt input (hidden by default) with dpg.child_window(height=80, tag=f"prompt_panel_{unique_id}", show=False): dpg.add_text("Describe changes:", color=(150, 200, 255)) with dpg.group(horizontal=True): dpg.add_input_text( tag=f"prompt_input_{unique_id}", hint="e.g., 'Add a cache component between API and Database'", width=-150 ) dpg.add_button(label="Apply AI Edit", tag=f"btn_apply_{unique_id}", callback=apply_ai_edit) dpg.add_separator() dpg.add_button(label="Close", callback=close_window) def _run_turn(self): """Run a discussion turn with all participants.""" if not self.current_discussion or self._turn_running: return self._turn_running = True # Switch to output tab dpg.set_value("content_tabs", "tab_output") # Clear output and create selectable output area dpg.delete_item("output_panel", children_only=True) self._output_lines = [] self._last_output_index = 0 # Create a single large selectable text area for output dpg.add_input_text( tag="output_text", default_value="Starting turn...\n", multiline=True, readonly=True, width=-1, height=-40, # Leave room for copy button parent="output_panel", tab_input=False ) with dpg.group(horizontal=True, parent="output_panel"): dpg.add_button(label="Copy Output", callback=self._copy_output) dpg.add_button(label="Clear", callback=lambda: dpg.set_value("output_text", "")) # Run in background thread thread = threading.Thread(target=self._run_turn_thread, daemon=True) thread.start() def _copy_output(self): """Copy output to clipboard.""" output = dpg.get_value("output_text") if self._copy_to_clipboard(output): # Brief visual feedback would be nice but we can't easily do that here pass def _add_output(self, text: str): """Add output line (thread-safe).""" self._output_lines.append(text) # Note: UI update happens via polling in main thread, not here # dpg.split_frame() is NOT thread-safe and causes segfaults def _run_turn_thread(self): """Background thread for running turn.""" d = self.current_discussion if not d: return participants = d.participant_aliases or ["architect", "security", "pragmatist"] # Create log file log_dir = Path.home() / ".local" / "share" / "orchestrated-discussions" / "logs" log_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") log_file = log_dir / f"turn_{timestamp}.log" self._add_output("=== Starting Turn ===") self._add_output(f"Discussion: {d.title}") self._add_output(f"Participants: {', '.join(participants)}") self._add_output(f"Log: {log_file}") self._add_output("") # Start all participants in parallel processes = {} discussion_content = d.get_content() for alias in participants: tool_name = f"discussion-{alias}" self._add_output(f">>> Starting {alias}...") try: process = subprocess.Popen( [tool_name, "--log-file", str(log_file)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) process.stdin.write(discussion_content) process.stdin.close() processes[alias] = process except FileNotFoundError: self._add_output(f" Tool {tool_name} not found") except Exception as e: self._add_output(f" ERROR: {e}") if not processes: self._add_output("No participants started!") self._turn_running = False return self._add_output("") self._add_output("Waiting for responses...") self._add_output("") # Tail log file while waiting last_pos = 0 import time while any(p.poll() is None for p in processes.values()): try: if log_file.exists(): with open(log_file, 'r') as f: f.seek(last_pos) for line in f.readlines(): self._add_output(line.rstrip()) last_pos = f.tell() except Exception: pass time.sleep(0.2) # Read remaining log entries try: if log_file.exists(): with open(log_file, 'r') as f: f.seek(last_pos) for line in f.readlines(): self._add_output(line.rstrip()) except Exception: pass # Collect responses self._add_output("") self._add_output("Collecting responses...") responses = [] for alias, process in processes.items(): try: stdout = process.stdout.read() if process.returncode != 0: self._add_output(f" {alias}: ERROR (exit {process.returncode})") continue json_start = stdout.find("{") json_end = stdout.rfind("}") + 1 if json_start >= 0 and json_end > json_start: response = json.loads(stdout[json_start:json_end]) if response.get("sentinel") == "NO_RESPONSE": self._add_output(f" {alias}: nothing to add") elif "comment" in response: vote = response.get("vote") diagram_file = response.get("diagram_file") vote_str = f" [{vote}]" if vote else "" diagram_str = " +diagram" if diagram_file else "" self._add_output(f" {alias}: responded{vote_str}{diagram_str}") comment_text = response["comment"] if diagram_file: comment_text += f"\n\nDIAGRAM: {diagram_file}" responses.append({ "author": f"AI-{alias.capitalize()}", "comment": comment_text, "vote": vote, }) else: self._add_output(f" {alias}: no JSON in response") except json.JSONDecodeError as e: self._add_output(f" {alias}: JSON parse error") except Exception as e: self._add_output(f" {alias}: ERROR: {e}") # Append responses if responses: self._add_output("") self._add_output("Appending responses...") d = Discussion.load(d.path) for resp in responses: d.add_comment( author=resp["author"], text=resp["comment"], vote=resp.get("vote"), ) d.save() self._add_output(f" Added {len(responses)} comments") # Count votes self._add_output("") self._add_output("Counting votes...") try: result = subprocess.run( ["discussion-parser"], input=Path(d.path).read_text(), capture_output=True, text=True, ) if result.returncode == 0: vote_result = subprocess.run( ["discussion-vote-counter"], input=result.stdout, capture_output=True, text=True, ) if vote_result.returncode == 0: votes = json.loads(vote_result.stdout) vs = votes.get("vote_summary", {}) self._add_output(f" READY={vs.get('READY', 0)} " f"CHANGES={vs.get('CHANGES', 0)} " f"REJECT={vs.get('REJECT', 0)}") consensus = votes.get("consensus", {}) if consensus.get("reached"): self._add_output(" Consensus: REACHED") # Auto-advance phase if in a voting phase self._try_advance_phase(d) elif consensus.get("reason"): self._add_output(f" Consensus: {consensus['reason']}") except Exception as e: self._add_output(f" Vote counting error: {e}") # Refresh view self._add_output("") self._add_output("=== Turn Complete ===") self._turn_running = False self._turn_complete = True # Signal main thread to refresh def _try_advance_phase(self, discussion: 'Discussion'): """Auto-advance phase if consensus reached in a voting phase.""" import yaml # Load template to check phase config template_name = discussion.template or "feature" template_dirs = [ Path.cwd() / "templates", Path(__file__).parent.parent.parent.parent / "templates", ] template = {} for template_dir in template_dirs: template_path = template_dir / f"{template_name}.yaml" if template_path.exists(): with open(template_path) as f: template = yaml.safe_load(f) or {} break phases = template.get("phases", {}) current_phase = discussion.phase phase_config = phases.get(current_phase, {}) # Only advance if this is a voting phase with a next_phase if not phase_config.get("voting", False): return next_phase = phase_config.get("next_phase") if not next_phase: return # Advance the phase d = Discussion.load(discussion.path) old_phase = d.phase d.update_phase(next_phase) d.save() self._add_output(f" Phase advanced: {old_phase} → {next_phase}") def _show_comment_dialog(self): """Show dialog for adding a human comment.""" if not self.current_discussion: return window_tag = "comment_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) with dpg.window(label="Add Comment", tag=window_tag, modal=True, width=600, height=400, pos=[400, 200]): with dpg.group(horizontal=True): dpg.add_text("Your comment:") dpg.add_spacer(width=10) dpg.add_button( label="Dictate", tag="dictate_btn", callback=lambda: self._dictate_clicked(), width=100 ) dpg.add_button( label="Add Artifact", callback=lambda: self._launch_artifact_editor_for_comment(), width=100 ) dpg.add_input_text(tag="comment_text", multiline=True, width=-1, height=200) dpg.add_spacer(height=10) dpg.add_text("Vote (optional):") dpg.add_radio_button( items=["None", "READY", "CHANGES", "REJECT"], tag="comment_vote", default_value="None", horizontal=True ) dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_button(label="Submit", callback=self._submit_comment) dpg.add_button(label="Cancel", callback=lambda: dpg.delete_item(window_tag)) def _submit_comment(self): """Submit the comment from the dialog.""" text = dpg.get_value("comment_text") vote_str = dpg.get_value("comment_vote") vote = vote_str if vote_str != "None" else None if text.strip(): author = os.environ.get("USER", "Human") self.current_discussion.add_comment(author, text, vote) self.current_discussion.save() # Refresh view self.current_discussion = Discussion.load(self.current_discussion.path) self._show_discussion() dpg.delete_item("comment_dialog") def _launch_artifact_editor_for_comment(self): """Launch artifact editor to create a new artifact for the current comment. Launches editor and waits for it to save. The DIAGRAM: marker is added to the comment only after we know the actual saved path (user may change format). """ if not self.current_discussion: self._show_error("No discussion loaded. Open a discussion first.") return # Check if artifact editor is available before proceeding cmd, env = self._get_artifact_editor_cmd() if not cmd: self._show_error( "Artifact Editor not installed.\n\n" "Install from ~/PycharmProjects/artifact-editor:\n" " cd ~/PycharmProjects/artifact-editor\n" " ./install.sh\n\n" "Or install with pip:\n" " pip install -e ~/PycharmProjects/artifact-editor" ) return # Create diagrams directory if needed diagrams_dir = self.current_discussion.path.parent / "diagrams" diagrams_dir.mkdir(exist_ok=True) # Generate suggested filename (artifact editor may change extension) import re title = getattr(self.current_discussion, 'title', 'artifact') or 'artifact' safe_title = re.sub(r'[^a-z0-9]+', '-', title.lower())[:50] user = os.environ.get("USER", "human") # Find next number - check all formats existing = list(diagrams_dir.glob(f"{safe_title}_{user}_*.*")) next_num = len(existing) + 1 suggested_file = diagrams_dir / f"{safe_title}_{user}_{next_num}.puml" # Launch editor and wait for result (in background thread) self._add_output("Opening artifact editor...") import threading def run_editor(): saved_path = self._launch_artifact_editor_blocking(str(suggested_file)) # Schedule UI update on main thread if saved_path: # Make path relative to diagrams dir for the DIAGRAM marker try: rel_path = Path(saved_path).relative_to(self.current_discussion.path.parent) except ValueError: rel_path = Path(saved_path).name # Update comment text with actual saved path if dpg.does_item_exist("comment_text"): current_text = dpg.get_value("comment_text") diagram_ref = f"\n\nDIAGRAM: {rel_path}" dpg.set_value("comment_text", current_text + diagram_ref) self._add_output(f"Added artifact: {rel_path}") else: self._add_output("Artifact editor closed without saving") thread = threading.Thread(target=run_editor, daemon=True) thread.start() def _show_diagram_dialog(self): """Show dialog for selecting a diagram to view.""" diagrams = self._find_diagrams() if not diagrams: self._show_error("No diagrams found in this discussion") return if len(diagrams) == 1: resolved = self._resolve_diagram_path(diagrams[0]) self._view_diagram(resolved) return window_tag = "diagram_select_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) # Track if selection was already made (double-click guard) self._diagram_selection_made = False def on_diagram_selected(sender, app_data, diagram_path): """Handle diagram selection with double-click guard.""" if self._diagram_selection_made: return # Ignore double-clicks self._diagram_selection_made = True # Close popup first if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) # Then view diagram self._view_diagram(diagram_path) def on_cancel(): if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) with dpg.window(label="Select Diagram", tag=window_tag, modal=True, width=400, height=300, pos=[500, 300]): dpg.add_text("Select a diagram to view:") dpg.add_separator() for diagram_path in diagrams: resolved = self._resolve_diagram_path(diagram_path) name = Path(diagram_path).name dpg.add_button( label=name, callback=on_diagram_selected, user_data=resolved, width=-1 ) dpg.add_separator() dpg.add_button(label="Cancel", callback=on_cancel) def _refresh_current(self): """Refresh the current discussion.""" if self.current_discussion: self._open_discussion(self.current_discussion.path) else: self._refresh_discussions() def _confirm_delete_discussion(self): """Show confirmation dialog before deleting a discussion.""" if not self.current_discussion: return window_tag = "delete_confirm_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) filename = self.current_discussion.path.name title = self.current_discussion.title or filename def do_delete(): try: path = self.current_discussion.path path.unlink() # Delete the file self.current_discussion = None # Clear the display dpg.set_value("discussion_title", "Select a discussion") dpg.set_value("discussion_status", "") dpg.set_value("discussion_phase", "") dpg.delete_item("discussion_content", children_only=True) dpg.add_text("Discussion deleted.", parent="discussion_content", color=(150, 150, 150)) # Disable buttons dpg.configure_item("btn_run_turn", enabled=False) dpg.configure_item("btn_comment", enabled=False) dpg.configure_item("btn_diagrams", enabled=False) dpg.configure_item("btn_edit", enabled=False) dpg.configure_item("btn_delete", enabled=False) # Refresh the list self._refresh_discussions() dpg.delete_item(window_tag) except Exception as e: dpg.delete_item(window_tag) self._show_error(f"Failed to delete: {e}") def cancel(): dpg.delete_item(window_tag) with dpg.window(label="Confirm Delete", tag=window_tag, modal=True, width=400, height=150, pos=[500, 350]): dpg.add_text("Are you sure you want to delete this discussion?", color=(255, 200, 100)) dpg.add_spacer(height=5) dpg.add_text(f" {title}", color=(200, 200, 255)) dpg.add_text(f" ({filename})", color=(150, 150, 150)) dpg.add_spacer(height=15) with dpg.group(horizontal=True): dpg.add_button(label="Delete", callback=do_delete, width=100) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=cancel, width=100) def _show_edit_discussion_dialog(self): """Show dialog for editing the discussion content.""" if not self.current_discussion: return window_tag = "edit_discussion_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) # Get current content content = self.current_discussion.get_content() def save_changes(): try: new_content = dpg.get_value("edit_content") # Update the raw content and save self.current_discussion._raw_content = new_content self.current_discussion._parse_content(new_content) self.current_discussion.save() # Refresh display self._show_discussion() dpg.delete_item(window_tag) except Exception as e: self._show_error(f"Failed to save: {e}") def cancel(): dpg.delete_item(window_tag) with dpg.window(label="Edit Discussion", tag=window_tag, width=800, height=600, pos=[300, 100], no_collapse=True): dpg.add_text("Edit the discussion markdown below:", color=(150, 200, 255)) dpg.add_text("(Metadata in comments, context before first ---, comments after)", color=(120, 120, 120)) dpg.add_spacer(height=5) dpg.add_input_text( tag="edit_content", default_value=content, multiline=True, width=-1, height=-60, tab_input=True ) dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_button(label="Save", callback=save_changes, width=100) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=cancel, width=100) def _show_info(self, message: str, title: str = "Info"): """Show an info popup.""" window_tag = "info_popup" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) with dpg.window(label=title, tag=window_tag, modal=True, width=400, height=120, pos=[500, 300], no_collapse=True): dpg.add_text(message, wrap=380) dpg.add_spacer(height=10) dpg.add_button(label="OK", callback=lambda: dpg.delete_item(window_tag), width=80) def _show_error(self, message: str): """Show an error popup with copyable text.""" window_tag = "error_popup" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) def copy_error(): """Copy error message to clipboard.""" if self._copy_to_clipboard(message): dpg.configure_item("copy_status", default_value="Copied!", color=(100, 255, 100)) else: dpg.configure_item("copy_status", default_value="Install xclip: sudo apt install xclip", color=(255, 200, 100)) with dpg.window(label="Error", tag=window_tag, modal=True, width=500, height=220, pos=[450, 350]): dpg.add_text("Error:", color=(255, 100, 100)) # Selectable text field dpg.add_input_text( default_value=message, multiline=True, readonly=True, width=-1, height=100 ) dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_button(label="Copy to Clipboard", callback=copy_error) dpg.add_text("", tag="copy_status", color=(150, 150, 150)) dpg.add_spacer(width=20) dpg.add_button(label="OK", callback=lambda: dpg.delete_item(window_tag)) def _get_templates(self) -> list[str]: """Get list of available template names.""" templates = [] # Check multiple locations for templates template_dirs = [ Path.cwd() / "templates", Path(__file__).parent.parent.parent.parent / "templates", ] for template_dir in template_dirs: if template_dir.exists(): for f in template_dir.glob("*.yaml"): name = f.stem if name not in templates: templates.append(name) return sorted(templates) if templates else ["feature", "brainstorm"] def _get_participants(self) -> list[tuple[str, str]]: """Get list of available participants as (alias, display_name) tuples.""" if get_registry is None: return [("architect", "AI-Architect"), ("security", "AI-Security"), ("pragmatist", "AI-Pragmatist")] registry = get_registry() participants = [] for p in registry.get_all(): participants.append((p.alias, p.name)) return sorted(participants) if participants else [ ("architect", "AI-Architect"), ("security", "AI-Security"), ("pragmatist", "AI-Pragmatist") ] def _show_new_discussion_dialog(self): """Show dialog for creating a new discussion.""" window_tag = "new_discussion_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) templates = self._get_templates() participants = self._get_participants() # State for the dialog dialog_state = { 'selected_participants': ["architect", "security", "pragmatist"], } def toggle_participant(sender, app_data, user_data): alias = user_data if alias in dialog_state['selected_participants']: dialog_state['selected_participants'].remove(alias) else: dialog_state['selected_participants'].append(alias) def on_template_change(sender, app_data): if app_data == "+ Create New Template...": # Reset combo to previous value, then open template dialog on top dpg.set_value("new_disc_template", templates[0] if templates else "feature") self._show_new_template_dialog(reopen_new_discussion=False) def create_discussion(): title = dpg.get_value("new_disc_title").strip() if not title: self._show_error("Please enter a title") return template = dpg.get_value("new_disc_template") context = dpg.get_value("new_disc_context").strip() selected = dialog_state['selected_participants'] if not selected: self._show_error("Please select at least one participant") return # Generate filename with .discussion.md extension slug = title.lower().replace(" ", "-") slug = "".join(c for c in slug if c.isalnum() or c == "-") # Get output directory from the location field output_dir = Path(dpg.get_value("new_disc_location")) if not output_dir.exists(): try: output_dir.mkdir(parents=True, exist_ok=True) except Exception as e: self._show_error(f"Cannot create directory: {e}") return output_path = output_dir / f"{slug}.discussion.md" # Check if file exists if output_path.exists(): self._show_error(f"File already exists: {output_path}") return try: discussion = Discussion.create( path=output_path, title=title, context=context, template=template, participants=selected, ) # Refresh and select the new discussion self._refresh_discussions() # Load and display the new discussion self.current_discussion = discussion self._show_discussion() dpg.delete_item(window_tag) except Exception as e: self._show_error(f"Failed to create discussion: {e}") # Determine initial output directory if self.current_discussion: initial_dir = self.current_discussion.path.parent else: initial_dir = self.discussions_dir if self.discussions_dir else Path.cwd() with dpg.window(label="New Discussion", tag=window_tag, width=550, height=550, pos=[400, 150], no_collapse=True): dpg.add_text("Title:", color=(150, 200, 255)) dpg.add_input_text(tag="new_disc_title", width=-1, hint="Enter discussion title") dpg.add_spacer(height=5) dpg.add_text("Location:", color=(150, 200, 255)) with dpg.group(horizontal=True): dpg.add_input_text( tag="new_disc_location", default_value=str(initial_dir), width=-80, readonly=False ) dpg.add_button(label="Browse", callback=lambda: self._browse_for_folder("new_disc_location")) dpg.add_spacer(height=10) dpg.add_text("Template:", color=(150, 200, 255)) template_items = templates + ["+ Create New Template..."] dpg.add_combo( items=template_items, tag="new_disc_template", default_value=templates[0] if templates else "feature", width=-1, callback=on_template_change ) dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_text("Participants:", color=(150, 200, 255)) dpg.add_spacer(width=20) def open_new_participant(): dpg.delete_item(window_tag) # Close New Discussion first self._show_new_participant_dialog(reopen_new_discussion=True) dpg.add_button(label="+ New Participant", callback=open_new_participant) with dpg.child_window(height=150, border=True): for alias, display_name in participants: is_default = alias in ["architect", "security", "pragmatist"] dpg.add_checkbox( label=f"@{alias} ({display_name})", default_value=is_default, callback=toggle_participant, user_data=alias ) dpg.add_spacer(height=10) dpg.add_text("Context/Description:", color=(150, 200, 255)) dpg.add_input_text( tag="new_disc_context", multiline=True, width=-1, height=100, hint="Describe what this discussion is about..." ) dpg.add_spacer(height=15) with dpg.group(horizontal=True): dpg.add_button(label="Create Discussion", callback=create_discussion, width=150) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=lambda: dpg.delete_item(window_tag), width=100) dpg.focus_item(window_tag) def _show_new_template_dialog(self, reopen_new_discussion=False): """Show dialog for creating a new template.""" window_tag = "new_template_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) # State for phases template_state = { 'phases': [ {'name': 'initial_feedback', 'goal': 'Gather initial perspectives', 'voting': False}, {'name': 'detailed_review', 'goal': 'Deep dive into details', 'voting': False}, {'name': 'consensus_vote', 'goal': 'Reach agreement', 'voting': True}, ] } def add_phase(): idx = len(template_state['phases']) template_state['phases'].append({ 'name': f'phase_{idx + 1}', 'goal': '', 'voting': False }) refresh_phases() def remove_phase(idx): if len(template_state['phases']) > 1: template_state['phases'].pop(idx) refresh_phases() def refresh_phases(): if dpg.does_item_exist("phases_container"): dpg.delete_item("phases_container", children_only=True) for i, phase in enumerate(template_state['phases']): with dpg.group(horizontal=True, parent="phases_container"): dpg.add_input_text( default_value=phase['name'], width=120, callback=lambda s, a, u=i: template_state['phases'].__setitem__(u, {**template_state['phases'][u], 'name': a}) ) dpg.add_input_text( default_value=phase['goal'], width=200, hint="Phase goal", callback=lambda s, a, u=i: template_state['phases'].__setitem__(u, {**template_state['phases'][u], 'goal': a}) ) dpg.add_checkbox( label="Vote", default_value=phase['voting'], callback=lambda s, a, u=i: template_state['phases'].__setitem__(u, {**template_state['phases'][u], 'voting': a}) ) dpg.add_button(label="X", callback=lambda s, a, u=i: remove_phase(u), width=25) def save_template(): name = dpg.get_value("template_name").strip() if not name: self._show_error("Please enter a template name") return # Build template YAML import yaml template_data = { 'name': name, 'description': dpg.get_value("template_desc").strip(), 'phases': {} } prev_phase = None for i, phase in enumerate(template_state['phases']): phase_id = phase['name'].strip().replace(' ', '_').lower() next_phase = template_state['phases'][i + 1]['name'].strip().replace(' ', '_').lower() if i + 1 < len(template_state['phases']) else None template_data['phases'][phase_id] = { 'goal': phase['goal'] or f"Phase {i + 1}", 'instructions': f"Instructions for {phase_id}", 'voting': phase['voting'], 'next_phase': next_phase } if phase['voting']: template_data['phases'][phase_id]['threshold_ready'] = 0.67 template_data['phases'][phase_id]['human_required'] = True # Save to templates directory template_dir = Path.cwd() / "templates" template_dir.mkdir(exist_ok=True) template_path = template_dir / f"{name}.yaml" if template_path.exists(): self._show_error(f"Template already exists: {name}") return try: with open(template_path, 'w') as f: yaml.dump(template_data, f, default_flow_style=False, sort_keys=False) dpg.delete_item(window_tag) # Update the template combo in New Discussion dialog if it's open if dpg.does_item_exist("new_disc_template"): new_templates = self._get_templates() template_items = new_templates + ["+ Create New Template..."] dpg.configure_item("new_disc_template", items=template_items) dpg.set_value("new_disc_template", name) # Select the new template except Exception as e: self._show_error(f"Failed to save template: {e}") def cancel_template(): dpg.delete_item(window_tag) dpg.split_frame() # Force frame update before creating window with dpg.window(label="Create New Template", tag=window_tag, width=600, height=500, pos=[380, 120], no_collapse=True, show=True): dpg.add_text("Template Name:", color=(150, 200, 255)) dpg.add_input_text(tag="template_name", width=-1, hint="e.g., feature, brainstorm, review") dpg.add_spacer(height=5) dpg.add_text("Description:", color=(150, 200, 255)) dpg.add_input_text(tag="template_desc", width=-1, hint="Brief description of this workflow") dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_text("Phases:", color=(150, 200, 255)) dpg.add_spacer(width=20) dpg.add_button(label="+ Add Phase", callback=add_phase) dpg.add_text("(Name | Goal | Voting Enabled)", color=(120, 120, 120)) with dpg.child_window(height=220, border=True, tag="phases_container"): pass # Will be populated by refresh_phases refresh_phases() dpg.add_spacer(height=15) with dpg.group(horizontal=True): dpg.add_button(label="Save Template", callback=save_template, width=120) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=cancel_template, width=100) # Bring window to front and focus it dpg.focus_item(window_tag) def _show_new_participant_dialog(self, reopen_new_discussion=False): """Show dialog for creating a new participant.""" window_tag = "new_participant_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) def save_participant(): alias = dpg.get_value("part_alias").strip().lower() if not alias: self._show_error("Please enter an alias") return if not alias.replace('_', '').replace('-', '').isalnum(): self._show_error("Alias should only contain letters, numbers, underscores, and hyphens") return display_name = dpg.get_value("part_display_name").strip() or f"AI-{alias.capitalize()}" description = dpg.get_value("part_description").strip() expertise = [e.strip() for e in dpg.get_value("part_expertise").split(",") if e.strip()] personality = dpg.get_value("part_personality").strip() voice = dpg.get_value("part_voice") or (DEFAULT_VOICE if DEFAULT_VOICE else "en-US-Neural2-J") # Build SmartTool config import yaml config = { 'name': f'discussion-{alias}', 'description': description or f'{display_name} participant for discussions', 'category': 'Discussion', 'meta': { 'display_name': display_name, 'alias': alias, 'type': 'voting', 'expertise': expertise or ['General discussion'], 'voice': voice, }, 'arguments': [ {'flag': '--callout', 'variable': 'callout', 'default': '', 'description': 'Specific question or context'}, {'flag': '--templates-dir', 'variable': 'templates_dir', 'default': 'templates', 'description': 'Path to templates'}, ], 'steps': [ { 'type': 'prompt', 'prompt': f'''You are {display_name}, a discussion participant. ## Your Personality {personality or "You provide thoughtful, balanced feedback on proposals."} ## Your Expertise {chr(10).join(f"- {e}" for e in expertise) if expertise else "- General discussion and review"} ## Current Discussion {{input}} ## Your Task {{callout}} Respond with valid JSON: {{"comment": "Your response here", "vote": "READY" or "CHANGES" or "REJECT" or null}} If you have nothing to add: {{"sentinel": "NO_RESPONSE"}}''', 'provider': 'claude-sonnet', 'output_var': 'response' }, { 'type': 'code', 'code': '''import json import re json_text = response.strip() code_block = re.search(r'```(?:json)?\\s*(.*?)```', json_text, re.DOTALL) if code_block: json_text = code_block.group(1).strip() try: parsed = json.loads(json_text) except: parsed = {"comment": json_text, "vote": None} final = json.dumps(parsed)''', 'output_var': 'final' } ], 'output': '{final}' } # Save to ~/.smarttools/ smarttools_dir = Path.home() / ".smarttools" / f"discussion-{alias}" smarttools_dir.mkdir(parents=True, exist_ok=True) config_path = smarttools_dir / "config.yaml" if config_path.exists(): self._show_error(f"Participant @{alias} already exists") return try: with open(config_path, 'w') as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) # Refresh participant registry if get_registry: get_registry().refresh() dpg.delete_item(window_tag) # Reopen the new discussion dialog if requested if reopen_new_discussion: self._show_new_discussion_dialog() except Exception as e: self._show_error(f"Failed to create participant: {e}") def cancel_participant(): dpg.delete_item(window_tag) if reopen_new_discussion: self._show_new_discussion_dialog() dpg.split_frame() # Force frame update before creating window with dpg.window(label="Create New Participant", tag=window_tag, width=550, height=480, pos=[400, 130], no_collapse=True, show=True): dpg.add_text("Alias (for @mentions):", color=(150, 200, 255)) dpg.add_input_text(tag="part_alias", width=-1, hint="e.g., designer, researcher, critic") dpg.add_spacer(height=5) dpg.add_text("Display Name:", color=(150, 200, 255)) dpg.add_input_text(tag="part_display_name", width=-1, hint="e.g., AI-Designer (optional)") dpg.add_spacer(height=5) dpg.add_text("Description:", color=(150, 200, 255)) dpg.add_input_text(tag="part_description", width=-1, hint="Brief role description") dpg.add_spacer(height=5) dpg.add_text("Expertise Areas (comma-separated):", color=(150, 200, 255)) dpg.add_input_text(tag="part_expertise", width=-1, hint="e.g., UI/UX, User research, Accessibility") dpg.add_spacer(height=5) dpg.add_text("Voice (for read-aloud):", color=(150, 200, 255)) voice_items = [v[0] for v in AVAILABLE_VOICES] if AVAILABLE_VOICES else ["en-US-Neural2-J"] dpg.add_combo( items=voice_items, tag="part_voice", default_value=DEFAULT_VOICE if DEFAULT_VOICE else "en-US-Neural2-J", width=-1 ) dpg.add_spacer(height=5) dpg.add_text("Personality/Perspective:", color=(150, 200, 255)) dpg.add_input_text( tag="part_personality", multiline=True, width=-1, height=100, hint="Describe how this participant thinks and what they focus on..." ) dpg.add_spacer(height=15) with dpg.group(horizontal=True): dpg.add_button(label="Create Participant", callback=save_participant, width=140) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=cancel_participant, width=100) # Bring window to front and focus it dpg.focus_item(window_tag) def _show_manage_templates_dialog(self): """Show dialog for managing templates (view, edit, delete).""" window_tag = "manage_templates_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) # State for tracking selection dialog_state = {'selected_template': None} def get_template_path(name: str) -> Path: """Get the path to a template file.""" template_dirs = [ Path.cwd() / "templates", Path(__file__).parent.parent.parent.parent / "templates", ] for template_dir in template_dirs: path = template_dir / f"{name}.yaml" if path.exists(): return path return Path.cwd() / "templates" / f"{name}.yaml" def select_template(sender, app_data, user_data): """Handle template selection.""" name = user_data dialog_state['selected_template'] = name # Update selection highlight for child in dpg.get_item_children("template_list_container", 1) or []: dpg.bind_item_theme(child, 0) dpg.bind_item_theme(sender, self.selected_theme) load_template_details(name) def refresh_list(): """Refresh the template list.""" if dpg.does_item_exist("template_list_container"): dpg.delete_item("template_list_container", children_only=True) templates = self._get_templates() for name in templates: dpg.add_button( label=name, callback=select_template, user_data=name, width=-1, parent="template_list_container" ) def load_template_details(name: str): """Load template details into the edit panel.""" import yaml path = get_template_path(name) if not path.exists(): return try: with open(path) as f: data = yaml.safe_load(f) dpg.set_value("edit_template_name", name) dpg.set_value("edit_template_desc", data.get('description', '')) # Show phases phases_text = "" if 'phases' in data: for phase_id, phase_data in data['phases'].items(): goal = phase_data.get('goal', '') voting = "✓" if phase_data.get('voting') else "✗" phases_text += f"{phase_id}: {goal} [voting: {voting}]\n" dpg.set_value("edit_template_phases", phases_text) # Show pipeline with input/output flow pipeline_text = "" pipeline = data.get('turn_pipeline', {}) steps = pipeline.get('steps', []) if steps: for i, step in enumerate(steps): tool = step.get('tool', '?').replace('discussion-', '') input_var = step.get('input', '') output_var = step.get('output', '') when = step.get('when', '') for_each = step.get('for_each', '') parallel = " ||" if step.get('parallel') else "" # Build flow: input -> tool -> output flow = f"{input_var} -> {tool}" if input_var else tool if output_var: flow += f" -> {output_var}" flow += parallel cond_str = f" (when: {when})" if when and when != 'always' else "" for_str = f" [for_each]" if for_each else "" pipeline_text += f"{i+1}. {flow}{cond_str}{for_str}\n" else: pipeline_text = "(using default pipeline)" dpg.set_value("edit_template_pipeline", pipeline_text) except Exception as e: self._show_error(f"Error loading template: {e}") def save_template(): """Save changes to the selected template.""" if not dialog_state['selected_template']: self._show_error("No template selected") return import yaml name = dialog_state['selected_template'] path = get_template_path(name) try: # Load existing data with open(path) as f: data = yaml.safe_load(f) # Update description data['description'] = dpg.get_value("edit_template_desc") # Save back with open(path, 'w') as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False) self._show_info(f"Template '{name}' saved!", title="Saved") except Exception as e: self._show_error(f"Error saving template: {e}") def delete_template(): """Delete the selected template.""" if not dialog_state['selected_template']: self._show_error("No template selected") return name = dialog_state['selected_template'] path = get_template_path(name) if not path.exists(): self._show_error(f"Template file not found: {path}") return try: path.unlink() dialog_state['selected_template'] = None dpg.set_value("edit_template_name", "") dpg.set_value("edit_template_desc", "") dpg.set_value("edit_template_phases", "") refresh_list() except Exception as e: self._show_error(f"Error deleting template: {e}") def edit_phases(): """Open a dialog to edit template phases.""" if not dialog_state['selected_template']: self._show_error("No template selected") return import yaml name = dialog_state['selected_template'] path = get_template_path(name) try: with open(path) as f: template_data = yaml.safe_load(f) except Exception as e: self._show_error(f"Error loading template: {e}") return phase_window_tag = "edit_phases_dialog" if dpg.does_item_exist(phase_window_tag): dpg.delete_item(phase_window_tag) # Convert phases dict to list for editing phases_list = [] for phase_id, phase_info in template_data.get('phases', {}).items(): phases_list.append({ 'id': phase_id, 'goal': phase_info.get('goal', ''), 'instructions': phase_info.get('instructions', ''), 'voting': phase_info.get('voting', False), 'next_phase': phase_info.get('next_phase'), }) phase_state = {'phases': phases_list, 'selected_idx': None} def select_phase(sender, app_data, user_data): idx = user_data phase_state['selected_idx'] = idx # Highlight selection for child in dpg.get_item_children("phase_list_container", 1) or []: dpg.bind_item_theme(child, 0) dpg.bind_item_theme(sender, self.selected_theme) # Load phase details phase = phase_state['phases'][idx] dpg.set_value("phase_id", phase['id']) dpg.set_value("phase_goal", phase['goal']) dpg.set_value("phase_instructions", phase['instructions']) dpg.set_value("phase_voting", phase['voting']) def refresh_phase_list(): if dpg.does_item_exist("phase_list_container"): dpg.delete_item("phase_list_container", children_only=True) for i, phase in enumerate(phase_state['phases']): vote_marker = " [V]" if phase['voting'] else "" dpg.add_button( label=f"{phase['id']}{vote_marker}", callback=select_phase, user_data=i, width=-1, parent="phase_list_container" ) def update_phase(): idx = phase_state['selected_idx'] if idx is None: return phase_state['phases'][idx]['id'] = dpg.get_value("phase_id") phase_state['phases'][idx]['goal'] = dpg.get_value("phase_goal") phase_state['phases'][idx]['instructions'] = dpg.get_value("phase_instructions") phase_state['phases'][idx]['voting'] = dpg.get_value("phase_voting") refresh_phase_list() def add_phase(): new_id = f"phase_{len(phase_state['phases']) + 1}" phase_state['phases'].append({ 'id': new_id, 'goal': 'New phase goal', 'instructions': 'Phase instructions here', 'voting': False, 'next_phase': None, }) refresh_phase_list() def delete_phase(): idx = phase_state['selected_idx'] if idx is None: self._show_error("No phase selected") return if len(phase_state['phases']) <= 1: self._show_error("Cannot delete the only phase") return phase_state['phases'].pop(idx) phase_state['selected_idx'] = None dpg.set_value("phase_id", "") dpg.set_value("phase_goal", "") dpg.set_value("phase_instructions", "") dpg.set_value("phase_voting", False) refresh_phase_list() def save_phases(): # Rebuild phases dict with proper next_phase links new_phases = {} for i, phase in enumerate(phase_state['phases']): next_p = phase_state['phases'][i + 1]['id'] if i + 1 < len(phase_state['phases']) else None new_phases[phase['id']] = { 'goal': phase['goal'], 'instructions': phase['instructions'], 'voting': phase['voting'], 'next_phase': next_p, } template_data['phases'] = new_phases try: with open(path, 'w') as f: yaml.dump(template_data, f, default_flow_style=False, sort_keys=False) dpg.delete_item(phase_window_tag) # Refresh the template details in parent dialog load_template_details(name) except Exception as e: self._show_error(f"Error saving phases: {e}") with dpg.window(label=f"Edit Phases: {name}", tag=phase_window_tag, width=700, height=450, pos=[360, 130], no_collapse=True): with dpg.group(horizontal=True): # Left: Phase list with dpg.child_window(width=180, height=-40): dpg.add_text("Phases", color=(150, 200, 255)) dpg.add_separator() with dpg.group(horizontal=True): dpg.add_button(label="+", callback=add_phase, width=30) dpg.add_button(label="-", callback=delete_phase, width=30) with dpg.child_window(tag="phase_list_container", height=-1): pass # Right: Phase details with dpg.child_window(width=-1, height=-40): dpg.add_text("Phase Details", color=(150, 200, 255)) dpg.add_separator() dpg.add_text("Phase ID:") dpg.add_input_text(tag="phase_id", width=-1, callback=lambda: update_phase()) dpg.add_spacer(height=5) dpg.add_text("Goal:") dpg.add_input_text(tag="phase_goal", width=-1, callback=lambda: update_phase()) dpg.add_spacer(height=5) dpg.add_text("Instructions:") dpg.add_input_text(tag="phase_instructions", width=-1, height=100, multiline=True, callback=lambda: update_phase()) dpg.add_spacer(height=5) dpg.add_checkbox(label="Voting Enabled", tag="phase_voting", callback=lambda: update_phase()) dpg.add_spacer(height=10) dpg.add_button(label="Apply Changes", callback=update_phase, width=120) with dpg.group(horizontal=True): dpg.add_button(label="Save & Close", callback=save_phases, width=120) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=lambda: dpg.delete_item(phase_window_tag), width=100) refresh_phase_list() dpg.focus_item(phase_window_tag) def edit_pipeline(): """Open a dialog to edit template pipeline.""" if not dialog_state['selected_template']: self._show_error("No template selected") return import yaml name = dialog_state['selected_template'] path = get_template_path(name) try: with open(path) as f: template_data = yaml.safe_load(f) except Exception as e: self._show_error(f"Error loading template: {e}") return pipeline_window_tag = "edit_pipeline_dialog" if dpg.does_item_exist(pipeline_window_tag): dpg.delete_item(pipeline_window_tag) # Get existing pipeline or default pipeline = template_data.get('turn_pipeline', {'steps': []}) steps_list = list(pipeline.get('steps', [])) # Available conditions (new $variable syntax) conditions = ["always", "not $participants_specified", "$phase_voting"] # Available variables (for input/output dropdowns) # "Custom..." triggers a popup for custom variable name variables = [ "$discussion", "$parsed", "$routing", "$responses[]", "$votes", "$promotion", "Custom...", ] # Available tools tools = [ "discussion-parser", "discussion-mention-router", "discussion-{participant}", "discussion-turn-appender", "discussion-vote-counter", "discussion-status-promoter", "discussion-moderator", "discussion-summarizer", "discussion-validator", ] # For each options for_each_options = ["", "$participants_to_call", "$routing.participants_to_call", "Custom..."] # Common arg names for the args editor common_args = [ "--callout", "--templates-dir", "--default-participants", "--responses-json", "--current-status", "--current-phase", "--provider", ] pipeline_state = {'steps': steps_list, 'selected_idx': None, 'args': []} def select_step(sender, app_data, user_data): idx = user_data pipeline_state['selected_idx'] = idx for child in dpg.get_item_children("pipeline_list_container", 1) or []: dpg.bind_item_theme(child, 0) dpg.bind_item_theme(sender, self.selected_theme) load_step_details(idx) def load_step_details(idx): if idx is None or idx >= len(pipeline_state['steps']): return step = pipeline_state['steps'][idx] dpg.set_value("step_tool", step.get('tool', '')) # Handle input - might be custom value not in dropdown input_val = step.get('input', '$discussion') if input_val and input_val not in variables[:-1]: # Exclude "Custom..." dpg.set_value("step_input", "Custom...") dpg.set_value("step_input_custom", input_val) dpg.configure_item("step_input_custom", show=True) else: dpg.set_value("step_input", input_val or '$discussion') dpg.configure_item("step_input_custom", show=False) # Handle output - might be custom value output_val = step.get('output', '') if output_val and output_val not in variables[:-1]: dpg.set_value("step_output", "Custom...") dpg.set_value("step_output_custom", output_val) dpg.configure_item("step_output_custom", show=True) else: dpg.set_value("step_output", output_val or '') dpg.configure_item("step_output_custom", show=False) dpg.set_value("step_when", step.get('when', 'always')) # Handle for_each - might be custom for_each_val = step.get('for_each', '') if for_each_val and for_each_val not in for_each_options[:-1]: dpg.set_value("step_for_each", "Custom...") dpg.set_value("step_for_each_custom", for_each_val) dpg.configure_item("step_for_each_custom", show=True) else: dpg.set_value("step_for_each", for_each_val or '') dpg.configure_item("step_for_each_custom", show=False) dpg.set_value("step_parallel", step.get('parallel', False)) # Load args into state and refresh args list args = step.get('args', {}) pipeline_state['args'] = [[k, v] for k, v in args.items()] refresh_args_list() def refresh_step_list(): if dpg.does_item_exist("pipeline_list_container"): dpg.delete_item("pipeline_list_container", children_only=True) for i, step in enumerate(pipeline_state['steps']): tool = step.get('tool', '?') # Shorten display name but keep it readable short_name = tool.replace('discussion-', '') if '{participant}' in short_name: short_name = 'participants' # More descriptive than * dpg.add_button( label=f"{i+1}. {short_name}", callback=select_step, user_data=i, width=-1, parent="pipeline_list_container" ) def refresh_args_list(): """Refresh the args editor list.""" if dpg.does_item_exist("args_list_container"): dpg.delete_item("args_list_container", children_only=True) for i, (arg_name, arg_val) in enumerate(pipeline_state['args']): with dpg.group(horizontal=True, parent="args_list_container"): dpg.add_input_text( default_value=arg_name, width=120, tag=f"arg_name_{i}", hint="--arg-name" ) dpg.add_input_text( default_value=arg_val, width=120, tag=f"arg_val_{i}", hint="$variable" ) dpg.add_button( label="X", callback=lambda s, a, u: remove_arg(u), user_data=i, width=20 ) def add_arg(): """Add a new argument row.""" pipeline_state['args'].append(["", ""]) refresh_args_list() def remove_arg(idx): """Remove an argument row.""" if 0 <= idx < len(pipeline_state['args']): pipeline_state['args'].pop(idx) refresh_args_list() def collect_args() -> dict: """Collect args from the UI into a dict.""" args = {} for i in range(len(pipeline_state['args'])): name_tag = f"arg_name_{i}" val_tag = f"arg_val_{i}" if dpg.does_item_exist(name_tag) and dpg.does_item_exist(val_tag): name = dpg.get_value(name_tag).strip() val = dpg.get_value(val_tag).strip() if name: args[name] = val return args def get_input_value() -> str: """Get input value, handling custom input.""" val = dpg.get_value("step_input") if val == "Custom...": return dpg.get_value("step_input_custom").strip() return val def get_output_value() -> str: """Get output value, handling custom input.""" val = dpg.get_value("step_output") if val == "Custom...": return dpg.get_value("step_output_custom").strip() return val def get_for_each_value() -> str: """Get for_each value, handling custom input.""" val = dpg.get_value("step_for_each") if val == "Custom...": return dpg.get_value("step_for_each_custom").strip() return val def on_input_change(sender, app_data): """Show/hide custom input field.""" dpg.configure_item("step_input_custom", show=(app_data == "Custom...")) def on_output_change(sender, app_data): """Show/hide custom output field.""" dpg.configure_item("step_output_custom", show=(app_data == "Custom...")) def on_for_each_change(sender, app_data): """Show/hide custom for_each field.""" dpg.configure_item("step_for_each_custom", show=(app_data == "Custom...")) def update_step(): idx = pipeline_state['selected_idx'] if idx is None: return # Collect args from UI args = collect_args() pipeline_state['steps'][idx] = { 'tool': dpg.get_value("step_tool"), 'input': get_input_value() or None, 'output': get_output_value() or None, 'when': dpg.get_value("step_when") if dpg.get_value("step_when") != 'always' else None, 'for_each': get_for_each_value() or None, 'parallel': dpg.get_value("step_parallel") if dpg.get_value("step_parallel") else None, 'args': args if args else None, } # Remove None values pipeline_state['steps'][idx] = {k: v for k, v in pipeline_state['steps'][idx].items() if v is not None} refresh_step_list() def add_step(): pipeline_state['steps'].append({'tool': 'discussion-parser', 'input': '$discussion', 'output': '$parsed'}) refresh_step_list() def delete_step(): idx = pipeline_state['selected_idx'] if idx is None: self._show_error("No step selected") return pipeline_state['steps'].pop(idx) pipeline_state['selected_idx'] = None pipeline_state['args'] = [] dpg.set_value("step_tool", "") dpg.set_value("step_input", "$discussion") dpg.set_value("step_output", "") dpg.configure_item("step_input_custom", show=False) dpg.configure_item("step_output_custom", show=False) dpg.configure_item("step_for_each_custom", show=False) dpg.set_value("step_when", "always") dpg.set_value("step_for_each", "") dpg.set_value("step_parallel", False) refresh_args_list() refresh_step_list() def move_step_up(): idx = pipeline_state['selected_idx'] if idx is None or idx == 0: return pipeline_state['steps'][idx], pipeline_state['steps'][idx-1] = \ pipeline_state['steps'][idx-1], pipeline_state['steps'][idx] pipeline_state['selected_idx'] = idx - 1 refresh_step_list() def move_step_down(): idx = pipeline_state['selected_idx'] if idx is None or idx >= len(pipeline_state['steps']) - 1: return pipeline_state['steps'][idx], pipeline_state['steps'][idx+1] = \ pipeline_state['steps'][idx+1], pipeline_state['steps'][idx] pipeline_state['selected_idx'] = idx + 1 refresh_step_list() def save_pipeline(): template_data['turn_pipeline'] = {'steps': pipeline_state['steps']} try: with open(path, 'w') as f: yaml.dump(template_data, f, default_flow_style=False, sort_keys=False) dpg.delete_item(pipeline_window_tag) load_template_details(name) except Exception as e: self._show_error(f"Error saving pipeline: {e}") with dpg.window(label=f"Edit Pipeline: {name}", tag=pipeline_window_tag, width=650, height=400, pos=[380, 150], no_collapse=True): with dpg.group(horizontal=True): # Left: Step list with dpg.child_window(width=180, height=-40): dpg.add_text("Pipeline Steps", color=(150, 200, 255)) dpg.add_separator() with dpg.group(horizontal=True): dpg.add_button(label="+", callback=add_step, width=25) dpg.add_button(label="-", callback=delete_step, width=25) dpg.add_button(label="Up", callback=move_step_up, width=30) dpg.add_button(label="Dn", callback=move_step_down, width=30) with dpg.child_window(tag="pipeline_list_container", height=-1): pass # Right: Step details with dpg.child_window(width=-1, height=-40): dpg.add_text("Step Details", color=(150, 200, 255)) dpg.add_separator() dpg.add_text("Tool:") dpg.add_combo(items=tools, tag="step_tool", width=-1) with dpg.group(horizontal=True): with dpg.group(): dpg.add_text("Input:") dpg.add_combo(items=variables, tag="step_input", default_value="$discussion", width=150, callback=on_input_change) dpg.add_input_text(tag="step_input_custom", width=150, show=False, hint="$custom_var") dpg.add_spacer(width=10) with dpg.group(): dpg.add_text("Output:") dpg.add_combo(items=variables, tag="step_output", width=150, callback=on_output_change) dpg.add_input_text(tag="step_output_custom", width=150, show=False, hint="$custom_var") dpg.add_spacer(height=3) dpg.add_text("Condition (when):") dpg.add_combo(items=conditions, tag="step_when", default_value="always", width=-1) dpg.add_spacer(height=3) with dpg.group(horizontal=True): dpg.add_text("For Each:") dpg.add_checkbox(label="Parallel", tag="step_parallel") dpg.add_combo(items=for_each_options, tag="step_for_each", width=-1, callback=on_for_each_change) dpg.add_input_text(tag="step_for_each_custom", width=-1, show=False, hint="$custom_array") dpg.add_spacer(height=3) with dpg.group(horizontal=True): dpg.add_text("Arguments:") dpg.add_button(label="+", callback=add_arg, width=20) with dpg.child_window(tag="args_list_container", height=60, border=True): pass # Populated by refresh_args_list dpg.add_spacer(height=5) dpg.add_button(label="Apply Changes", callback=update_step, width=120) with dpg.group(horizontal=True): dpg.add_button(label="Save & Close", callback=save_pipeline, width=120) dpg.add_spacer(width=20) dpg.add_button(label="Cancel", callback=lambda: dpg.delete_item(pipeline_window_tag), width=100) refresh_step_list() dpg.focus_item(pipeline_window_tag) with dpg.window(label="Manage Templates", tag=window_tag, width=700, height=500, pos=[350, 100], no_collapse=True): with dpg.group(horizontal=True): # Left panel: Template list with dpg.child_window(width=200, height=-40): dpg.add_text("Templates", color=(150, 200, 255)) dpg.add_separator() with dpg.child_window(tag="template_list_container", height=-1): pass # Populated by refresh_list # Right panel: Edit details with dpg.child_window(width=-1, height=-40): dpg.add_text("Template Details", color=(150, 200, 255)) dpg.add_separator() dpg.add_text("Name:") dpg.add_input_text(tag="edit_template_name", width=-1, readonly=True) dpg.add_spacer(height=5) dpg.add_text("Description:") dpg.add_input_text(tag="edit_template_desc", width=-1) dpg.add_spacer(height=5) with dpg.group(horizontal=True): dpg.add_text("Phases:") dpg.add_button(label="Edit Phases...", callback=edit_phases, small=True) dpg.add_input_text(tag="edit_template_phases", width=-1, height=100, multiline=True, readonly=True) dpg.add_spacer(height=5) with dpg.group(horizontal=True): dpg.add_text("Turn Pipeline:") dpg.add_button(label="Edit Pipeline...", callback=edit_pipeline, small=True) dpg.add_input_text(tag="edit_template_pipeline", width=-1, height=80, multiline=True, readonly=True) dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_button(label="Save Changes", callback=save_template, width=120) dpg.add_button(label="Delete Template", callback=delete_template, width=120) with dpg.group(horizontal=True): dpg.add_button(label="New Template...", callback=lambda: self._show_new_template_dialog(), width=120) dpg.add_spacer(width=20) dpg.add_button(label="Close", callback=lambda: dpg.delete_item(window_tag), width=100) refresh_list() dpg.focus_item(window_tag) def _show_manage_participants_dialog(self): """Show dialog for managing participants (view, edit, delete).""" window_tag = "manage_participants_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) # State for tracking selection dialog_state = {'selected_participant': None} def get_participant_path(alias: str) -> Path: """Get the path to a participant config.""" return Path.home() / ".smarttools" / f"discussion-{alias}" / "config.yaml" def select_participant(sender, app_data, user_data): """Handle participant selection.""" alias = user_data dialog_state['selected_participant'] = alias # Update selection highlight for child in dpg.get_item_children("participant_list_container", 1) or []: dpg.bind_item_theme(child, 0) dpg.bind_item_theme(sender, self.selected_theme) load_participant_details(alias) def refresh_list(): """Refresh the participant list.""" if dpg.does_item_exist("participant_list_container"): dpg.delete_item("participant_list_container", children_only=True) participants = self._get_participants() for alias, display_name in participants: dpg.add_button( label=f"@{alias}", callback=select_participant, user_data=alias, width=-1, parent="participant_list_container" ) def load_participant_details(alias: str): """Load participant details into the edit panel.""" import yaml import re path = get_participant_path(alias) if not path.exists(): return try: with open(path) as f: data = yaml.safe_load(f) dpg.set_value("edit_part_alias", alias) meta = data.get('meta', {}) dpg.set_value("edit_part_display_name", meta.get('display_name', '')) dpg.set_value("edit_part_description", data.get('description', '')) dpg.set_value("edit_part_expertise", ', '.join(meta.get('expertise', []))) # Set voice dropdown voice = meta.get('voice', DEFAULT_VOICE if DEFAULT_VOICE else 'en-US-Neural2-J') dpg.set_value("edit_part_voice", voice) # Set provider dropdown - check meta first, then prompt steps provider = meta.get('provider', None) if not provider: for step in data.get('steps', []): if step.get('type') == 'prompt' and 'provider' in step: provider = step['provider'] break if not provider: provider = DEFAULT_PROVIDER if DEFAULT_PROVIDER else 'claude-sonnet' dpg.set_value("edit_part_provider", provider) # Extract personality from prompt step - try multiple formats personality = "" for step in data.get('steps', []): if step.get('type') == 'prompt': prompt = step.get('prompt', '') # Try format 1: ## Your Personality (new format) if '## Your Personality' in prompt: start = prompt.find('## Your Personality') end = prompt.find('##', start + 1) if end > start: personality = prompt[start:end].replace('## Your Personality', '').strip() # Try format 2: ## Your Role + ## Your Perspective (existing format) elif '## Your Role' in prompt or '## Your Perspective' in prompt: parts = [] # Extract Your Role section if '## Your Role' in prompt: start = prompt.find('## Your Role') end = prompt.find('##', start + 1) if end > start: role_text = prompt[start:end].replace('## Your Role', '').strip() parts.append(f"ROLE:\n{role_text}") # Extract Your Perspective section if '## Your Perspective' in prompt: start = prompt.find('## Your Perspective') end = prompt.find('##', start + 1) if end > start: persp_text = prompt[start:end].replace('## Your Perspective', '').strip() parts.append(f"PERSPECTIVE:\n{persp_text}") personality = "\n\n".join(parts) break dpg.set_value("edit_part_personality", personality) except Exception as e: self._show_error(f"Error loading participant: {e}") def save_participant(): """Save changes to the selected participant.""" if not dialog_state['selected_participant']: self._show_error("No participant selected") return import yaml alias = dialog_state['selected_participant'] path = get_participant_path(alias) try: # Load existing data with open(path) as f: data = yaml.safe_load(f) # Get old voice for comparison old_voice = data.get('meta', {}).get('voice', '') # Update fields data['description'] = dpg.get_value("edit_part_description") if 'meta' not in data: data['meta'] = {} data['meta']['display_name'] = dpg.get_value("edit_part_display_name") expertise_str = dpg.get_value("edit_part_expertise") data['meta']['expertise'] = [e.strip() for e in expertise_str.split(',') if e.strip()] # Save voice and check if changed new_voice = dpg.get_value("edit_part_voice") voice_changed = old_voice != new_voice if new_voice: data['meta']['voice'] = new_voice # Save provider - update both meta and prompt steps new_provider = dpg.get_value("edit_part_provider") if new_provider: data['meta']['provider'] = new_provider # Also update provider in prompt steps for step in data.get('steps', []): if step.get('type') == 'prompt': step['provider'] = new_provider # Update personality in prompt - handle multiple formats new_personality = dpg.get_value("edit_part_personality") for step in data.get('steps', []): if step.get('type') == 'prompt': prompt = step.get('prompt', '') # Format 1: ## Your Personality (new format) if '## Your Personality' in prompt: start = prompt.find('## Your Personality') end = prompt.find('##', start + 1) if end > start: new_prompt = prompt[:start] + f"## Your Personality\n{new_personality}\n\n" + prompt[end:] step['prompt'] = new_prompt # Format 2: ## Your Role + ## Your Perspective (existing format) elif '## Your Role' in prompt or '## Your Perspective' in prompt: # Parse the edited text back into role/perspective new_role = "" new_persp = "" if "ROLE:" in new_personality and "PERSPECTIVE:" in new_personality: role_start = new_personality.find("ROLE:") persp_start = new_personality.find("PERSPECTIVE:") if role_start < persp_start: new_role = new_personality[role_start + 5:persp_start].strip() new_persp = new_personality[persp_start + 12:].strip() else: new_persp = new_personality[persp_start + 12:role_start].strip() new_role = new_personality[role_start + 5:].strip() elif "ROLE:" in new_personality: new_role = new_personality.replace("ROLE:", "").strip() elif "PERSPECTIVE:" in new_personality: new_persp = new_personality.replace("PERSPECTIVE:", "").strip() else: # No markers - treat as perspective new_persp = new_personality # Replace sections in prompt if new_role and '## Your Role' in prompt: start = prompt.find('## Your Role') end = prompt.find('##', start + 1) if end > start: prompt = prompt[:start] + f"## Your Role\n{new_role}\n\n" + prompt[end:] if new_persp and '## Your Perspective' in prompt: start = prompt.find('## Your Perspective') end = prompt.find('##', start + 1) if end > start: prompt = prompt[:start] + f"## Your Perspective\n{new_persp}\n\n" + prompt[end:] step['prompt'] = prompt break # Save back with open(path, 'w') as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False) # Refresh participant registry so voice changes take effect if get_registry: get_registry().refresh() # Clear read-aloud cache if voice changed cache_cleared = False if voice_changed: cache_dir = os.path.expanduser("~/.cache/read-aloud") if os.path.exists(cache_dir): try: for f in os.listdir(cache_dir): fpath = os.path.join(cache_dir, f) if os.path.isfile(fpath) and f.endswith('.mp3'): os.remove(fpath) cache_cleared = True except Exception: pass # Ignore cache clearing errors if voice_changed and cache_cleared: self._show_info(f"Participant '@{alias}' saved!\n\nVoice changed - audio cache cleared.", title="Saved") else: self._show_info(f"Participant '@{alias}' saved!", title="Saved") except Exception as e: self._show_error(f"Error saving participant: {e}") def delete_participant(): """Delete the selected participant.""" if not dialog_state['selected_participant']: self._show_error("No participant selected") return alias = dialog_state['selected_participant'] path = get_participant_path(alias) config_dir = path.parent if not path.exists(): self._show_error(f"Participant config not found: {path}") return try: # Delete the config file and directory path.unlink() if config_dir.exists() and not any(config_dir.iterdir()): config_dir.rmdir() dialog_state['selected_participant'] = None dpg.set_value("edit_part_alias", "") dpg.set_value("edit_part_display_name", "") dpg.set_value("edit_part_description", "") dpg.set_value("edit_part_expertise", "") dpg.set_value("edit_part_personality", "") refresh_list() # Refresh registry if get_registry: get_registry().refresh() except Exception as e: self._show_error(f"Error deleting participant: {e}") with dpg.window(label="Manage Participants", tag=window_tag, width=750, height=550, pos=[320, 80], no_collapse=True): with dpg.group(horizontal=True): # Left panel: Participant list with dpg.child_window(width=180, height=-40): dpg.add_text("Participants", color=(150, 200, 255)) dpg.add_separator() with dpg.child_window(tag="participant_list_container", height=-1): pass # Populated by refresh_list # Right panel: Edit details with dpg.child_window(width=-1, height=-40): dpg.add_text("Participant Details", color=(150, 200, 255)) dpg.add_separator() dpg.add_text("Alias:") dpg.add_input_text(tag="edit_part_alias", width=-1, readonly=True) dpg.add_spacer(height=5) dpg.add_text("Display Name:") dpg.add_input_text(tag="edit_part_display_name", width=-1) dpg.add_spacer(height=5) dpg.add_text("Description:") dpg.add_input_text(tag="edit_part_description", width=-1) dpg.add_spacer(height=5) dpg.add_text("Expertise (comma-separated):") dpg.add_input_text(tag="edit_part_expertise", width=-1) dpg.add_spacer(height=5) dpg.add_text("Voice (for read-aloud):") voice_items = [v[0] for v in AVAILABLE_VOICES] if AVAILABLE_VOICES else ["en-US-Neural2-J"] dpg.add_combo( items=voice_items, tag="edit_part_voice", default_value=DEFAULT_VOICE if DEFAULT_VOICE else "en-US-Neural2-J", width=-1 ) dpg.add_spacer(height=5) dpg.add_text("AI Provider:") provider_list = get_available_providers() if get_available_providers else [] provider_items = [p[0] for p in provider_list] if provider_list else ["claude-sonnet"] dpg.add_combo( items=provider_items, tag="edit_part_provider", default_value=DEFAULT_PROVIDER if DEFAULT_PROVIDER else "claude-sonnet", width=-1 ) dpg.add_spacer(height=5) dpg.add_text("Personality/Perspective:") dpg.add_input_text(tag="edit_part_personality", width=-1, height=100, multiline=True) dpg.add_spacer(height=10) with dpg.group(horizontal=True): dpg.add_button(label="Save Changes", callback=save_participant, width=120) dpg.add_button(label="Delete Participant", callback=delete_participant, width=130) with dpg.group(horizontal=True): dpg.add_button(label="New Participant...", callback=lambda: self._show_new_participant_dialog(), width=130) dpg.add_spacer(width=20) dpg.add_button(label="Close", callback=lambda: dpg.delete_item(window_tag), width=100) refresh_list() dpg.focus_item(window_tag) def _show_shortcuts(self): """Show keyboard shortcuts dialog.""" window_tag = "shortcuts_dialog" if dpg.does_item_exist(window_tag): dpg.delete_item(window_tag) with dpg.window(label="Keyboard Shortcuts", tag=window_tag, width=300, height=250, pos=[550, 300]): shortcuts = [ ("N", "New Discussion"), ("Q", "Quit"), ("R", "Refresh"), ("T", "Run Turn"), ("C", "Add Comment"), ("D", "View Diagrams"), ("Escape", "Close dialogs"), ] for key, action in shortcuts: with dpg.group(horizontal=True): dpg.add_text(f"[{key}]", color=(150, 200, 255)) dpg.add_text(f" {action}") dpg.add_separator() dpg.add_button(label="Close", callback=lambda: dpg.delete_item(window_tag)) # Keyboard handlers def _is_typing(self): """Check if user is typing in an input field.""" # Check if focused item is an input type focused = dpg.get_focused_item() if focused: try: item_type = dpg.get_item_type(focused) # Input text types that should capture keyboard if "input" in item_type.lower() or "text" in item_type.lower(): return True except Exception: pass # Also check if any dialog windows with inputs are visible # When these dialogs are open, disable hotkeys to allow typing dialog_windows = [ "comment_dialog", "new_discussion_dialog", "new_template_dialog", "new_participant_dialog", "edit_phases_dialog", "edit_pipeline_dialog", "delete_confirm_dialog", "edit_discussion_dialog", ] for tag in dialog_windows: if dpg.does_item_exist(tag) and dpg.is_item_shown(tag): return True # Check for active diagram editor windows if hasattr(self, '_active_diagram_windows'): for tag in self._active_diagram_windows: if dpg.does_item_exist(tag) and dpg.is_item_shown(tag): return True return False def _has_modifier_key(self): """Check if Ctrl, Shift, or Alt is held - used to avoid blocking clipboard shortcuts.""" return (dpg.is_key_down(dpg.mvKey_LControl) or dpg.is_key_down(dpg.mvKey_RControl) or dpg.is_key_down(dpg.mvKey_LShift) or dpg.is_key_down(dpg.mvKey_RShift) or dpg.is_key_down(dpg.mvKey_LAlt) or dpg.is_key_down(dpg.mvKey_RAlt)) def _on_quit(self): if not self._is_typing() and not self._has_modifier_key(): dpg.stop_dearpygui() def _on_refresh(self): if not self._is_typing() and not self._has_modifier_key(): self._refresh_current() def _on_turn(self): if not self._is_typing() and not self._has_modifier_key() and self.current_discussion: self._run_turn() def _on_comment(self): if not self._is_typing() and not self._has_modifier_key() and self.current_discussion: self._show_comment_dialog() def _on_diagrams(self): if not self._is_typing() and not self._has_modifier_key() and self.current_discussion: self._show_diagram_dialog() def _on_escape(self): # Escape should always work to close dialogs for tag in ["comment_dialog", "diagram_select_dialog", "error_popup", "shortcuts_dialog", "new_discussion_dialog", "new_template_dialog", "new_participant_dialog", "manage_templates_dialog", "manage_participants_dialog", "edit_phases_dialog", "edit_pipeline_dialog", "delete_confirm_dialog", "edit_discussion_dialog"]: if dpg.does_item_exist(tag): dpg.delete_item(tag) def _on_new_discussion(self): if not self._is_typing() and not self._has_modifier_key(): self._show_new_discussion_dialog() def _poll_background_tasks(self): """Called from main thread to poll background task updates.""" # Clean up any textures marked for deletion if hasattr(self, '_textures_to_delete') and self._textures_to_delete: for tex_tag in self._textures_to_delete[:]: # Copy list to iterate safely try: if dpg.does_item_exist(tex_tag): dpg.delete_item(tex_tag) self._textures_to_delete.remove(tex_tag) except Exception: pass # Poll diagram editors for AI completion if hasattr(self, '_diagram_editors'): for poll_func in list(self._diagram_editors.values()): try: poll_func() except Exception: pass # Update output display if not hasattr(self, '_last_output_index'): self._last_output_index = 0 if len(self._output_lines) > self._last_output_index: if dpg.does_item_exist("output_text"): new_text = "\n".join(self._output_lines[self._last_output_index:]) current = dpg.get_value("output_text") dpg.set_value("output_text", current + new_text + "\n") self._last_output_index = len(self._output_lines) # Check if turn completed and needs refresh if getattr(self, '_turn_complete', False): self._turn_complete = False if self.current_discussion: self.current_discussion = Discussion.load(self.current_discussion.path) self._show_discussion() def run(self): """Start the GUI main loop.""" dpg.setup_dearpygui() dpg.show_viewport() # Load discussions after UI is shown self._refresh_discussions() # Open specific file if requested if getattr(self, '_pending_open_file', None): self._open_discussion(self._pending_open_file) self._pending_open_file = None # Manual render loop with background task polling while dpg.is_dearpygui_running(): # Poll for updates from background threads (output, turn completion) self._poll_background_tasks() # Render frame dpg.render_dearpygui_frame() dpg.destroy_context() def main(discussions_dir: str = None, open_file: str = None): """Entry point for GUI. Args: discussions_dir: Directory to browse for discussions open_file: Specific discussion file to open immediately """ dir_path = Path(discussions_dir) if discussions_dir else None app = DiscussionGUI(dir_path) # If a specific file was requested, open it after UI initializes if open_file: app._pending_open_file = Path(open_file) else: app._pending_open_file = None app.run() if __name__ == "__main__": import sys discussions_dir = sys.argv[1] if len(sys.argv) > 1 else None main(discussions_dir)