3793 lines
157 KiB
Python
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)
|