orchestrated-discussions/src/discussions/ui/gui.py

3793 lines
157 KiB
Python

"""
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)