From 3ecadb5e16fbf0667e3a34d22dd616cc94bb0545 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 9 Jan 2026 10:54:53 -0400 Subject: [PATCH] Convert to PySide6, add ecosystem installer and Docker testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PySide6 Migration: - Convert all Qt imports from PyQt6 to PySide6 (LGPL license) - Replace pyqtSignal with Signal throughout codebase - Unifies Qt library across ecosystem (ramble, artifact-editor) New Features: - Add Re-align Goals button to dashboard (launches Ramble for interview) - ReAlignGoalsDialog now directly launches Ramble with questions as fields Ecosystem Tools: - Add bin/install-ecosystem script for unified installation - Add Dockerfile for testing ecosystem installation - Add docker-compose.yml for easy testing - Add ramble and cmdforge as tracked dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Dockerfile | 94 ++++++++ bin/install-ecosystem | 132 ++++++++++ docker-compose.yml | 23 ++ pyproject.toml | 4 +- src/development_hub/app.py | 4 +- src/development_hub/dialogs.py | 227 +++++++++++++++++- src/development_hub/main_window.py | 12 +- src/development_hub/project_list.py | 20 +- src/development_hub/terminal_widget.py | 22 +- src/development_hub/views/dashboard.py | 38 ++- src/development_hub/views/global_dashboard.py | 8 +- src/development_hub/widgets/action_menu.py | 22 +- .../widgets/collapsible_section.py | 44 ++-- src/development_hub/widgets/health_card.py | 8 +- src/development_hub/widgets/progress_bar.py | 4 +- src/development_hub/widgets/stat_card.py | 6 +- src/development_hub/widgets/toast.py | 10 +- src/development_hub/workspace.py | 32 +-- 18 files changed, 590 insertions(+), 120 deletions(-) create mode 100644 Dockerfile create mode 100755 bin/install-ecosystem create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1c3a9e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,94 @@ +# Development Hub Ecosystem - Docker Build for Testing +# +# Tests that all ecosystem projects can be installed together. +# GUI functionality is not tested (no X11), only imports and CLI tools. +# +# Build: docker build -t development-hub . +# Test: docker run --rm development-hub + +FROM python:3.12-slim + +# Install system dependencies for Qt (even though we won't run GUI, imports need libs) +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + libgl1 \ + libegl1 \ + libxkbcommon0 \ + libdbus-1-3 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-keysyms1 \ + libxcb-shape0 \ + libxcb-xfixes0 \ + libxcb-xinerama0 \ + libxcb-render0 \ + libxcb-render-util0 \ + libxcb-image0 \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Set up virtual display for Qt imports (no actual display, just for import) +ENV QT_QPA_PLATFORM=offscreen + +WORKDIR /workspace + +# Clone all repos +ARG GITEA_URL=https://gitea.brrd.tech/rob +RUN git clone ${GITEA_URL}/CmdForge.git CmdForge && \ + git clone ${GITEA_URL}/ramble.git ramble && \ + git clone ${GITEA_URL}/artifact-editor.git artifact-editor && \ + git clone ${GITEA_URL}/orchestrated-discussions.git orchestrated-discussions && \ + git clone ${GITEA_URL}/development-hub.git development-hub + +# Apply patches for PySide6 unification (until changes are pushed to Gitea) +# Patch pyproject.toml files +RUN sed -i 's|PyQt6|PySide6|g' /workspace/development-hub/pyproject.toml && \ + sed -i 's|file:///home/rob/PycharmProjects|file:///workspace|g' /workspace/development-hub/pyproject.toml && \ + sed -i 's|PyQt6|PySide6|g' /workspace/artifact-editor/pyproject.toml + +# Patch source files: PyQt6 -> PySide6 +RUN find /workspace/development-hub/src -name "*.py" -exec sed -i \ + -e 's/from PyQt6/from PySide6/g' \ + -e 's/import PyQt6/import PySide6/g' \ + -e 's/pyqtSignal/Signal/g' \ + -e 's/pyqtSlot/Slot/g' {} \; + +RUN find /workspace/artifact-editor/src -name "*.py" -exec sed -i \ + -e 's/from PyQt6/from PySide6/g' \ + -e 's/import PyQt6/import PySide6/g' \ + -e 's/pyqtSignal/Signal/g' \ + -e 's/pyqtSlot/Slot/g' {} \; + +# Create venv and install +RUN python -m venv /venv +ENV PATH="/venv/bin:$PATH" + +RUN pip install --upgrade pip wheel setuptools + +# Install in dependency order (all patches applied above) +RUN pip install -e /workspace/CmdForge +RUN pip install -e /workspace/ramble +RUN pip install -e /workspace/artifact-editor +RUN pip install -e "/workspace/orchestrated-discussions[gui]" +RUN pip install -e /workspace/development-hub + +# Verification script +RUN echo '#!/bin/bash\n\ +set -e\n\ +echo "Testing imports..."\n\ +python -c "import cmdforge; print(\" cmdforge: OK\")"\n\ +python -c "import ramble; print(\" ramble: OK\")"\n\ +python -c "import artifact_editor; print(\" artifact_editor: OK\")"\n\ +python -c "import discussions; print(\" discussions: OK\")"\n\ +python -c "import development_hub; print(\" development_hub: OK\")"\n\ +echo ""\n\ +echo "Testing CLI tools..."\n\ +cmdforge --help > /dev/null && echo " cmdforge CLI: OK"\n\ +ramble --help > /dev/null && echo " ramble CLI: OK"\n\ +artifact-editor --help > /dev/null && echo " artifact-editor CLI: OK"\n\ +discussions --help > /dev/null && echo " discussions CLI: OK"\n\ +echo ""\n\ +echo "All tests passed!"\n\ +' > /test.sh && chmod +x /test.sh + +CMD ["/test.sh"] diff --git a/bin/install-ecosystem b/bin/install-ecosystem new file mode 100755 index 0000000..572a03a --- /dev/null +++ b/bin/install-ecosystem @@ -0,0 +1,132 @@ +#!/bin/bash +# Development Ecosystem Installer +# +# Installs all projects in the development ecosystem in the correct order. +# Projects are cloned from Gitea and installed as editable packages. +# +# Usage: +# ./install-ecosystem # Fresh install to ~/PycharmProjects +# ./install-ecosystem --update # Update existing repos and reinstall +# ./install-ecosystem --help # Show this help +# +# Projects installed (in order): +# 1. CmdForge - AI-powered CLI tools +# 2. ramble - Voice note transcription +# 3. artifact-editor - Visual artifact editor +# 4. orchestrated-discussions - Multi-agent AI discussions +# 5. development-hub - Central orchestration GUI + +set -e + +GITEA_URL="https://gitea.brrd.tech/rob" +PROJECTS_DIR="${PROJECTS_DIR:-$HOME/PycharmProjects}" +VENV_DIR="${VENV_DIR:-$PROJECTS_DIR/development-hub/.venv}" + +# Project definitions: name, repo_name, extras +PROJECTS=( + "CmdForge:CmdForge:" + "ramble:ramble:" + "artifact-editor:artifact-editor:" + "orchestrated-discussions:orchestrated-discussions:gui" + "development-hub:development-hub:" +) + +show_help() { + head -20 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' +} + +info() { + echo -e "\033[1;34m==>\033[0m \033[1m$1\033[0m" +} + +success() { + echo -e "\033[1;32m==>\033[0m \033[1m$1\033[0m" +} + +error() { + echo -e "\033[1;31m==>\033[0m \033[1m$1\033[0m" >&2 +} + +# Parse arguments +UPDATE_MODE=false +while [[ $# -gt 0 ]]; do + case $1 in + --update|-u) + UPDATE_MODE=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + error "Unknown option: $1" + exit 1 + ;; + esac +done + +# Ensure projects directory exists +mkdir -p "$PROJECTS_DIR" +cd "$PROJECTS_DIR" + +info "Installing development ecosystem to $PROJECTS_DIR" + +# Clone or update repositories +for project_spec in "${PROJECTS[@]}"; do + IFS=':' read -r name repo extras <<< "$project_spec" + project_dir="$PROJECTS_DIR/$name" + + if [[ -d "$project_dir" ]]; then + if [[ "$UPDATE_MODE" == "true" ]]; then + info "Updating $name..." + cd "$project_dir" + git fetch origin + git pull --ff-only origin main 2>/dev/null || git pull --ff-only origin master 2>/dev/null || true + cd "$PROJECTS_DIR" + else + info "$name already exists, skipping clone" + fi + else + info "Cloning $name..." + git clone "$GITEA_URL/$repo.git" "$name" + fi +done + +# Create or activate virtual environment +if [[ ! -d "$VENV_DIR" ]]; then + info "Creating virtual environment at $VENV_DIR..." + python3 -m venv "$VENV_DIR" +fi + +info "Activating virtual environment..." +source "$VENV_DIR/bin/activate" + +# Upgrade pip +pip install --upgrade pip wheel setuptools -q + +# Install projects in order +for project_spec in "${PROJECTS[@]}"; do + IFS=':' read -r name repo extras <<< "$project_spec" + project_dir="$PROJECTS_DIR/$name" + + if [[ -n "$extras" ]]; then + info "Installing $name[$extras]..." + pip install -e "$project_dir[$extras]" -q + else + info "Installing $name..." + pip install -e "$project_dir" -q + fi +done + +success "Installation complete!" +echo "" +echo "To use the ecosystem:" +echo " source $VENV_DIR/bin/activate" +echo " development-hub # Launch the GUI" +echo "" +echo "Individual tools:" +echo " cmdforge # AI-powered CLI tools" +echo " ramble # Voice note transcription" +echo " artifact-editor # Visual artifact editor" +echo " discussions # Multi-agent AI discussions" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..04d23bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +# Development Ecosystem Docker Compose +# +# Builds and tests the complete ecosystem installation. +# +# Usage: +# docker compose build # Build the test image +# docker compose up # Run installation tests +# docker compose down # Cleanup + +services: + ecosystem-test: + build: . + container_name: development-hub-test + environment: + - QT_QPA_PLATFORM=offscreen + - DISPLAY=:99 + # For local development testing, mount the local repos: + # volumes: + # - ./:/workspace/development-hub:ro + # - ../CmdForge:/workspace/CmdForge:ro + # - ../ramble:/workspace/ramble:ro + # - ../artifact-editor:/workspace/artifact-editor:ro + # - ../orchestrated-discussions:/workspace/orchestrated-discussions:ro diff --git a/pyproject.toml b/pyproject.toml index 51c1c0e..9be3dce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,11 @@ readme = "README.md" license = {text = "MIT"} requires-python = ">=3.10" dependencies = [ - "PyQt6>=6.5.0", + "PySide6>=6.4.0", "pyte>=0.8.0", "orchestrated-discussions[gui] @ file:///home/rob/PycharmProjects/orchestrated-discussions", + "ramble @ file:///home/rob/PycharmProjects/ramble", + "cmdforge @ file:///home/rob/PycharmProjects/CmdForge", ] [project.optional-dependencies] diff --git a/src/development_hub/app.py b/src/development_hub/app.py index c6158c7..8972b2a 100644 --- a/src/development_hub/app.py +++ b/src/development_hub/app.py @@ -3,8 +3,8 @@ import signal import sys -from PyQt6.QtCore import QTimer -from PyQt6.QtWidgets import QApplication +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication from development_hub.main_window import MainWindow from development_hub.styles import DARK_THEME diff --git a/src/development_hub/dialogs.py b/src/development_hub/dialogs.py index 0d4310e..12b2d66 100644 --- a/src/development_hub/dialogs.py +++ b/src/development_hub/dialogs.py @@ -5,8 +5,8 @@ import subprocess import shutil from pathlib import Path -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QProcess -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, QThread, Signal, QProcess, QTimer +from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, @@ -36,8 +36,8 @@ from development_hub.settings import Settings class RambleThread(QThread): """Thread to run Ramble subprocess.""" - finished = pyqtSignal(dict) # Emits parsed result - error = pyqtSignal(str) # Emits error message + finished = Signal(dict) # Emits parsed result + error = Signal(str) # Emits error message def __init__(self, prompt: str, fields: list[str], criteria: dict[str, str] = None): super().__init__() @@ -97,8 +97,8 @@ class RambleThread(QThread): class NewProjectThread(QThread): """Thread to run new-project script.""" - output = pyqtSignal(str) - finished = pyqtSignal(bool, str) # success, message + output = Signal(str) + finished = Signal(bool, str) # success, message def __init__(self, name: str, title: str, tagline: str, deploy: bool): super().__init__() @@ -152,8 +152,8 @@ class NewProjectThread(QThread): class DeployDocsThread(QThread): """Thread to run deploy docs script asynchronously.""" - output = pyqtSignal(str) - finished = pyqtSignal(bool, str) # success, message + output = Signal(str) + finished = Signal(bool, str) # success, message def __init__(self, project_key: str, project_title: str, docs_url: str): super().__init__() @@ -198,7 +198,7 @@ class DeployDocsThread(QThread): class UpdateDocsThread(QThread): """Thread to run update-docs CmdForge tool.""" - finished = pyqtSignal(bool, str, str) # success, message, stderr + finished = Signal(bool, str, str) # success, message, stderr def __init__(self, project_key: str): super().__init__() @@ -237,8 +237,8 @@ class UpdateDocsThread(QThread): class RebuildMainDocsThread(QThread): """Thread to refresh the main documentation site.""" - output = pyqtSignal(str) - finished = pyqtSignal(bool, str) # success, message + output = Signal(str) + finished = Signal(bool, str) # success, message def __init__(self): super().__init__() @@ -301,7 +301,7 @@ class RebuildMainDocsThread(QThread): class NewProjectDialog(QDialog): """Dialog for creating a new project.""" - project_created = pyqtSignal(str) # Emits project name + project_created = Signal(str) # Emits project name def __init__(self, parent=None): super().__init__(parent) @@ -621,7 +621,7 @@ class SettingsDialog(QDialog): class StandupDialog(QDialog): """Dialog for capturing daily standup progress.""" - progress_saved = pyqtSignal(Path) # Emits path to saved file + progress_saved = Signal(Path) # Emits path to saved file def __init__(self, parent=None): super().__init__(parent) @@ -1182,3 +1182,204 @@ class DocsPreviewDialog(QDialog): _, new_widget, _ = self._text_widgets[filename] result[filename] = new_widget.toPlainText() return result + + +class RealignGoalsThread(QThread): + """Thread to run realign-goals CmdForge tool.""" + + finished = Signal(str) # Emits generated goals content + error = Signal(str) # Emits error message + + def __init__(self, answers: dict, project_name: str = "global"): + super().__init__() + self.answers = answers + self.project_name = project_name + + def run(self): + """Run realign-goals tool.""" + cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge" + if not cmdforge_path.exists(): + cmdforge_path = shutil.which("cmdforge") + if not cmdforge_path: + self.error.emit("CmdForge not found") + return + + # Format answers as input + input_text = json.dumps(self.answers, indent=2) + + try: + result = subprocess.run( + [str(cmdforge_path), "run", "realign-goals", "--project", self.project_name], + input=input_text, + capture_output=True, + text=True, + timeout=120, + ) + + if result.returncode != 0: + self.error.emit(f"Error: {result.stderr or result.stdout}") + return + + self.finished.emit(result.stdout) + + except subprocess.TimeoutExpired: + self.error.emit("Tool timed out after 2 minutes") + except Exception as e: + self.error.emit(f"Error: {e}") + + +class ReAlignGoalsDialog(QDialog): + """Dialog for re-aligning goals through Ramble interview. + + Launches Ramble directly with interview questions as fields, + then generates new goals.md content and shows diff for approval. + """ + + # Interview questions: (field_name, question_text, criteria_hint) + QUESTIONS = [ + ("motivation", "What drives you to build software?", + "solving problems, tools, automation, learning, etc."), + ("common_thread", "What's the common thread in your projects?", + "problems or frustrations that led to building them"), + ("finished", "How do you feel about 'finished' software?", + "complete and move on vs ongoing refinement"), + ("audience", "Who are you building for?", + "yourself, developers, non-technical users, community"), + ("polished", "What does 'polished' mean to you?", + "qualities that make software feel polished"), + ("scope", "How do you feel about scope and focus?", + "build many things vs focus deeply"), + ("priorities", "How do you decide what to work on next?", + "frustrations, closest to done, most impactful"), + ("shipping", "What's your relationship with shipping?", + "polish forever vs push things out early"), + ("ai_vision", "What's your vision for AI-assisted development?", + "tool, scope enabler, or something more"), + ("done", "What does 'done' look like for a project?", + "is there a completion point"), + ("principles", "What principles guide how you write code?", + "philosophies, patterns, practices"), + ("success", "What does success look like in 2-3 years?", + "the picture if things go well"), + ("avoid", "What do you want to avoid?", + "traps or patterns to stay away from"), + ] + + def __init__(self, goals_path: Path, project_name: str = "global", parent=None): + super().__init__(parent) + self.goals_path = goals_path + self.project_name = project_name + self._ramble_thread = None + self._realign_thread = None + + self.setWindowTitle("Re-align Goals") + self.setFixedSize(400, 150) + self._setup_ui() + + # Start Ramble immediately + QTimer.singleShot(100, self._start_ramble) + + def _setup_ui(self): + """Set up minimal progress UI.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + + self.status_label = QLabel("Launching Ramble...") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setStyleSheet("font-size: 14px; color: #e0e0e0;") + layout.addWidget(self.status_label) + + self.progress = QProgressBar() + self.progress.setRange(0, 0) # Indeterminate + self.progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #3d3d3d; + border-radius: 4px; + text-align: center; + background-color: #2d2d2d; + } + QProgressBar::chunk { + background-color: #4a9eff; + } + """) + layout.addWidget(self.progress) + + layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + layout.addWidget(cancel_btn, alignment=Qt.AlignmentFlag.AlignCenter) + + def _start_ramble(self): + """Launch Ramble with interview questions as fields.""" + self.status_label.setText("Ramble is open - answer the questions...") + + # Build field names and criteria from questions + fields = [q[0] for q in self.QUESTIONS] + criteria = {q[0]: q[2] for q in self.QUESTIONS} + + # Build hints from the question text + hints = [q[1] for q in self.QUESTIONS] + + self._ramble_thread = RambleThread( + prompt=( + "Answer these questions about your development philosophy and goals. " + "Type or use voice input, then click Generate to fill in the fields." + ), + fields=fields, + criteria=criteria, + ) + self._ramble_thread.finished.connect(self._on_ramble_finished) + self._ramble_thread.error.connect(self._on_ramble_error) + self._ramble_thread.start() + + def _on_ramble_finished(self, result: dict): + """Handle Ramble completion - now generate goals.""" + if not result or "fields" not in result: + self.reject() + return + + answers = result["fields"] + if not any(v.strip() for v in answers.values()): + QMessageBox.warning(self, "No Answers", "Please answer at least some questions.") + self.reject() + return + + self.status_label.setText("Generating goals from your answers...") + self._realign_thread = RealignGoalsThread(answers, self.project_name) + self._realign_thread.finished.connect(self._on_generate_finished) + self._realign_thread.error.connect(self._on_generate_error) + self._realign_thread.start() + + def _on_ramble_error(self, error: str): + """Handle Ramble error.""" + QMessageBox.warning(self, "Ramble Error", error) + self.reject() + + def _on_generate_finished(self, new_content: str): + """Handle successful generation - show diff dialog.""" + self.progress.setRange(0, 1) + self.progress.setValue(1) + + # Read existing content + old_content = "" + if self.goals_path.exists(): + old_content = self.goals_path.read_text() + + # Show diff dialog + changes = {"goals.md": (old_content, new_content)} + preview = DocsPreviewDialog(changes, self) + if preview.exec() == QDialog.DialogCode.Accepted and preview.was_accepted(): + selected = preview.get_selected_changes() + if "goals.md" in selected: + self.goals_path.write_text(selected["goals.md"]) + QMessageBox.information(self, "Goals Updated", "Your goals have been updated.") + self.accept() + return + + self.reject() + + def _on_generate_error(self, error: str): + """Handle generation error.""" + QMessageBox.critical(self, "Generation Error", error) + self.reject() diff --git a/src/development_hub/main_window.py b/src/development_hub/main_window.py index db2c54b..fa3f084 100644 --- a/src/development_hub/main_window.py +++ b/src/development_hub/main_window.py @@ -3,9 +3,9 @@ import subprocess from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QAction, QKeySequence -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import ( QLabel, QMainWindow, QSplitter, @@ -227,7 +227,7 @@ class MainWindow(QMainWindow): def _launch_discussion(self): """Launch orchestrated-discussions UI in the current project directory.""" - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox # Get current project context from active pane pane = self.workspace.get_active_pane() @@ -260,7 +260,7 @@ class MainWindow(QMainWindow): def _launch_global_discussion(self): """Launch orchestrated-discussions UI in the root projects directory with new dialog.""" - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox projects_root = Path.home() / "PycharmProjects" @@ -345,7 +345,7 @@ class MainWindow(QMainWindow): def _show_about(self): """Show about dialog.""" - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox QMessageBox.about( self, diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py index 583b352..d46a1fc 100644 --- a/src/development_hub/project_list.py +++ b/src/development_hub/project_list.py @@ -4,9 +4,9 @@ import subprocess import webbrowser from pathlib import Path -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( QDialog, QLabel, QListWidget, @@ -26,9 +26,9 @@ from development_hub.project_discovery import Project, discover_projects class ProjectListWidget(QWidget): """Widget displaying the list of projects with context menu actions.""" - project_selected = pyqtSignal(Project) - open_terminal_requested = pyqtSignal(Project) - open_dashboard_requested = pyqtSignal(Project) + project_selected = Signal(Project) + open_terminal_requested = Signal(Project) + open_dashboard_requested = Signal(Project) def __init__(self, parent=None): super().__init__(parent) @@ -267,7 +267,7 @@ class ProjectListWidget(QWidget): elif clicked == commit_btn: # Show commit dialog - from PyQt6.QtWidgets import QInputDialog + from PySide6.QtWidgets import QInputDialog message, ok = QInputDialog.getText( self, "Commit Message", @@ -415,8 +415,8 @@ class ProjectListWidget(QWidget): def _update_docs(self, project: Project): """Update documentation using CmdForge update-docs tool with preview.""" - from PyQt6.QtWidgets import QMessageBox, QProgressDialog - from PyQt6.QtCore import Qt, QTimer + from PySide6.QtWidgets import QMessageBox, QProgressDialog + from PySide6.QtCore import Qt, QTimer from pathlib import Path # Confirm before starting @@ -513,7 +513,7 @@ class ProjectListWidget(QWidget): def _on_update_docs_finished(self, success: bool, message: str, detail: str): """Handle update docs thread completion.""" - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox from pathlib import Path # Stop timer diff --git a/src/development_hub/terminal_widget.py b/src/development_hub/terminal_widget.py index 071c8a6..57448d8 100644 --- a/src/development_hub/terminal_widget.py +++ b/src/development_hub/terminal_widget.py @@ -10,8 +10,8 @@ import termios from pathlib import Path import pyte -from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer -from PyQt6.QtGui import ( +from PySide6.QtCore import QThread, Signal, Qt, QTimer +from PySide6.QtGui import ( QFont, QFontMetrics, QColor, @@ -22,14 +22,14 @@ from PyQt6.QtGui import ( QDragEnterEvent, QDropEvent, ) -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollBar, QAbstractScrollArea +from PySide6.QtWidgets import QWidget, QVBoxLayout, QScrollBar, QAbstractScrollArea class PtyReaderThread(QThread): """Thread that reads from PTY master and emits output.""" - output_ready = pyqtSignal(bytes) - finished_signal = pyqtSignal() + output_ready = Signal(bytes) + finished_signal = Signal() def __init__(self, master_fd: int): super().__init__() @@ -98,8 +98,8 @@ BG_COLORS = { class TerminalDisplay(QWidget): """Terminal display widget that renders a pyte screen.""" - key_pressed = pyqtSignal(bytes) - cursor_position_requested = pyqtSignal() + key_pressed = Signal(bytes) + cursor_position_requested = Signal() def __init__(self, rows: int = 24, cols: int = 80, parent=None): super().__init__(parent) @@ -450,7 +450,7 @@ class TerminalDisplay(QWidget): def contextMenuEvent(self, event): """Show context menu with copy/paste.""" - from PyQt6.QtWidgets import QMenu, QApplication + from PySide6.QtWidgets import QMenu, QApplication menu = QMenu(self) @@ -490,7 +490,7 @@ class TerminalDisplay(QWidget): def _copy_selection(self): """Copy selected text to clipboard.""" - from PyQt6.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication start, end = self._get_selection_bounds() if start is None: @@ -513,7 +513,7 @@ class TerminalDisplay(QWidget): def _paste_clipboard(self): """Paste clipboard content as keyboard input.""" - from PyQt6.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication text = QApplication.clipboard().text() if text: @@ -589,7 +589,7 @@ class TerminalDisplay(QWidget): class TerminalWidget(QWidget): """Full terminal widget with PTY support using pyte emulation.""" - closed = pyqtSignal() + closed = Signal() def __init__(self, cwd: Path | None = None, parent=None): super().__init__(parent) diff --git a/src/development_hub/views/dashboard.py b/src/development_hub/views/dashboard.py index d3a29b0..a94f36f 100644 --- a/src/development_hub/views/dashboard.py +++ b/src/development_hub/views/dashboard.py @@ -6,8 +6,8 @@ from pathlib import Path from development_hub.parsers.base import atomic_write -from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher, QTimer, QThread, QObject -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, Signal, QFileSystemWatcher, QTimer, QThread, QObject +from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, @@ -27,13 +27,14 @@ from development_hub.project_discovery import Project from development_hub.services.health_checker import HealthChecker from development_hub.parsers.progress_parser import ProgressLogManager from development_hub.widgets.health_card import HealthCardCompact +from development_hub.dialogs import ReAlignGoalsDialog class AuditWorker(QObject): """Background worker for running goals audit.""" - finished = pyqtSignal(str, bool) # output, success - error = pyqtSignal(str) + finished = Signal(str, bool) # output, success + error = Signal(str) def __init__(self, goals_content: str, project_path: Path | None = None): super().__init__() @@ -117,9 +118,9 @@ class ProjectDashboard(QWidget): - Today's progress """ - terminal_requested = pyqtSignal(object) # Emits Project - project_selected = pyqtSignal(str) # Emits project_key (global mode only) - standup_requested = pyqtSignal() # Global mode only + terminal_requested = Signal(object) # Emits Project + project_selected = Signal(str) # Emits project_key (global mode only) + standup_requested = Signal() # Global mode only def __init__(self, project: Project | None = None, parent: QWidget | None = None): """Initialize project dashboard. @@ -312,7 +313,7 @@ class ProjectDashboard(QWidget): goals_header_layout.addWidget(goals_label) # Goals progress bar - from PyQt6.QtWidgets import QProgressBar + from PySide6.QtWidgets import QProgressBar self.goals_progress = QProgressBar() self.goals_progress.setMinimum(0) self.goals_progress.setMaximum(100) @@ -343,6 +344,11 @@ class ProjectDashboard(QWidget): audit_goals_btn.clicked.connect(self._audit_goals) goals_header_layout.addWidget(audit_goals_btn) + realign_goals_btn = QPushButton("Re-align") + realign_goals_btn.setStyleSheet(self._button_style()) + realign_goals_btn.clicked.connect(self._realign_goals) + goals_header_layout.addWidget(realign_goals_btn) + edit_goals_btn = QPushButton("Edit") edit_goals_btn.setStyleSheet(self._button_style()) edit_goals_btn.clicked.connect(self._open_goals) @@ -1592,7 +1598,7 @@ class ProjectDashboard(QWidget): def _on_todo_start_discussion(self, todo): """Handle starting a discussion for a todo item.""" - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox # Cannot start discussion in global mode (no project context) if self.is_global: @@ -2413,7 +2419,7 @@ class ProjectDashboard(QWidget): """Handle audit completion.""" import json from datetime import datetime - from PyQt6.QtWidgets import QTextEdit, QDialogButtonBox + from PySide6.QtWidgets import QTextEdit, QDialogButtonBox self._cleanup_audit() @@ -2660,6 +2666,18 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} goals_path = self._docs_root / "projects" / self.project.key / "goals.md" self._open_file_in_editor(goals_path) + def _realign_goals(self): + """Open the Re-align Goals dialog to regenerate goals from interview.""" + if self.is_global: + goals_path = self._docs_root / "goals" / "goals.md" + project_name = "global" + else: + goals_path = self._docs_root / "projects" / self.project.key / "goals.md" + project_name = self.project.key + + dialog = ReAlignGoalsDialog(goals_path, project_name, parent=self) + dialog.exec() + def _open_milestones(self): """Open the milestones.md file.""" if self.is_global: diff --git a/src/development_hub/views/global_dashboard.py b/src/development_hub/views/global_dashboard.py index 406606e..e32db33 100644 --- a/src/development_hub/views/global_dashboard.py +++ b/src/development_hub/views/global_dashboard.py @@ -1,7 +1,7 @@ """Global dashboard view - wrapper around ProjectDashboard in global mode.""" -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtWidgets import QWidget, QVBoxLayout +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget, QVBoxLayout from development_hub.views.dashboard import ProjectDashboard @@ -12,8 +12,8 @@ class GlobalDashboard(QWidget): This is a thin wrapper around ProjectDashboard in global mode. """ - project_selected = pyqtSignal(str) # Emits project_key - standup_requested = pyqtSignal() + project_selected = Signal(str) # Emits project_key + standup_requested = Signal() def __init__(self, parent: QWidget | None = None): """Initialize global dashboard.""" diff --git a/src/development_hub/widgets/action_menu.py b/src/development_hub/widgets/action_menu.py index 184916e..8c78e68 100644 --- a/src/development_hub/widgets/action_menu.py +++ b/src/development_hub/widgets/action_menu.py @@ -1,8 +1,8 @@ """Action menu dropdown widget (hamburger menu).""" -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QToolButton, QMenu, QWidget +from PySide6.QtCore import Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QToolButton, QMenu, QWidget class ActionMenu(QToolButton): @@ -17,14 +17,14 @@ class ActionMenu(QToolButton): settings_requested: Emitted when Settings action is triggered """ - dashboard_requested = pyqtSignal() - global_dashboard_requested = pyqtSignal() - terminal_requested = pyqtSignal() - launch_discussion_requested = pyqtSignal() - standup_requested = pyqtSignal() - update_docs_requested = pyqtSignal() - settings_requested = pyqtSignal() - close_pane_requested = pyqtSignal() + dashboard_requested = Signal() + global_dashboard_requested = Signal() + terminal_requested = Signal() + launch_discussion_requested = Signal() + standup_requested = Signal() + update_docs_requested = Signal() + settings_requested = Signal() + close_pane_requested = Signal() def __init__(self, parent: QWidget | None = None): """Initialize action menu. diff --git a/src/development_hub/widgets/collapsible_section.py b/src/development_hub/widgets/collapsible_section.py index 91cc635..3af2763 100644 --- a/src/development_hub/widgets/collapsible_section.py +++ b/src/development_hub/widgets/collapsible_section.py @@ -1,7 +1,7 @@ """Collapsible section widget for dashboards.""" -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, @@ -23,7 +23,7 @@ class CollapsibleSection(QFrame): toggled: Emitted when section is expanded/collapsed (bool expanded) """ - toggled = pyqtSignal(bool) + toggled = Signal(bool) def __init__( self, @@ -179,10 +179,10 @@ class CollapsibleSection(QFrame): class TodoItemWidget(QWidget): """Widget for displaying a single todo item with checkbox.""" - toggled = pyqtSignal(object, bool) # Emits (todo, completed) - deleted = pyqtSignal(object) # Emits todo - start_discussion = pyqtSignal(object) # Emits todo - edited = pyqtSignal(object, str, str) # Emits (todo, old_text, new_text) + toggled = Signal(object, bool) # Emits (todo, completed) + deleted = Signal(object) # Emits todo + start_discussion = Signal(object) # Emits todo + edited = Signal(object, str, str) # Emits (todo, old_text, new_text) def __init__(self, todo, parent: QWidget | None = None, show_priority: bool = False): """Initialize todo item widget. @@ -371,7 +371,7 @@ class TodoItemWidget(QWidget): def eventFilter(self, obj, event): """Handle Escape key to cancel editing, focus out to save.""" - from PyQt6.QtCore import QEvent + from PySide6.QtCore import QEvent if obj == self._edit_widget: if event.type() == QEvent.Type.KeyPress: @@ -416,9 +416,9 @@ class TodoItemWidget(QWidget): class GoalItemWidget(QWidget): """Widget for displaying a single goal item with checkbox.""" - toggled = pyqtSignal(object, bool, bool, bool) # Emits (goal, new_completed, was_completed, was_partial) - deleted = pyqtSignal(object) # Emits goal - edited = pyqtSignal(object, str, str) # Emits (goal, old_text, new_text) + toggled = Signal(object, bool, bool, bool) # Emits (goal, new_completed, was_completed, was_partial) + deleted = Signal(object) # Emits goal + edited = Signal(object, str, str) # Emits (goal, old_text, new_text) def __init__(self, goal, parent: QWidget | None = None, is_non_goal: bool = False): """Initialize goal item widget. @@ -624,7 +624,7 @@ class GoalItemWidget(QWidget): def eventFilter(self, obj, event): """Handle Escape key to cancel editing, focus out to save.""" - from PyQt6.QtCore import QEvent + from PySide6.QtCore import QEvent if obj == self._edit_widget: if event.type() == QEvent.Type.KeyPress: @@ -641,8 +641,8 @@ class GoalItemWidget(QWidget): class DeliverableItemWidget(QWidget): """Widget for displaying a single deliverable item with checkbox.""" - toggled = pyqtSignal(object, bool) # Emits (deliverable, is_done) - deleted = pyqtSignal(object) # Emits deliverable + toggled = Signal(object, bool) # Emits (deliverable, is_done) + deleted = Signal(object) # Emits deliverable def __init__(self, deliverable, parent: QWidget | None = None): """Initialize deliverable item widget. @@ -753,14 +753,14 @@ class MilestoneWidget(QFrame): - Linked todos (preferred, when todos parameter is provided) """ - deliverable_toggled = pyqtSignal(object, object, bool) # (milestone, deliverable, is_done) - deliverable_added = pyqtSignal(object, str) # (milestone, text) - deliverable_deleted = pyqtSignal(object, object) # (milestone, deliverable) - todo_toggled = pyqtSignal(object, bool) # (todo, completed) - for linked todos mode - todo_deleted = pyqtSignal(object) # (todo) - for linked todos mode - todo_added = pyqtSignal(str, str, str) # (text, priority, milestone_id) - for adding new todos - todo_start_discussion = pyqtSignal(object) # (todo) - for starting discussion from todo - todo_edited = pyqtSignal(object, str, str) # (todo, old_text, new_text) - for inline editing + deliverable_toggled = Signal(object, object, bool) # (milestone, deliverable, is_done) + deliverable_added = Signal(object, str) # (milestone, text) + deliverable_deleted = Signal(object, object) # (milestone, deliverable) + todo_toggled = Signal(object, bool) # (todo, completed) - for linked todos mode + todo_deleted = Signal(object) # (todo) - for linked todos mode + todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos + todo_start_discussion = Signal(object) # (todo) - for starting discussion from todo + todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing def __init__( self, diff --git a/src/development_hub/widgets/health_card.py b/src/development_hub/widgets/health_card.py index 606578a..c1c4b44 100644 --- a/src/development_hub/widgets/health_card.py +++ b/src/development_hub/widgets/health_card.py @@ -1,7 +1,7 @@ """Project health card widget.""" -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar class HealthCard(QFrame): @@ -14,7 +14,7 @@ class HealthCard(QFrame): - Todo count """ - clicked = pyqtSignal(str) # Emits project_key when clicked + clicked = Signal(str) # Emits project_key when clicked def __init__(self, parent=None): """Initialize health card. @@ -125,7 +125,7 @@ class HealthCard(QFrame): class HealthCardCompact(QFrame): """Compact single-line health card.""" - clicked = pyqtSignal(str) + clicked = Signal(str) def __init__(self, parent=None): """Initialize compact health card.""" diff --git a/src/development_hub/widgets/progress_bar.py b/src/development_hub/widgets/progress_bar.py index d9cf08a..45e4d59 100644 --- a/src/development_hub/widgets/progress_bar.py +++ b/src/development_hub/widgets/progress_bar.py @@ -1,7 +1,7 @@ """Milestone progress bar widget.""" -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar class MilestoneProgressBar(QWidget): diff --git a/src/development_hub/widgets/stat_card.py b/src/development_hub/widgets/stat_card.py index 07e3714..99d3828 100644 --- a/src/development_hub/widgets/stat_card.py +++ b/src/development_hub/widgets/stat_card.py @@ -1,7 +1,7 @@ """Statistic card widget.""" -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel class StatCard(QFrame): @@ -88,7 +88,7 @@ class StatCardRow(QFrame): def __init__(self, parent=None): """Initialize stat card row.""" super().__init__(parent) - from PyQt6.QtWidgets import QHBoxLayout + from PySide6.QtWidgets import QHBoxLayout self._layout = QHBoxLayout(self) self._layout.setContentsMargins(0, 0, 0, 0) diff --git a/src/development_hub/widgets/toast.py b/src/development_hub/widgets/toast.py index 1f716ad..d56b012 100644 --- a/src/development_hub/widgets/toast.py +++ b/src/development_hub/widgets/toast.py @@ -1,7 +1,7 @@ """Toast notification widget with undo/redo support.""" -from PyQt6.QtCore import Qt, QTimer, pyqtSignal -from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton class Toast(QWidget): @@ -13,9 +13,9 @@ class Toast(QWidget): dismissed: Emitted when toast is dismissed (timeout or manual) """ - undo_clicked = pyqtSignal() - redo_clicked = pyqtSignal() - dismissed = pyqtSignal() + undo_clicked = Signal() + redo_clicked = Signal() + dismissed = Signal() def __init__(self, parent: QWidget | None = None): """Initialize toast widget. diff --git a/src/development_hub/workspace.py b/src/development_hub/workspace.py index 9ee5550..1946849 100644 --- a/src/development_hub/workspace.py +++ b/src/development_hub/workspace.py @@ -3,8 +3,8 @@ from pathlib import Path from weakref import ref -from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, Signal, QEvent, QTimer +from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QSplitter, @@ -40,14 +40,14 @@ PANE_UNFOCUSED_STYLE = """ class PaneWidget(QFrame): """A pane containing a tab widget with terminals.""" - clicked = pyqtSignal(object) # Emits self when clicked - empty = pyqtSignal(object) # Emits self when last tab closed - terminal_requested = pyqtSignal() # Request new terminal - standup_requested = pyqtSignal() # Request daily standup dialog - launch_discussion_requested = pyqtSignal() # Request launch discussion action - update_docs_requested = pyqtSignal() # Request docs update - settings_requested = pyqtSignal() # Request settings dialog - close_requested = pyqtSignal(object) # Emits self when close pane requested + clicked = Signal(object) # Emits self when clicked + empty = Signal(object) # Emits self when last tab closed + terminal_requested = Signal() # Request new terminal + standup_requested = Signal() # Request daily standup dialog + launch_discussion_requested = Signal() # Request launch discussion action + update_docs_requested = Signal() # Request docs update + settings_requested = Signal() # Request settings dialog + close_requested = Signal(object) # Emits self when close pane requested def __init__(self, parent=None): super().__init__(parent) @@ -362,12 +362,12 @@ class PaneWidget(QFrame): class WorkspaceManager(QWidget): """Manages splittable panes, each with their own tab bar.""" - pane_count_changed = pyqtSignal(int) - terminal_requested = pyqtSignal() # Request new terminal (no project context) - standup_requested = pyqtSignal() # Request daily standup dialog - launch_discussion_requested = pyqtSignal() # Request launch discussion action - update_docs_requested = pyqtSignal(str) # Request docs update with project key - settings_requested = pyqtSignal() # Request settings dialog + pane_count_changed = Signal(int) + terminal_requested = Signal() # Request new terminal (no project context) + standup_requested = Signal() # Request daily standup dialog + launch_discussion_requested = Signal() # Request launch discussion action + update_docs_requested = Signal(str) # Request docs update with project key + settings_requested = Signal() # Request settings dialog def __init__(self, parent=None): super().__init__(parent)