diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..ad5e4d3
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,30 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "development-hub"
+version = "0.1.0"
+description = "Central project orchestration GUI for multi-project development"
+readme = "README.md"
+license = {text = "MIT"}
+requires-python = ">=3.10"
+dependencies = [
+ "PyQt6>=6.5.0",
+ "pyte>=0.8.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0",
+ "pytest-qt>=4.0",
+]
+
+[project.scripts]
+development-hub = "development_hub.app:main"
+
+[project.gui-scripts]
+development-hub-gui = "development_hub.app:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/src/development_hub/__init__.py b/src/development_hub/__init__.py
new file mode 100644
index 0000000..5213007
--- /dev/null
+++ b/src/development_hub/__init__.py
@@ -0,0 +1,3 @@
+"""Development Hub - Central project orchestration GUI."""
+
+__version__ = "0.1.0"
diff --git a/src/development_hub/__main__.py b/src/development_hub/__main__.py
new file mode 100644
index 0000000..6fc586d
--- /dev/null
+++ b/src/development_hub/__main__.py
@@ -0,0 +1,7 @@
+"""Entry point for running Development Hub as a module."""
+
+import sys
+from development_hub.app import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/development_hub/app.py b/src/development_hub/app.py
new file mode 100644
index 0000000..cad4aee
--- /dev/null
+++ b/src/development_hub/app.py
@@ -0,0 +1,35 @@
+"""Application entry point for Development Hub."""
+
+import sys
+
+from PyQt6.QtWidgets import QApplication
+
+from development_hub.main_window import MainWindow
+from development_hub.styles import DARK_THEME
+
+
+class DevelopmentHubApp(QApplication):
+ """Main application class for Development Hub."""
+
+ def __init__(self, argv: list[str]):
+ super().__init__(argv)
+ self.setApplicationName("Development Hub")
+ self.setApplicationVersion("0.1.0")
+ self.setStyle("Fusion") # Consistent cross-platform style
+ self.setStyleSheet(DARK_THEME)
+
+
+def main() -> int:
+ """Run the Development Hub application.
+
+ Returns:
+ Exit code (0 for success).
+ """
+ app = DevelopmentHubApp(sys.argv)
+ window = MainWindow()
+ window.show()
+ return app.exec()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/development_hub/dialogs.py b/src/development_hub/dialogs.py
new file mode 100644
index 0000000..86ad233
--- /dev/null
+++ b/src/development_hub/dialogs.py
@@ -0,0 +1,372 @@
+"""Dialogs for Development Hub."""
+
+import json
+import subprocess
+import shutil
+from pathlib import Path
+
+from PyQt6.QtCore import Qt, QThread, pyqtSignal, QProcess
+from PyQt6.QtWidgets import (
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QFormLayout,
+ QLineEdit,
+ QTextEdit,
+ QPushButton,
+ QLabel,
+ QProgressBar,
+ QMessageBox,
+ QGroupBox,
+)
+
+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
+
+ def __init__(self, prompt: str, fields: list[str], criteria: dict[str, str] = None):
+ super().__init__()
+ self.prompt = prompt
+ self.fields = fields
+ self.criteria = criteria or {}
+
+ def run(self):
+ """Run Ramble and parse output."""
+ # Check if ramble is available
+ ramble_path = shutil.which("ramble")
+ if not ramble_path:
+ self.error.emit("Ramble is not installed or not in PATH.\nInstall with: pip install ramble")
+ return
+
+ # Build command
+ cmd = [
+ ramble_path,
+ "--prompt", self.prompt,
+ "--fields", *self.fields,
+ ]
+
+ if self.criteria:
+ cmd.extend(["--criteria", json.dumps(self.criteria)])
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=300, # 5 minute timeout
+ )
+
+ if result.returncode != 0:
+ # User cancelled or error
+ if "cancelled" in result.stderr.lower():
+ self.finished.emit({})
+ else:
+ self.error.emit(f"Ramble error: {result.stderr}")
+ return
+
+ # Parse JSON output
+ try:
+ data = json.loads(result.stdout)
+ self.finished.emit(data)
+ except json.JSONDecodeError:
+ # Ramble might output the dialog result differently
+ # Try to find JSON in the output
+ self.finished.emit({})
+
+ except subprocess.TimeoutExpired:
+ self.error.emit("Ramble timed out after 5 minutes")
+ except Exception as e:
+ self.error.emit(f"Error running Ramble: {e}")
+
+
+class NewProjectThread(QThread):
+ """Thread to run new-project script."""
+
+ output = pyqtSignal(str)
+ finished = pyqtSignal(bool, str) # success, message
+
+ def __init__(self, name: str, title: str, tagline: str, deploy: bool):
+ super().__init__()
+ self.name = name
+ self.title = title
+ self.tagline = tagline
+ self.deploy = deploy
+
+ def run(self):
+ """Run the new-project script."""
+ script = Path.home() / "PycharmProjects" / "development-hub" / "bin" / "new-project"
+
+ if not script.exists():
+ self.finished.emit(False, f"Script not found: {script}")
+ return
+
+ cmd = [
+ str(script),
+ self.name,
+ "--title", self.title,
+ "--tagline", self.tagline,
+ ]
+
+ if self.deploy:
+ cmd.append("--deploy")
+
+ try:
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ )
+
+ # Stream output
+ for line in process.stdout:
+ self.output.emit(line.rstrip())
+
+ process.wait()
+
+ if process.returncode == 0:
+ self.finished.emit(True, f"Project '{self.name}' created successfully!")
+ else:
+ self.finished.emit(False, f"Project creation failed with code {process.returncode}")
+
+ except Exception as e:
+ self.finished.emit(False, f"Error: {e}")
+
+
+class NewProjectDialog(QDialog):
+ """Dialog for creating a new project."""
+
+ project_created = pyqtSignal(str) # Emits project name
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("New Project")
+ self.setMinimumWidth(500)
+ self.settings = Settings()
+
+ self._ramble_thread = None
+ self._create_thread = None
+
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Set up the dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Form fields
+ form_group = QGroupBox("Project Details")
+ form_layout = QFormLayout(form_group)
+
+ self.name_input = QLineEdit()
+ self.name_input.setPlaceholderText("my-project (lowercase, hyphens)")
+ self.name_input.textChanged.connect(self._validate)
+ form_layout.addRow("Name:", self.name_input)
+
+ self.title_input = QLineEdit()
+ self.title_input.setPlaceholderText("My Project")
+ form_layout.addRow("Title:", self.title_input)
+
+ self.tagline_input = QLineEdit()
+ self.tagline_input.setPlaceholderText("A short description of the project")
+ form_layout.addRow("Tagline:", self.tagline_input)
+
+ layout.addWidget(form_group)
+
+ # Ramble button
+ ramble_layout = QHBoxLayout()
+ ramble_layout.addStretch()
+
+ self.ramble_btn = QPushButton("š¤ Ramble...")
+ self.ramble_btn.setToolTip("Describe your project idea and let AI fill in the fields")
+ self.ramble_btn.clicked.connect(self._start_ramble)
+ ramble_layout.addWidget(self.ramble_btn)
+
+ layout.addLayout(ramble_layout)
+
+ # Output area (hidden initially)
+ self.output_group = QGroupBox("Output")
+ output_layout = QVBoxLayout(self.output_group)
+
+ self.output_text = QTextEdit()
+ self.output_text.setReadOnly(True)
+ self.output_text.setMaximumHeight(150)
+ output_layout.addWidget(self.output_text)
+
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setRange(0, 0) # Indeterminate
+ self.progress_bar.hide()
+ output_layout.addWidget(self.progress_bar)
+
+ self.output_group.hide()
+ layout.addWidget(self.output_group)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ button_layout.addStretch()
+
+ self.cancel_btn = QPushButton("Cancel")
+ self.cancel_btn.clicked.connect(self.reject)
+ button_layout.addWidget(self.cancel_btn)
+
+ self.create_btn = QPushButton("Create Project")
+ self.create_btn.setDefault(True)
+ self.create_btn.setEnabled(False)
+ self.create_btn.clicked.connect(self._create_project)
+ button_layout.addWidget(self.create_btn)
+
+ layout.addLayout(button_layout)
+
+ def _validate(self):
+ """Validate form and enable/disable create button."""
+ name = self.name_input.text().strip()
+ # Basic validation: name must be non-empty and valid
+ valid = bool(name) and name.replace("-", "").replace("_", "").isalnum()
+ self.create_btn.setEnabled(valid)
+
+ def _start_ramble(self):
+ """Launch Ramble to fill in fields."""
+ self.ramble_btn.setEnabled(False)
+ self.ramble_btn.setText("š¤ Running Ramble...")
+
+ self._ramble_thread = RambleThread(
+ prompt="Describe the project you want to create. What does it do? What problem does it solve?",
+ fields=["Name", "Title", "Tagline"],
+ criteria={
+ "Name": "lowercase, use hyphens instead of spaces, no special characters",
+ "Title": "Title case, human readable",
+ "Tagline": "One sentence description",
+ }
+ )
+ 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."""
+ self.ramble_btn.setEnabled(True)
+ self.ramble_btn.setText("š¤ Ramble...")
+
+ if result and "fields" in result:
+ fields = result["fields"]
+ if "Name" in fields:
+ self.name_input.setText(fields["Name"])
+ if "Title" in fields:
+ self.title_input.setText(fields["Title"])
+ if "Tagline" in fields:
+ self.tagline_input.setText(fields["Tagline"])
+
+ def _on_ramble_error(self, error: str):
+ """Handle Ramble error."""
+ self.ramble_btn.setEnabled(True)
+ self.ramble_btn.setText("š¤ Ramble...")
+ QMessageBox.warning(self, "Ramble Error", error)
+
+ def _create_project(self):
+ """Start project creation."""
+ name = self.name_input.text().strip()
+ title = self.title_input.text().strip() or name.replace("-", " ").title()
+ tagline = self.tagline_input.text().strip() or f"The {title} project"
+
+ # Show output area
+ self.output_group.show()
+ self.output_text.clear()
+ self.progress_bar.show()
+
+ # Disable inputs
+ self.name_input.setEnabled(False)
+ self.title_input.setEnabled(False)
+ self.tagline_input.setEnabled(False)
+ self.ramble_btn.setEnabled(False)
+ self.create_btn.setEnabled(False)
+
+ # Start creation thread
+ self._create_thread = NewProjectThread(
+ name=name,
+ title=title,
+ tagline=tagline,
+ deploy=self.settings.deploy_docs_after_creation,
+ )
+ self._create_thread.output.connect(self._on_create_output)
+ self._create_thread.finished.connect(self._on_create_finished)
+ self._create_thread.start()
+
+ def _on_create_output(self, line: str):
+ """Handle output from creation script."""
+ self.output_text.append(line)
+ # Auto-scroll to bottom
+ scrollbar = self.output_text.verticalScrollBar()
+ scrollbar.setValue(scrollbar.maximum())
+
+ def _on_create_finished(self, success: bool, message: str):
+ """Handle project creation completion."""
+ self.progress_bar.hide()
+
+ if success:
+ self.output_text.append(f"\nā {message}")
+ self.project_created.emit(self.name_input.text().strip())
+
+ # Change cancel to close
+ self.cancel_btn.setText("Close")
+ self.create_btn.hide()
+ else:
+ self.output_text.append(f"\nā {message}")
+
+ # Re-enable inputs for retry
+ self.name_input.setEnabled(True)
+ self.title_input.setEnabled(True)
+ self.tagline_input.setEnabled(True)
+ self.ramble_btn.setEnabled(True)
+ self.create_btn.setEnabled(True)
+
+
+class SettingsDialog(QDialog):
+ """Dialog for application settings."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Settings")
+ self.setMinimumWidth(400)
+ self.settings = Settings()
+
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Set up the dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Project creation settings
+ project_group = QGroupBox("Project Creation")
+ project_layout = QVBoxLayout(project_group)
+
+ from PyQt6.QtWidgets import QCheckBox
+ self.deploy_checkbox = QCheckBox("Deploy docs after creating new project")
+ self.deploy_checkbox.setChecked(self.settings.deploy_docs_after_creation)
+ project_layout.addWidget(self.deploy_checkbox)
+
+ layout.addWidget(project_group)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ button_layout.addStretch()
+
+ cancel_btn = QPushButton("Cancel")
+ cancel_btn.clicked.connect(self.reject)
+ button_layout.addWidget(cancel_btn)
+
+ save_btn = QPushButton("Save")
+ save_btn.setDefault(True)
+ save_btn.clicked.connect(self._save)
+ button_layout.addWidget(save_btn)
+
+ layout.addLayout(button_layout)
+
+ def _save(self):
+ """Save settings and close."""
+ self.settings.deploy_docs_after_creation = self.deploy_checkbox.isChecked()
+ self.accept()
diff --git a/src/development_hub/main_window.py b/src/development_hub/main_window.py
new file mode 100644
index 0000000..c113064
--- /dev/null
+++ b/src/development_hub/main_window.py
@@ -0,0 +1,276 @@
+"""Main window for Development Hub."""
+
+from pathlib import Path
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QAction, QKeySequence
+from PyQt6.QtWidgets import (
+ QLabel,
+ QMainWindow,
+ QSplitter,
+ QStatusBar,
+ QVBoxLayout,
+ QWidget,
+)
+
+from development_hub.project_discovery import Project
+from development_hub.project_list import ProjectListWidget
+from development_hub.workspace import WorkspaceManager
+from development_hub.dialogs import NewProjectDialog, SettingsDialog
+from development_hub.settings import Settings
+
+
+class MainWindow(QMainWindow):
+ """Main application window with project list and workspace."""
+
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Development Hub")
+ self.resize(1200, 800)
+ self.settings = Settings()
+
+ self._setup_ui()
+ self._setup_menus()
+ self._setup_status_bar()
+ self._restore_session()
+
+ def _setup_ui(self):
+ """Set up the main UI layout."""
+ # Central widget with splitter
+ central = QWidget()
+ self.setCentralWidget(central)
+
+ layout = QVBoxLayout(central)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Main splitter: project list | workspace
+ self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
+ layout.addWidget(self.main_splitter)
+
+ # Left: Project list
+ self.project_list = ProjectListWidget()
+ self.project_list.setFixedWidth(200)
+ self.project_list.open_terminal_requested.connect(self._open_terminal)
+ self.main_splitter.addWidget(self.project_list)
+
+ # Right: Workspace manager with splittable panes
+ self.workspace = WorkspaceManager()
+ self.workspace.pane_count_changed.connect(self._update_status)
+ self.main_splitter.addWidget(self.workspace)
+
+ # Set splitter proportions
+ self.main_splitter.setSizes([200, 1000])
+
+ def _setup_menus(self):
+ """Set up the menu bar."""
+ menubar = self.menuBar()
+
+ # File menu
+ file_menu = menubar.addMenu("&File")
+
+ new_project = QAction("&New Project...", self)
+ new_project.setShortcut(QKeySequence("Ctrl+N"))
+ new_project.triggered.connect(self._new_project)
+ file_menu.addAction(new_project)
+
+ file_menu.addSeparator()
+
+ settings_action = QAction("&Settings...", self)
+ settings_action.setShortcut(QKeySequence("Ctrl+,"))
+ settings_action.triggered.connect(self._show_settings)
+ file_menu.addAction(settings_action)
+
+ file_menu.addSeparator()
+
+ exit_action = QAction("E&xit", self)
+ exit_action.setShortcut(QKeySequence("Ctrl+Q"))
+ exit_action.triggered.connect(self.close)
+ file_menu.addAction(exit_action)
+
+ # Project menu
+ project_menu = menubar.addMenu("&Project")
+
+ refresh = QAction("&Refresh List", self)
+ refresh.setShortcut(QKeySequence("F5"))
+ refresh.triggered.connect(self.project_list.refresh)
+ project_menu.addAction(refresh)
+
+ # View menu
+ view_menu = menubar.addMenu("&View")
+
+ toggle_projects = QAction("Toggle &Project Panel", self)
+ toggle_projects.setShortcut(QKeySequence("Ctrl+B"))
+ toggle_projects.triggered.connect(self._toggle_project_panel)
+ view_menu.addAction(toggle_projects)
+
+ view_menu.addSeparator()
+
+ split_h = QAction("Split &Horizontal", self)
+ split_h.setShortcut(QKeySequence("Ctrl+Shift+D"))
+ split_h.triggered.connect(self._split_horizontal)
+ view_menu.addAction(split_h)
+
+ split_v = QAction("Split &Vertical", self)
+ split_v.setShortcut(QKeySequence("Ctrl+Shift+E"))
+ split_v.triggered.connect(self._split_vertical)
+ view_menu.addAction(split_v)
+
+ view_menu.addSeparator()
+
+ close_pane = QAction("Close &Pane", self)
+ close_pane.setShortcut(QKeySequence("Ctrl+Shift+P"))
+ close_pane.triggered.connect(self._close_active_pane)
+ view_menu.addAction(close_pane)
+
+ view_menu.addSeparator()
+
+ next_pane = QAction("&Next Pane", self)
+ next_pane.setShortcut(QKeySequence("Ctrl+Alt+Right"))
+ next_pane.triggered.connect(self.workspace.focus_next_pane)
+ view_menu.addAction(next_pane)
+
+ prev_pane = QAction("P&revious Pane", self)
+ prev_pane.setShortcut(QKeySequence("Ctrl+Alt+Left"))
+ prev_pane.triggered.connect(self.workspace.focus_previous_pane)
+ view_menu.addAction(prev_pane)
+
+ # Terminal menu
+ terminal_menu = menubar.addMenu("&Terminal")
+
+ new_tab = QAction("New &Tab", self)
+ new_tab.setShortcut(QKeySequence("Ctrl+Shift+T"))
+ new_tab.triggered.connect(self._new_terminal_tab)
+ terminal_menu.addAction(new_tab)
+
+ close_tab = QAction("&Close Tab", self)
+ close_tab.setShortcut(QKeySequence("Ctrl+Shift+W"))
+ close_tab.triggered.connect(self._close_current_tab)
+ terminal_menu.addAction(close_tab)
+
+ # Help menu
+ help_menu = menubar.addMenu("&Help")
+
+ about = QAction("&About", self)
+ about.triggered.connect(self._show_about)
+ help_menu.addAction(about)
+
+ def _setup_status_bar(self):
+ """Set up the status bar."""
+ self.status_bar = QStatusBar()
+ self.setStatusBar(self.status_bar)
+ self._update_status()
+
+ def _update_status(self, *args):
+ """Update status bar text."""
+ project_count = self.project_list.list_widget.count()
+ pane_count = len(self.workspace.find_panes())
+ tab_count = self.workspace.total_tab_count()
+
+ pane_str = f"{pane_count} pane{'s' if pane_count != 1 else ''}"
+ tab_str = f"{tab_count} tab{'s' if tab_count != 1 else ''}"
+
+ self.status_bar.showMessage(
+ f"{project_count} projects | {pane_str} | {tab_str}"
+ )
+
+ def _open_terminal(self, project: Project):
+ """Open a terminal tab for a project in the active pane."""
+ self.workspace.add_terminal(project.path, project.title)
+ self._update_status()
+
+ def _new_terminal_tab(self):
+ """Create a new terminal tab at home directory in the active pane."""
+ # Count existing terminal tabs for naming
+ panes = self.workspace.find_panes()
+ terminal_count = 0
+ for pane in panes:
+ for i in range(pane.tab_widget.count()):
+ if pane.tab_widget.tabText(i).startswith("Terminal"):
+ terminal_count += 1
+
+ tab_name = f"Terminal {terminal_count + 1}" if terminal_count > 0 else "Terminal"
+ self.workspace.add_terminal(Path.home(), tab_name)
+ self._update_status()
+
+ def _close_current_tab(self):
+ """Close the current tab in the active pane."""
+ self.workspace.close_current_tab()
+ self._update_status()
+
+ def _toggle_project_panel(self):
+ """Toggle visibility of the project list panel."""
+ if self.project_list.isVisible():
+ self.project_list.hide()
+ else:
+ self.project_list.show()
+
+ def _split_horizontal(self):
+ """Split the active pane horizontally (creates left/right panes)."""
+ self.workspace.split_horizontal()
+
+ def _split_vertical(self):
+ """Split the active pane vertically (creates top/bottom panes)."""
+ self.workspace.split_vertical()
+
+ def _close_active_pane(self):
+ """Close the currently active pane."""
+ self.workspace.close_active_pane()
+ self._update_status()
+
+ def _new_project(self):
+ """Show new project dialog."""
+ dialog = NewProjectDialog(self)
+ dialog.project_created.connect(self._on_project_created)
+ dialog.exec()
+
+ def _on_project_created(self, project_name: str):
+ """Handle new project creation."""
+ # Refresh the project list to show the new project
+ self.project_list.refresh()
+ self._update_status()
+
+ def _show_settings(self):
+ """Show settings dialog."""
+ dialog = SettingsDialog(self)
+ dialog.exec()
+
+ def _show_about(self):
+ """Show about dialog."""
+ from PyQt6.QtWidgets import QMessageBox
+
+ QMessageBox.about(
+ self,
+ "About Development Hub",
+ "
Development Hub
"
+ "Version 0.1.0
"
+ "Central project orchestration for multi-project development.
"
+ "Part of Rob's development ecosystem.
"
+ "Keyboard Shortcuts
"
+ ""
+ "- Ctrl+Shift+T - New terminal tab
"
+ "- Ctrl+Shift+W - Close current tab
"
+ "- Ctrl+Shift+D - Split pane horizontal
"
+ "- Ctrl+Shift+E - Split pane vertical
"
+ "- Ctrl+Alt+Left/Right - Switch panes
"
+ "- Ctrl+B - Toggle project panel
"
+ "- F5 - Refresh project list
"
+ "
"
+ )
+
+ def _restore_session(self):
+ """Restore the previous session."""
+ session = self.settings.load_session()
+ if session:
+ self.workspace.restore_session(session)
+ self._update_status()
+
+ def closeEvent(self, event):
+ """Handle window close - save session and terminate all terminals."""
+ # Save session state
+ session = self.workspace.get_session_state()
+ self.settings.save_session(session)
+
+ # Terminate all terminals
+ self.workspace.terminate_all()
+ super().closeEvent(event)
diff --git a/src/development_hub/project_discovery.py b/src/development_hub/project_discovery.py
new file mode 100644
index 0000000..76d1bc7
--- /dev/null
+++ b/src/development_hub/project_discovery.py
@@ -0,0 +1,84 @@
+"""Discover projects from the build-public-docs.sh configuration."""
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class Project:
+ """Represents a project in the development ecosystem."""
+
+ key: str
+ title: str
+ tagline: str
+ owner: str
+ repo: str
+ dirname: str
+
+ @property
+ def path(self) -> Path:
+ """Local path to project directory."""
+ return Path.home() / "PycharmProjects" / self.dirname
+
+ @property
+ def gitea_url(self) -> str:
+ """URL to Gitea repository."""
+ return f"https://gitea.brrd.tech/{self.owner}/{self.repo}"
+
+ @property
+ def docs_url(self) -> str:
+ """URL to public documentation."""
+ return f"https://pages.brrd.tech/{self.owner}/{self.repo}/"
+
+ @property
+ def exists(self) -> bool:
+ """Check if project directory exists locally."""
+ return self.path.exists()
+
+
+def discover_projects() -> list[Project]:
+ """Parse PROJECT_CONFIG from build-public-docs.sh.
+
+ Returns:
+ List of Project objects discovered from the build script.
+ """
+ build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
+
+ if not build_script.exists():
+ return []
+
+ projects = []
+ # Pattern: PROJECT_CONFIG["key"]="Title|Tagline|Owner|Repo|DirName"
+ pattern = r'PROJECT_CONFIG\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'
+
+ for match in re.finditer(pattern, build_script.read_text()):
+ key, title, tagline, owner, repo, dirname = match.groups()
+ projects.append(Project(
+ key=key,
+ title=title,
+ tagline=tagline,
+ owner=owner,
+ repo=repo,
+ dirname=dirname,
+ ))
+
+ # Sort by title for consistent display
+ projects.sort(key=lambda p: p.title.lower())
+
+ return projects
+
+
+def get_project_by_key(key: str) -> Project | None:
+ """Get a specific project by its key.
+
+ Args:
+ key: The project key (e.g., 'cmdforge', 'ramble')
+
+ Returns:
+ Project object if found, None otherwise.
+ """
+ for project in discover_projects():
+ if project.key == key:
+ return project
+ return None
diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py
new file mode 100644
index 0000000..a688857
--- /dev/null
+++ b/src/development_hub/project_list.py
@@ -0,0 +1,153 @@
+"""Project list widget showing all discovered projects."""
+
+import subprocess
+import webbrowser
+from pathlib import Path
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QAction
+from PyQt6.QtWidgets import (
+ QLabel,
+ QListWidget,
+ QListWidgetItem,
+ QMenu,
+ QVBoxLayout,
+ QWidget,
+)
+
+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)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._projects: list[Project] = []
+ self._setup_ui()
+ self.refresh()
+
+ def _setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Header
+ header = QLabel("PROJECTS")
+ header.setObjectName("header")
+ layout.addWidget(header)
+
+ # Project list
+ self.list_widget = QListWidget()
+ self.list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.list_widget.customContextMenuRequested.connect(self._show_context_menu)
+ self.list_widget.itemClicked.connect(self._on_item_clicked)
+ self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
+ layout.addWidget(self.list_widget)
+
+ def refresh(self):
+ """Refresh the project list from the build configuration."""
+ self.list_widget.clear()
+ self._projects = discover_projects()
+
+ for project in self._projects:
+ item = QListWidgetItem(project.title)
+ item.setData(Qt.ItemDataRole.UserRole, project)
+
+ # Add tooltip with project info
+ tooltip = f"{project.tagline}\n\nPath: {project.path}"
+ if not project.exists:
+ tooltip += "\n\n(Directory not found)"
+ item.setForeground(Qt.GlobalColor.darkGray)
+ item.setToolTip(tooltip)
+
+ self.list_widget.addItem(item)
+
+ def _get_selected_project(self) -> Project | None:
+ """Get the currently selected project."""
+ item = self.list_widget.currentItem()
+ if item:
+ return item.data(Qt.ItemDataRole.UserRole)
+ return None
+
+ def _on_item_clicked(self, item: QListWidgetItem):
+ """Handle single click on project."""
+ project = item.data(Qt.ItemDataRole.UserRole)
+ self.project_selected.emit(project)
+
+ def _on_item_double_clicked(self, item: QListWidgetItem):
+ """Handle double-click - open terminal at project."""
+ project = item.data(Qt.ItemDataRole.UserRole)
+ if project.exists:
+ self.open_terminal_requested.emit(project)
+
+ def _show_context_menu(self, position):
+ """Show context menu for project actions."""
+ item = self.list_widget.itemAt(position)
+ if not item:
+ return
+
+ project = item.data(Qt.ItemDataRole.UserRole)
+ menu = QMenu(self)
+
+ # Open Terminal
+ open_terminal = QAction("Open Terminal", self)
+ open_terminal.triggered.connect(lambda: self.open_terminal_requested.emit(project))
+ open_terminal.setEnabled(project.exists)
+ menu.addAction(open_terminal)
+
+ # Open in Editor
+ open_editor = QAction("Open in Editor", self)
+ open_editor.triggered.connect(lambda: self._open_in_editor(project))
+ open_editor.setEnabled(project.exists)
+ menu.addAction(open_editor)
+
+ menu.addSeparator()
+
+ # View on Gitea
+ view_gitea = QAction("View on Gitea", self)
+ view_gitea.triggered.connect(lambda: webbrowser.open(project.gitea_url))
+ menu.addAction(view_gitea)
+
+ # View Documentation
+ view_docs = QAction("View Documentation", self)
+ view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url))
+ menu.addAction(view_docs)
+
+ menu.addSeparator()
+
+ # Deploy Docs
+ deploy_docs = QAction("Deploy Docs", self)
+ deploy_docs.triggered.connect(lambda: self._deploy_docs(project))
+ menu.addAction(deploy_docs)
+
+ menu.exec(self.list_widget.mapToGlobal(position))
+
+ def _open_in_editor(self, project: Project):
+ """Open project in the default editor."""
+ import os
+ import shutil
+
+ # Try common editors in order of preference
+ editors = [
+ os.environ.get("EDITOR"),
+ "pycharm",
+ "code", # VS Code
+ "subl", # Sublime Text
+ "gedit",
+ "xdg-open",
+ ]
+
+ for editor in editors:
+ if editor and shutil.which(editor):
+ subprocess.Popen([editor, str(project.path)])
+ return
+
+ def _deploy_docs(self, project: Project):
+ """Deploy documentation for project."""
+ build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
+ if build_script.exists():
+ subprocess.Popen([str(build_script), project.key, "--deploy"])
diff --git a/src/development_hub/settings.py b/src/development_hub/settings.py
new file mode 100644
index 0000000..10ab24e
--- /dev/null
+++ b/src/development_hub/settings.py
@@ -0,0 +1,90 @@
+"""Settings management for Development Hub."""
+
+import json
+from pathlib import Path
+
+
+class Settings:
+ """Application settings with file persistence."""
+
+ _instance = None
+ _settings_file = Path.home() / ".config" / "development-hub" / "settings.json"
+ _session_file = Path.home() / ".config" / "development-hub" / "session.json"
+
+ # Default settings
+ DEFAULTS = {
+ "deploy_docs_after_creation": True,
+ "default_project_path": str(Path.home() / "PycharmProjects"),
+ }
+
+ def __new__(cls):
+ """Singleton pattern."""
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._settings = {}
+ cls._instance._load()
+ return cls._instance
+
+ def _load(self):
+ """Load settings from file."""
+ self._settings = self.DEFAULTS.copy()
+
+ if self._settings_file.exists():
+ try:
+ with open(self._settings_file) as f:
+ saved = json.load(f)
+ self._settings.update(saved)
+ except (json.JSONDecodeError, OSError):
+ pass
+
+ def _save(self):
+ """Save settings to file."""
+ self._settings_file.parent.mkdir(parents=True, exist_ok=True)
+ with open(self._settings_file, "w") as f:
+ json.dump(self._settings, f, indent=2)
+
+ def get(self, key: str, default=None):
+ """Get a setting value."""
+ return self._settings.get(key, default)
+
+ def set(self, key: str, value):
+ """Set a setting value and save."""
+ self._settings[key] = value
+ self._save()
+
+ @property
+ def deploy_docs_after_creation(self) -> bool:
+ """Whether to deploy docs after creating a new project."""
+ return self.get("deploy_docs_after_creation", True)
+
+ @deploy_docs_after_creation.setter
+ def deploy_docs_after_creation(self, value: bool):
+ self.set("deploy_docs_after_creation", value)
+
+ @property
+ def default_project_path(self) -> Path:
+ """Default path for new projects."""
+ return Path(self.get("default_project_path", str(Path.home() / "PycharmProjects")))
+
+ @default_project_path.setter
+ def default_project_path(self, value: Path):
+ self.set("default_project_path", str(value))
+
+ def save_session(self, state: dict):
+ """Save session state to file."""
+ self._session_file.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ with open(self._session_file, "w") as f:
+ json.dump(state, f, indent=2)
+ except OSError:
+ pass
+
+ def load_session(self) -> dict:
+ """Load session state from file."""
+ if self._session_file.exists():
+ try:
+ with open(self._session_file) as f:
+ return json.load(f)
+ except (json.JSONDecodeError, OSError):
+ pass
+ return {}
diff --git a/src/development_hub/styles.py b/src/development_hub/styles.py
new file mode 100644
index 0000000..4ffb97a
--- /dev/null
+++ b/src/development_hub/styles.py
@@ -0,0 +1,214 @@
+"""Dark theme stylesheet for Development Hub."""
+
+DARK_THEME = """
+QMainWindow {
+ background-color: #1e1e1e;
+}
+
+QWidget {
+ background-color: #1e1e1e;
+ color: #d4d4d4;
+ font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
+ font-size: 12px;
+}
+
+QMenuBar {
+ background-color: #252526;
+ color: #cccccc;
+ border-bottom: 1px solid #3c3c3c;
+ padding: 4px;
+}
+
+QMenuBar::item {
+ padding: 4px 10px;
+ background-color: transparent;
+}
+
+QMenuBar::item:selected {
+ background-color: #094771;
+ border-radius: 3px;
+}
+
+QMenu {
+ background-color: #252526;
+ border: 1px solid #3c3c3c;
+ padding: 4px;
+}
+
+QMenu::item {
+ padding: 6px 24px 6px 12px;
+}
+
+QMenu::item:selected {
+ background-color: #094771;
+}
+
+QMenu::separator {
+ height: 1px;
+ background-color: #3c3c3c;
+ margin: 4px 8px;
+}
+
+QListWidget {
+ background-color: #252526;
+ border: none;
+ outline: none;
+ padding: 4px;
+}
+
+QListWidget::item {
+ padding: 8px 12px;
+ border-radius: 3px;
+ margin: 2px 4px;
+}
+
+QListWidget::item:hover {
+ background-color: #2a2d2e;
+}
+
+QListWidget::item:selected {
+ background-color: #094771;
+}
+
+QSplitter::handle {
+ background-color: #3c3c3c;
+}
+
+QSplitter::handle:horizontal {
+ width: 2px;
+}
+
+QSplitter::handle:vertical {
+ height: 2px;
+}
+
+QTabWidget::pane {
+ border: 1px solid #3c3c3c;
+ background-color: #1e1e1e;
+}
+
+QTabBar::tab {
+ background-color: #2d2d2d;
+ color: #969696;
+ padding: 8px 16px;
+ border: none;
+ border-right: 1px solid #3c3c3c;
+}
+
+QTabBar::tab:selected {
+ background-color: #1e1e1e;
+ color: #ffffff;
+ border-bottom: 2px solid #007acc;
+}
+
+QTabBar::tab:hover:!selected {
+ background-color: #323232;
+}
+
+QStatusBar {
+ background-color: #007acc;
+ color: #ffffff;
+ padding: 2px 8px;
+}
+
+QLabel#header {
+ font-size: 11px;
+ font-weight: bold;
+ color: #858585;
+ padding: 8px 12px 4px 12px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+QScrollBar:vertical {
+ background-color: #1e1e1e;
+ width: 12px;
+ margin: 0;
+}
+
+QScrollBar::handle:vertical {
+ background-color: #424242;
+ min-height: 20px;
+ border-radius: 3px;
+ margin: 2px;
+}
+
+QScrollBar::handle:vertical:hover {
+ background-color: #525252;
+}
+
+QScrollBar::add-line:vertical,
+QScrollBar::sub-line:vertical {
+ height: 0;
+}
+
+QScrollBar:horizontal {
+ background-color: #1e1e1e;
+ height: 12px;
+ margin: 0;
+}
+
+QScrollBar::handle:horizontal {
+ background-color: #424242;
+ min-width: 20px;
+ border-radius: 3px;
+ margin: 2px;
+}
+
+QScrollBar::handle:horizontal:hover {
+ background-color: #525252;
+}
+
+QScrollBar::add-line:horizontal,
+QScrollBar::sub-line:horizontal {
+ width: 0;
+}
+
+QPushButton {
+ background-color: #0e639c;
+ color: #ffffff;
+ border: none;
+ padding: 6px 14px;
+ border-radius: 3px;
+}
+
+QPushButton:hover {
+ background-color: #1177bb;
+}
+
+QPushButton:pressed {
+ background-color: #094771;
+}
+
+QPushButton:disabled {
+ background-color: #3c3c3c;
+ color: #6e6e6e;
+}
+
+QLineEdit {
+ background-color: #3c3c3c;
+ border: 1px solid #3c3c3c;
+ border-radius: 3px;
+ padding: 6px 8px;
+ color: #cccccc;
+ selection-background-color: #094771;
+}
+
+QLineEdit:focus {
+ border-color: #007acc;
+}
+
+QTextEdit, QPlainTextEdit {
+ background-color: #1e1e1e;
+ border: none;
+ color: #d4d4d4;
+ selection-background-color: #264f78;
+}
+
+QToolTip {
+ background-color: #252526;
+ color: #cccccc;
+ border: 1px solid #3c3c3c;
+ padding: 4px 8px;
+}
+"""
diff --git a/src/development_hub/terminal_widget.py b/src/development_hub/terminal_widget.py
new file mode 100644
index 0000000..071c8a6
--- /dev/null
+++ b/src/development_hub/terminal_widget.py
@@ -0,0 +1,786 @@
+"""PTY-based terminal widget with full terminal emulation using pyte."""
+
+import fcntl
+import os
+import pty
+import select
+import struct
+import subprocess
+import termios
+from pathlib import Path
+
+import pyte
+from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
+from PyQt6.QtGui import (
+ QFont,
+ QFontMetrics,
+ QColor,
+ QPainter,
+ QKeyEvent,
+ QPalette,
+ QTextCharFormat,
+ QDragEnterEvent,
+ QDropEvent,
+)
+from PyQt6.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()
+
+ def __init__(self, master_fd: int):
+ super().__init__()
+ self.master_fd = master_fd
+ self._running = True
+
+ def run(self):
+ """Read from PTY and emit output."""
+ while self._running:
+ try:
+ readable, _, _ = select.select([self.master_fd], [], [], 0.1)
+ if readable:
+ try:
+ data = os.read(self.master_fd, 4096)
+ if data:
+ self.output_ready.emit(data)
+ else:
+ break
+ except OSError:
+ break
+ except (ValueError, OSError):
+ break
+
+ self.finished_signal.emit()
+
+ def stop(self):
+ """Stop the reader thread."""
+ self._running = False
+
+
+# Color mapping from pyte to Qt
+PYTE_COLORS = {
+ "black": QColor("#1e1e1e"),
+ "red": QColor("#f44747"),
+ "green": QColor("#6a9955"),
+ "brown": QColor("#dcdcaa"), # yellow
+ "blue": QColor("#569cd6"),
+ "magenta": QColor("#c586c0"),
+ "cyan": QColor("#4ec9b0"),
+ "white": QColor("#d4d4d4"),
+ "default": QColor("#d4d4d4"),
+ # Bright colors
+ "brightblack": QColor("#808080"),
+ "brightred": QColor("#f14c4c"),
+ "brightgreen": QColor("#73c991"),
+ "brightbrown": QColor("#e2e210"),
+ "brightblue": QColor("#3794ff"),
+ "brightmagenta": QColor("#d670d6"),
+ "brightcyan": QColor("#29b8db"),
+ "brightwhite": QColor("#ffffff"),
+}
+
+BG_COLORS = {
+ "black": QColor("#1e1e1e"),
+ "red": QColor("#5a1d1d"),
+ "green": QColor("#1d3d1d"),
+ "brown": QColor("#3d3d1d"),
+ "blue": QColor("#1d1d5a"),
+ "magenta": QColor("#3d1d3d"),
+ "cyan": QColor("#1d3d3d"),
+ "white": QColor("#3d3d3d"),
+ "default": QColor("#1e1e1e"),
+}
+
+
+class TerminalDisplay(QWidget):
+ """Terminal display widget that renders a pyte screen."""
+
+ key_pressed = pyqtSignal(bytes)
+ cursor_position_requested = pyqtSignal()
+
+ def __init__(self, rows: int = 24, cols: int = 80, parent=None):
+ super().__init__(parent)
+
+ # Create pyte screen and stream
+ self.screen = pyte.Screen(cols, rows)
+ self.stream = pyte.Stream(self.screen)
+
+ # Set up display
+ self._setup_display()
+
+ # Cursor state (no blinking for performance)
+ self._cursor_visible = True
+
+ # Enable focus
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+
+ # Enable drag-drop
+ self.setAcceptDrops(True)
+
+ # Dirty flag for efficient updates
+ self._dirty = True
+
+ # Selection state
+ self._selection_start = None # (row, col)
+ self._selection_end = None # (row, col)
+ self._selecting = False
+
+ def _setup_display(self):
+ """Configure the terminal display."""
+ # Use monospace font - smaller size
+ self.font = QFont("Monospace", 10)
+ self.font.setStyleHint(QFont.StyleHint.Monospace)
+ self.font.setFixedPitch(True)
+ self.setFont(self.font)
+
+ # Calculate character dimensions
+ fm = QFontMetrics(self.font)
+ self.char_width = fm.horizontalAdvance("M")
+ self.char_height = fm.lineSpacing()
+
+ # Set background
+ palette = self.palette()
+ palette.setColor(QPalette.ColorRole.Window, QColor("#1e1e1e"))
+ self.setPalette(palette)
+ self.setAutoFillBackground(True)
+
+ def resize_screen(self, rows: int, cols: int):
+ """Resize the pyte screen."""
+ self.screen.resize(rows, cols)
+ self._dirty = True
+ self.update()
+
+ def feed(self, data: bytes):
+ """Feed data to the terminal emulator."""
+ try:
+ text = data.decode("utf-8", errors="replace")
+ except UnicodeDecodeError:
+ text = data.decode("latin-1", errors="replace")
+
+ # Check for cursor position query (DSR) before feeding to pyte
+ if '\x1b[6n' in text:
+ self.cursor_position_requested.emit()
+ text = text.replace('\x1b[6n', '')
+
+ if text:
+ self.stream.feed(text)
+ self._dirty = True
+ self.update()
+
+ # Debug: print non-default attributes (disabled)
+ # self._debug_screen()
+
+ def _debug_screen(self):
+ """Debug: Print cells with non-default attributes."""
+ for row in range(min(5, self.screen.lines)): # Just first 5 rows
+ for col in range(min(80, self.screen.columns)):
+ char = self.screen.buffer[row][col]
+ if char.reverse or char.bg != "default" or (char.fg != "default" and char.data.strip()):
+ print(f"[{row},{col}] '{char.data}' fg={char.fg} bg={char.bg} rev={char.reverse} bold={char.bold}")
+
+ def paintEvent(self, event):
+ """Render the terminal screen."""
+ painter = QPainter(self)
+ painter.setFont(self.font)
+
+ # Fill background
+ default_bg_color = QColor("#1e1e1e")
+ painter.fillRect(self.rect(), default_bg_color)
+
+ fm = QFontMetrics(self.font)
+ ascent = fm.ascent()
+
+ # Draw each row
+ for row in range(min(self.screen.lines, self.height() // self.char_height + 1)):
+ y_base = row * self.char_height
+ y_text = y_base + ascent
+
+ col = 0
+ while col < self.screen.columns:
+ char = self.screen.buffer[row][col]
+ x = col * self.char_width
+
+ # Get colors
+ fg_color = self._get_fg_color(char)
+ bg_color = self._get_bg_color(char)
+
+ # Draw background if not default (compare RGB values)
+ if bg_color.rgb() != default_bg_color.rgb():
+ painter.fillRect(x, y_base, self.char_width, self.char_height, bg_color)
+
+ # Draw character
+ if char.data and char.data != " ":
+ painter.setPen(fg_color)
+ painter.drawText(x, y_text, char.data)
+
+ col += 1
+
+
+ # Draw selection
+ self._draw_selection(painter)
+
+ # Draw cursor (block cursor)
+ if self._cursor_visible and self.hasFocus():
+ cx = self.screen.cursor.x
+ cy = self.screen.cursor.y
+ if 0 <= cx < self.screen.columns and 0 <= cy < self.screen.lines:
+ cursor_x = cx * self.char_width
+ cursor_y = cy * self.char_height
+ painter.fillRect(
+ cursor_x, cursor_y,
+ self.char_width, self.char_height,
+ QColor(200, 200, 200, 128)
+ )
+
+ def _color_to_qcolor(self, color, default_fg=True) -> QColor:
+ """Convert a pyte color to QColor.
+
+ Args:
+ color: The color value from pyte (string, int, or tuple)
+ default_fg: If True, return default foreground; else default background
+ """
+ default = QColor("#d4d4d4") if default_fg else QColor("#1e1e1e")
+
+ if color == "default" or color is None:
+ return default
+
+ # Handle string color names or hex colors
+ if isinstance(color, str):
+ # Check if it's a hex color (6 hex digits) - pyte returns 256/truecolor as hex
+ if len(color) == 6 and all(c in '0123456789abcdefABCDEF' for c in color):
+ return QColor(f"#{color}")
+
+ # For named colors, use our palettes
+ if default_fg:
+ return PYTE_COLORS.get(color, default)
+ else:
+ return BG_COLORS.get(color, default)
+
+ # Handle 256 color palette
+ if isinstance(color, int):
+ if color < 16:
+ # Standard 16 colors
+ fg_colors = [
+ QColor("#1e1e1e"), QColor("#f44747"), QColor("#6a9955"), QColor("#dcdcaa"),
+ QColor("#569cd6"), QColor("#c586c0"), QColor("#4ec9b0"), QColor("#d4d4d4"),
+ QColor("#808080"), QColor("#f14c4c"), QColor("#73c991"), QColor("#e2e210"),
+ QColor("#3794ff"), QColor("#d670d6"), QColor("#29b8db"), QColor("#ffffff"),
+ ]
+ bg_colors = [
+ QColor("#1e1e1e"), QColor("#5a1d1d"), QColor("#1d3d1d"), QColor("#3d3d1d"),
+ QColor("#1d1d5a"), QColor("#3d1d3d"), QColor("#1d3d3d"), QColor("#4a4a4a"),
+ QColor("#4d4d4d"), QColor("#8b3d3d"), QColor("#3d6b3d"), QColor("#6b6b3d"),
+ QColor("#3d3d8b"), QColor("#6b3d6b"), QColor("#3d6b6b"), QColor("#6a6a6a"),
+ ]
+ return fg_colors[color] if default_fg else bg_colors[color]
+ elif color < 232:
+ # 216 color cube
+ c = color - 16
+ r = (c // 36) * 51
+ g = ((c // 6) % 6) * 51
+ b = (c % 6) * 51
+ return QColor(r, g, b)
+ else:
+ # Grayscale (24 shades)
+ gray = (color - 232) * 10 + 8
+ return QColor(gray, gray, gray)
+
+ # Handle RGB tuple (truecolor)
+ if isinstance(color, tuple) and len(color) == 3:
+ return QColor(color[0], color[1], color[2])
+
+ return default
+
+ def _get_fg_color(self, char) -> QColor:
+ """Get foreground color for a character."""
+ if char.reverse:
+ # Reverse video: swap fg/bg
+ if char.bg == "default":
+ return QColor("#1e1e1e")
+ return self._color_to_qcolor(char.bg, default_fg=True)
+
+ if char.fg == "default":
+ color = QColor("#d4d4d4")
+ else:
+ color = self._color_to_qcolor(char.fg, default_fg=True)
+
+ # Brighten if bold
+ if char.bold and isinstance(char.fg, str) and char.fg != "default":
+ if not char.fg.startswith("bright"):
+ bright_name = "bright" + char.fg
+ if bright_name in PYTE_COLORS:
+ return PYTE_COLORS[bright_name]
+
+ return color
+
+ def _get_bg_color(self, char) -> QColor:
+ """Get background color for a character."""
+ if char.reverse:
+ # Reverse video: swap fg/bg, use foreground as background
+ if char.fg == "default":
+ return QColor("#d4d4d4") # Light background for reverse
+ return self._color_to_qcolor(char.fg, default_fg=False)
+
+ if char.bg == "default":
+ return QColor("#1e1e1e")
+
+ return self._color_to_qcolor(char.bg, default_fg=False)
+
+ def keyPressEvent(self, event: QKeyEvent):
+ """Handle keyboard input and send to PTY."""
+ key = event.key()
+ modifiers = event.modifiers()
+ text = event.text()
+
+ # Handle special key combinations
+ if modifiers == Qt.KeyboardModifier.ControlModifier:
+ if key == Qt.Key.Key_C:
+ self.key_pressed.emit(b'\x03')
+ return
+ elif key == Qt.Key.Key_D:
+ self.key_pressed.emit(b'\x04')
+ return
+ elif key == Qt.Key.Key_Z:
+ self.key_pressed.emit(b'\x1a')
+ return
+ elif key == Qt.Key.Key_L:
+ self.key_pressed.emit(b'\x0c')
+ return
+ elif key == Qt.Key.Key_A:
+ self.key_pressed.emit(b'\x01')
+ return
+ elif key == Qt.Key.Key_E:
+ self.key_pressed.emit(b'\x05')
+ return
+ elif key == Qt.Key.Key_K:
+ self.key_pressed.emit(b'\x0b')
+ return
+ elif key == Qt.Key.Key_U:
+ self.key_pressed.emit(b'\x15')
+ return
+ elif key == Qt.Key.Key_W:
+ self.key_pressed.emit(b'\x17')
+ return
+
+ # Arrow keys and special keys
+ if key == Qt.Key.Key_Up:
+ self.key_pressed.emit(b'\x1b[A')
+ return
+ elif key == Qt.Key.Key_Down:
+ self.key_pressed.emit(b'\x1b[B')
+ return
+ elif key == Qt.Key.Key_Right:
+ self.key_pressed.emit(b'\x1b[C')
+ return
+ elif key == Qt.Key.Key_Left:
+ self.key_pressed.emit(b'\x1b[D')
+ return
+ elif key == Qt.Key.Key_Home:
+ self.key_pressed.emit(b'\x1b[H')
+ return
+ elif key == Qt.Key.Key_End:
+ self.key_pressed.emit(b'\x1b[F')
+ return
+ elif key == Qt.Key.Key_Delete:
+ self.key_pressed.emit(b'\x1b[3~')
+ return
+ elif key == Qt.Key.Key_Backspace:
+ self.key_pressed.emit(b'\x7f')
+ return
+ elif key == Qt.Key.Key_Tab:
+ self.key_pressed.emit(b'\t')
+ return
+ elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
+ self.key_pressed.emit(b'\r')
+ return
+ elif key == Qt.Key.Key_Escape:
+ self.key_pressed.emit(b'\x1b')
+ return
+ elif key == Qt.Key.Key_PageUp:
+ self.key_pressed.emit(b'\x1b[5~')
+ return
+ elif key == Qt.Key.Key_PageDown:
+ self.key_pressed.emit(b'\x1b[6~')
+ return
+ elif key == Qt.Key.Key_Insert:
+ self.key_pressed.emit(b'\x1b[2~')
+ return
+
+ # Function keys
+ if Qt.Key.Key_F1 <= key <= Qt.Key.Key_F12:
+ fn = key - Qt.Key.Key_F1 + 1
+ if fn <= 4:
+ self.key_pressed.emit(f'\x1bO{chr(ord("P") + fn - 1)}'.encode())
+ else:
+ codes = {5: 15, 6: 17, 7: 18, 8: 19, 9: 20, 10: 21, 11: 23, 12: 24}
+ self.key_pressed.emit(f'\x1b[{codes.get(fn, 15)}~'.encode())
+ return
+
+ # Regular text
+ if text:
+ self.key_pressed.emit(text.encode('utf-8'))
+
+ def _pos_to_cell(self, pos) -> tuple[int, int]:
+ """Convert pixel position to (row, col)."""
+ col = max(0, min(pos.x() // self.char_width, self.screen.columns - 1))
+ row = max(0, min(pos.y() // self.char_height, self.screen.lines - 1))
+ return (row, col)
+
+ def mousePressEvent(self, event):
+ """Handle mouse press for selection."""
+ if event.button() == Qt.MouseButton.LeftButton:
+ self._selection_start = self._pos_to_cell(event.pos())
+ self._selection_end = self._selection_start
+ self._selecting = True
+ self.update()
+
+ def mouseMoveEvent(self, event):
+ """Handle mouse move for selection."""
+ if self._selecting:
+ self._selection_end = self._pos_to_cell(event.pos())
+ self.update()
+
+ def mouseReleaseEvent(self, event):
+ """Handle mouse release."""
+ if event.button() == Qt.MouseButton.LeftButton:
+ self._selecting = False
+
+ def contextMenuEvent(self, event):
+ """Show context menu with copy/paste."""
+ from PyQt6.QtWidgets import QMenu, QApplication
+
+ menu = QMenu(self)
+
+ copy_action = menu.addAction("Copy")
+ copy_action.triggered.connect(self._copy_selection)
+ copy_action.setEnabled(self._has_selection())
+
+ paste_action = menu.addAction("Paste")
+ paste_action.triggered.connect(self._paste_clipboard)
+
+ menu.addSeparator()
+
+ clear_action = menu.addAction("Clear Selection")
+ clear_action.triggered.connect(self._clear_selection)
+
+ menu.exec(event.globalPos())
+
+ def _has_selection(self) -> bool:
+ """Check if there's an active selection."""
+ return (self._selection_start is not None and
+ self._selection_end is not None and
+ self._selection_start != self._selection_end)
+
+ def _get_selection_bounds(self) -> tuple[tuple[int, int], tuple[int, int]]:
+ """Get normalized selection bounds (start <= end)."""
+ if not self._has_selection():
+ return None, None
+
+ start = self._selection_start
+ end = self._selection_end
+
+ # Normalize so start comes before end
+ if (start[0] > end[0]) or (start[0] == end[0] and start[1] > end[1]):
+ start, end = end, start
+
+ return start, end
+
+ def _copy_selection(self):
+ """Copy selected text to clipboard."""
+ from PyQt6.QtWidgets import QApplication
+
+ start, end = self._get_selection_bounds()
+ if start is None:
+ return
+
+ lines = []
+ for row in range(start[0], end[0] + 1):
+ line_start = start[1] if row == start[0] else 0
+ line_end = end[1] + 1 if row == end[0] else self.screen.columns
+
+ line = ""
+ for col in range(line_start, line_end):
+ char = self.screen.buffer[row][col]
+ line += char.data if char.data else " "
+
+ lines.append(line.rstrip())
+
+ text = "\n".join(lines)
+ QApplication.clipboard().setText(text)
+
+ def _paste_clipboard(self):
+ """Paste clipboard content as keyboard input."""
+ from PyQt6.QtWidgets import QApplication
+
+ text = QApplication.clipboard().text()
+ if text:
+ self.key_pressed.emit(text.encode('utf-8'))
+
+ def _clear_selection(self):
+ """Clear the current selection."""
+ self._selection_start = None
+ self._selection_end = None
+ self.update()
+
+ def _draw_selection(self, painter):
+ """Draw selection highlight."""
+ start, end = self._get_selection_bounds()
+ if start is None:
+ return
+
+ selection_color = QColor(70, 130, 180, 100) # Steel blue, semi-transparent
+
+ for row in range(start[0], end[0] + 1):
+ line_start = start[1] if row == start[0] else 0
+ line_end = end[1] + 1 if row == end[0] else self.screen.columns
+
+ x = line_start * self.char_width
+ y = row * self.char_height
+ width = (line_end - line_start) * self.char_width
+
+ painter.fillRect(x, y, width, self.char_height, selection_color)
+
+ def dragEnterEvent(self, event: QDragEnterEvent):
+ """Accept drag events that contain file URLs."""
+ if event.mimeData().hasUrls():
+ event.acceptProposedAction()
+ else:
+ event.ignore()
+
+ def dragMoveEvent(self, event):
+ """Accept drag move events."""
+ if event.mimeData().hasUrls():
+ event.acceptProposedAction()
+
+ def dropEvent(self, event: QDropEvent):
+ """Handle file/folder drops."""
+ urls = event.mimeData().urls()
+ if not urls:
+ return
+
+ # Get the first dropped item's local path
+ path = urls[0].toLocalFile()
+ if not path:
+ return
+
+ # Determine what to do based on the path type
+ path_obj = Path(path)
+
+ if path_obj.is_dir():
+ # Directory ā cd to it
+ self._inject_text(f'cd "{path}"\n')
+ elif path_obj.is_file() and os.access(path, os.X_OK):
+ # Executable file ā run it
+ self._inject_text(f'"{path}"\n')
+ else:
+ # Regular file ā insert quoted path (no newline, user can add args)
+ self._inject_text(f'"{path}" ')
+
+ event.acceptProposedAction()
+
+ def _inject_text(self, text: str):
+ """Inject text as if user typed it."""
+ self.key_pressed.emit(text.encode('utf-8'))
+
+
+class TerminalWidget(QWidget):
+ """Full terminal widget with PTY support using pyte emulation."""
+
+ closed = pyqtSignal()
+
+ def __init__(self, cwd: Path | None = None, parent=None):
+ super().__init__(parent)
+ self.cwd = cwd or Path.home()
+ self.master_fd = None
+ self.slave_fd = None
+ self.process = None
+ self.reader_thread = None
+
+ self._setup_ui()
+ self._start_shell()
+
+ def _setup_ui(self):
+ """Set up the terminal UI."""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Start with reasonable default size
+ self.display = TerminalDisplay(rows=24, cols=80)
+ self.display.key_pressed.connect(self._on_key_pressed)
+ self.display.cursor_position_requested.connect(self._report_cursor_position)
+ self.display.setSizePolicy(
+ self.display.sizePolicy().horizontalPolicy(),
+ self.display.sizePolicy().verticalPolicy()
+ )
+ layout.addWidget(self.display)
+
+ def _start_shell(self):
+ """Start the shell process with PTY."""
+ # Create PTY pair
+ self.master_fd, self.slave_fd = pty.openpty()
+
+ # Get terminal size
+ rows = self.display.screen.lines
+ cols = self.display.screen.columns
+
+ # Set terminal size
+ winsize = struct.pack('HHHH', rows, cols, 0, 0)
+ fcntl.ioctl(self.slave_fd, termios.TIOCSWINSZ, winsize)
+
+ # Get shell
+ shell = os.environ.get('SHELL', '/bin/bash')
+
+ # Environment
+ env = os.environ.copy()
+ env['TERM'] = 'xterm-256color'
+ env['COLORTERM'] = 'truecolor'
+ env['COLUMNS'] = str(cols)
+ env['LINES'] = str(rows)
+
+ self.process = subprocess.Popen(
+ [shell, '-i'],
+ stdin=self.slave_fd,
+ stdout=self.slave_fd,
+ stderr=self.slave_fd,
+ cwd=str(self.cwd),
+ env=env,
+ start_new_session=True,
+ )
+
+ os.close(self.slave_fd)
+ self.slave_fd = None
+
+ # Make master non-blocking
+ flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
+ fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+ # Start reader thread
+ self.reader_thread = PtyReaderThread(self.master_fd)
+ self.reader_thread.output_ready.connect(self._on_output)
+ self.reader_thread.finished_signal.connect(self._on_shell_exit)
+ self.reader_thread.start()
+
+ def resizeEvent(self, event):
+ """Handle resize events to update PTY size."""
+ super().resizeEvent(event)
+ QTimer.singleShot(50, self._update_terminal_size)
+
+ def _update_terminal_size(self):
+ """Update terminal size based on widget size."""
+ if self.master_fd is None:
+ return
+
+ # Use the display's calculated char dimensions
+ char_width = self.display.char_width
+ char_height = self.display.char_height
+
+ if char_width <= 0 or char_height <= 0:
+ return
+
+ width = self.display.width()
+ height = self.display.height()
+
+ if width <= 0 or height <= 0:
+ return
+
+ cols = max(width // char_width, 20)
+ rows = max(height // char_height, 5)
+
+ # Clamp to reasonable values
+ cols = min(cols, 500)
+ rows = min(rows, 200)
+
+ # Update pyte screen if size changed
+ if rows != self.display.screen.lines or cols != self.display.screen.columns:
+ self.display.resize_screen(rows, cols)
+
+ # Update PTY size
+ try:
+ winsize = struct.pack('HHHH', rows, cols, width, height)
+ fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
+ except OSError:
+ pass
+
+ def _on_key_pressed(self, data: bytes):
+ """Handle key press from display."""
+ if self.master_fd is not None:
+ try:
+ os.write(self.master_fd, data)
+ except OSError:
+ pass
+
+ def _report_cursor_position(self):
+ """Respond to cursor position query (DSR)."""
+ if self.master_fd is None:
+ return
+
+ # Get cursor position from pyte screen
+ row = self.display.screen.cursor.y + 1 # 1-based
+ col = self.display.screen.cursor.x + 1 # 1-based
+
+ # Send CPR (Cursor Position Report): \x1b[row;colR
+ response = f'\x1b[{row};{col}R'.encode('utf-8')
+ try:
+ os.write(self.master_fd, response)
+ except OSError:
+ pass
+
+ def _on_output(self, data: bytes):
+ """Handle output from PTY."""
+ self.display.feed(data)
+
+ def _on_shell_exit(self):
+ """Handle shell process exit."""
+ self.display.feed(b'\r\n[Process exited]\r\n')
+ self.closed.emit()
+
+ def inject_text(self, text: str):
+ """Inject text as if user typed it."""
+ if self.master_fd is not None:
+ try:
+ os.write(self.master_fd, text.encode('utf-8'))
+ except OSError:
+ pass
+
+ def send_signal(self, sig: int):
+ """Send a signal to the shell process."""
+ if self.process:
+ try:
+ os.killpg(os.getpgid(self.process.pid), sig)
+ except (OSError, ProcessLookupError):
+ pass
+
+ def terminate(self):
+ """Terminate the shell and clean up."""
+ if self.reader_thread:
+ self.reader_thread.stop()
+ self.reader_thread.wait(1000)
+
+ if self.process:
+ try:
+ self.process.terminate()
+ self.process.wait(timeout=1)
+ except subprocess.TimeoutExpired:
+ self.process.kill()
+ except OSError:
+ pass
+
+ if self.master_fd is not None:
+ try:
+ os.close(self.master_fd)
+ except OSError:
+ pass
+ self.master_fd = None
+
+ def closeEvent(self, event):
+ """Handle widget close."""
+ self.terminate()
+ super().closeEvent(event)
+
+ def setFocus(self):
+ """Set focus to the display widget."""
+ self.display.setFocus()
diff --git a/src/development_hub/workspace.py b/src/development_hub/workspace.py
new file mode 100644
index 0000000..9597242
--- /dev/null
+++ b/src/development_hub/workspace.py
@@ -0,0 +1,626 @@
+"""Workspace widget for managing splittable pane layouts with tabs."""
+
+from pathlib import Path
+from weakref import ref
+
+from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer
+from PyQt6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QSplitter,
+ QTabWidget,
+ QLabel,
+ QFrame,
+ QApplication,
+)
+
+from development_hub.terminal_widget import TerminalWidget
+
+
+# Styles for focused/unfocused panes
+PANE_FOCUSED_STYLE = """
+ PaneWidget {
+ border: 2px solid #4a9eff;
+ border-radius: 3px;
+ }
+"""
+
+PANE_UNFOCUSED_STYLE = """
+ PaneWidget {
+ border: 2px solid transparent;
+ border-radius: 3px;
+ }
+"""
+
+
+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
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._is_focused = False
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Set up the pane UI."""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(2, 2, 2, 2)
+ layout.setSpacing(0)
+
+ self.tab_widget = QTabWidget()
+ self.tab_widget.setTabsClosable(True)
+ self.tab_widget.setMovable(True)
+ self.tab_widget.tabCloseRequested.connect(self._close_tab)
+ layout.addWidget(self.tab_widget)
+
+ # Set initial unfocused style
+ self.setStyleSheet(PANE_UNFOCUSED_STYLE)
+
+ def set_focused(self, focused: bool):
+ """Set the focus state and update visual styling."""
+ self._is_focused = focused
+ if focused:
+ self.setStyleSheet(PANE_FOCUSED_STYLE)
+ else:
+ self.setStyleSheet(PANE_UNFOCUSED_STYLE)
+
+ def is_focused(self) -> bool:
+ """Check if this pane is focused."""
+ return self._is_focused
+
+ def add_welcome_tab(self):
+ """Add a welcome tab with instructions."""
+ welcome = QWidget()
+ layout = QVBoxLayout(welcome)
+ layout.setContentsMargins(40, 40, 40, 40)
+
+ label = QLabel(
+ "Development Hub
"
+ "Central project orchestration for your development ecosystem.
"
+ "Getting Started
"
+ ""
+ "- Double-click a project to open a terminal
"
+ "- Right-click for more actions (Gitea, docs, deploy)
"
+ "- Ctrl+N to create a new project
"
+ "
"
+ "Keyboard Shortcuts
"
+ ""
+ "- Ctrl+Shift+T - New terminal tab
"
+ "- Ctrl+Shift+W - Close current tab
"
+ "- Ctrl+Shift+D - Split pane horizontal
"
+ "- Ctrl+Shift+E - Split pane vertical
"
+ "- Ctrl+Alt+Left/Right - Switch panes
"
+ "- Ctrl+B - Toggle project panel
"
+ "- F5 - Refresh project list
"
+ "
"
+ )
+ label.setWordWrap(True)
+ label.setAlignment(Qt.AlignmentFlag.AlignTop)
+ layout.addWidget(label)
+ layout.addStretch()
+
+ self.tab_widget.addTab(welcome, "Welcome")
+
+ def add_terminal(self, cwd: Path, title: str) -> TerminalWidget:
+ """Add a new terminal tab to this pane."""
+ terminal = TerminalWidget(cwd=cwd)
+
+ # Use weak reference to avoid preventing garbage collection
+ weak_self = ref(self)
+ def on_closed():
+ pane = weak_self()
+ if pane is not None:
+ pane._on_terminal_closed(terminal)
+ terminal.closed.connect(on_closed)
+
+ idx = self.tab_widget.addTab(terminal, title)
+ self.tab_widget.setCurrentIndex(idx)
+ terminal.setFocus()
+ return terminal
+
+ def _close_tab(self, index: int):
+ """Close tab at index."""
+ widget = self.tab_widget.widget(index)
+ self.tab_widget.removeTab(index)
+
+ if isinstance(widget, TerminalWidget):
+ widget.terminate()
+
+ widget.deleteLater()
+
+ # Check if pane is now empty
+ if self.tab_widget.count() == 0:
+ self.empty.emit(self)
+
+ def _on_terminal_closed(self, terminal: TerminalWidget):
+ """Handle terminal process exit."""
+ # Check if tab_widget still exists
+ if not hasattr(self, 'tab_widget') or self.tab_widget is None:
+ return
+ try:
+ for i in range(self.tab_widget.count()):
+ if self.tab_widget.widget(i) == terminal:
+ current_title = self.tab_widget.tabText(i)
+ if not current_title.endswith(" (exited)"):
+ self.tab_widget.setTabText(i, f"{current_title} (exited)")
+ break
+ except RuntimeError:
+ # Widget was deleted
+ pass
+
+ def close_current_tab(self):
+ """Close the currently selected tab."""
+ idx = self.tab_widget.currentIndex()
+ if idx >= 0:
+ self._close_tab(idx)
+
+ def take_current_tab(self) -> tuple[QWidget, str] | None:
+ """Remove and return the current tab widget and its title."""
+ idx = self.tab_widget.currentIndex()
+ if idx < 0:
+ return None
+
+ title = self.tab_widget.tabText(idx)
+ widget = self.tab_widget.widget(idx)
+ self.tab_widget.removeTab(idx)
+
+ # Check if pane is now empty
+ if self.tab_widget.count() == 0:
+ self.empty.emit(self)
+
+ return (widget, title)
+
+ def add_widget_tab(self, widget: QWidget, title: str):
+ """Add an existing widget as a tab."""
+ idx = self.tab_widget.addTab(widget, title)
+ self.tab_widget.setCurrentIndex(idx)
+
+ def has_tabs(self) -> bool:
+ """Check if pane has any tabs."""
+ return self.tab_widget.count() > 0
+
+ def tab_count(self) -> int:
+ """Get number of tabs in this pane."""
+ return self.tab_widget.count()
+
+ def get_current_terminal(self) -> TerminalWidget | None:
+ """Get the current terminal widget."""
+ widget = self.tab_widget.currentWidget()
+ if isinstance(widget, TerminalWidget):
+ return widget
+ return None
+
+ def terminate_all(self):
+ """Terminate all terminals in this pane."""
+ for i in range(self.tab_widget.count()):
+ widget = self.tab_widget.widget(i)
+ if isinstance(widget, TerminalWidget):
+ widget.terminate()
+
+ def setFocus(self):
+ """Set focus to the current terminal."""
+ terminal = self.get_current_terminal()
+ if terminal:
+ terminal.setFocus()
+
+ def get_session_state(self) -> dict:
+ """Get the session state for persistence."""
+ tabs = []
+ for i in range(self.tab_widget.count()):
+ widget = self.tab_widget.widget(i)
+ title = self.tab_widget.tabText(i)
+
+ if isinstance(widget, TerminalWidget):
+ tabs.append({
+ "type": "terminal",
+ "title": title,
+ "cwd": str(widget.cwd),
+ })
+ # Skip welcome tab and other non-terminal widgets
+
+ return {
+ "tabs": tabs,
+ "current_tab": self.tab_widget.currentIndex(),
+ }
+
+ def restore_tabs(self, state: dict):
+ """Restore tabs from session state."""
+ tabs = state.get("tabs", [])
+ current_tab = state.get("current_tab", 0)
+
+ for tab_info in tabs:
+ if tab_info.get("type") == "terminal":
+ cwd = Path(tab_info.get("cwd", str(Path.home())))
+ title = tab_info.get("title", "Terminal")
+ self.add_terminal(cwd, title)
+
+ # Restore current tab selection
+ if 0 <= current_tab < self.tab_widget.count():
+ self.tab_widget.setCurrentIndex(current_tab)
+
+
+class WorkspaceManager(QWidget):
+ """Manages splittable panes, each with their own tab bar."""
+
+ pane_count_changed = pyqtSignal(int)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._active_pane = None
+ self._setup_ui()
+
+ # Install application-wide event filter for focus tracking
+ QApplication.instance().installEventFilter(self)
+
+ def _setup_ui(self):
+ """Set up the workspace with initial pane."""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Create initial pane with welcome tab
+ self._root_widget = self._create_pane()
+ self._root_widget.add_welcome_tab()
+ layout.addWidget(self._root_widget)
+ self._active_pane = self._root_widget
+ self._active_pane.set_focused(True)
+
+ def _create_pane(self) -> PaneWidget:
+ """Create a new pane widget."""
+ pane = PaneWidget()
+ pane.empty.connect(self._on_pane_empty)
+ return pane
+
+ def eventFilter(self, obj, event):
+ """Application-wide event filter to track which pane is clicked."""
+ if event.type() == QEvent.Type.MouseButtonPress:
+ # Find which pane (if any) contains the clicked widget
+ widget = obj
+ while widget is not None:
+ if isinstance(widget, PaneWidget):
+ self._set_active_pane(widget)
+ break
+ widget = widget.parent()
+ return super().eventFilter(obj, event)
+
+ def _set_active_pane(self, pane: PaneWidget):
+ """Set the active pane and update styling."""
+ if pane == self._active_pane:
+ return
+
+ if self._active_pane is not None:
+ self._active_pane.set_focused(False)
+
+ self._active_pane = pane
+ pane.set_focused(True)
+
+ def _on_pane_empty(self, pane: PaneWidget):
+ """Handle pane becoming empty."""
+ panes = self.find_panes()
+ if len(panes) <= 1:
+ # Last pane - don't remove, just leave empty
+ return
+
+ self._remove_pane(pane)
+
+ def _remove_pane(self, pane: PaneWidget):
+ """Remove a pane from the workspace."""
+ parent = pane.parent()
+
+ pane.terminate_all()
+
+ if parent == self:
+ # This is the root - shouldn't remove
+ return
+
+ if isinstance(parent, QSplitter):
+ # Remove from splitter
+ pane.setParent(None)
+ pane.deleteLater()
+
+ # If splitter has only one child left, replace splitter with that child
+ if parent.count() == 1:
+ remaining = parent.widget(0)
+ grandparent = parent.parent()
+
+ if grandparent == self:
+ # Parent splitter is root
+ layout = self.layout()
+ layout.removeWidget(parent)
+ remaining.setParent(None)
+ layout.addWidget(remaining)
+ self._root_widget = remaining
+ parent.deleteLater()
+
+ elif isinstance(grandparent, QSplitter):
+ # Nested splitter
+ index = grandparent.indexOf(parent)
+ remaining.setParent(None)
+ grandparent.replaceWidget(index, remaining)
+ parent.deleteLater()
+
+ # Update active pane with proper focus styling
+ panes = self.find_panes()
+ if panes:
+ self._active_pane = panes[0]
+ self._active_pane.set_focused(True)
+ self._active_pane.setFocus()
+
+ self.pane_count_changed.emit(len(self.find_panes()))
+
+ def get_active_pane(self) -> PaneWidget | None:
+ """Get the currently active pane."""
+ if self._active_pane and self._active_pane.isVisible():
+ return self._active_pane
+
+ # Fallback to first pane
+ panes = self.find_panes()
+ if panes:
+ self._active_pane = panes[0]
+ return self._active_pane
+ return None
+
+ def find_panes(self) -> list[PaneWidget]:
+ """Find all pane widgets in the workspace."""
+ panes = []
+ self._collect_panes(self._root_widget, panes)
+ return panes
+
+ def _collect_panes(self, widget, panes: list):
+ """Recursively collect panes from widget tree."""
+ if isinstance(widget, PaneWidget):
+ panes.append(widget)
+ elif isinstance(widget, QSplitter):
+ for i in range(widget.count()):
+ self._collect_panes(widget.widget(i), panes)
+
+ def add_terminal(self, cwd: Path, title: str) -> TerminalWidget:
+ """Add a terminal to the active pane."""
+ pane = self.get_active_pane()
+ if pane:
+ return pane.add_terminal(cwd, title)
+ return None
+
+ def split_horizontal(self):
+ """Split the active pane horizontally (left/right)."""
+ self._split(Qt.Orientation.Horizontal)
+
+ def split_vertical(self):
+ """Split the active pane vertically (top/bottom)."""
+ self._split(Qt.Orientation.Vertical)
+
+ def _split(self, orientation: Qt.Orientation):
+ """Split the active pane with given orientation."""
+ current_pane = self.get_active_pane()
+ if not current_pane:
+ return
+
+ # Get current size before splitting
+ if orientation == Qt.Orientation.Horizontal:
+ total_size = current_pane.width()
+ else:
+ total_size = current_pane.height()
+ half_size = max(total_size // 2, 100)
+
+ # Create new pane
+ new_pane = self._create_pane()
+
+ # If current pane has 2+ tabs, move the focused tab to new pane
+ tab_to_move = None
+ if current_pane.tab_count() >= 2:
+ tab_to_move = current_pane.take_current_tab()
+
+ # Find parent of current pane
+ parent = current_pane.parent()
+ splitter_to_equalize = None
+
+ if parent == self:
+ # Current is the root widget - create splitter as new root
+ layout = self.layout()
+ layout.removeWidget(current_pane)
+
+ splitter = QSplitter(orientation)
+ splitter.addWidget(current_pane)
+ splitter.addWidget(new_pane)
+ splitter_to_equalize = splitter
+
+ layout.addWidget(splitter)
+ self._root_widget = splitter
+
+ elif isinstance(parent, QSplitter):
+ # Current is in a splitter
+ index = parent.indexOf(current_pane)
+
+ if parent.orientation() == orientation:
+ # Same orientation - just add to existing splitter
+ parent.insertWidget(index + 1, new_pane)
+ splitter_to_equalize = parent
+ else:
+ # Different orientation - need nested splitter
+ new_splitter = QSplitter(orientation)
+ parent.replaceWidget(index, new_splitter)
+ new_splitter.addWidget(current_pane)
+ new_splitter.addWidget(new_pane)
+ splitter_to_equalize = new_splitter
+
+ # If we moved a tab, add it to the new pane
+ if tab_to_move:
+ widget, title = tab_to_move
+ new_pane.add_widget_tab(widget, title)
+
+ # Update focus to new pane
+ self._set_active_pane(new_pane)
+ new_pane.setFocus()
+
+ # Equalize sizes after layout is processed
+ if splitter_to_equalize:
+ QTimer.singleShot(0, lambda s=splitter_to_equalize: self._equalize_splitter(s))
+
+ self.pane_count_changed.emit(len(self.find_panes()))
+
+ def _equalize_splitter(self, splitter: QSplitter):
+ """Set equal sizes for all children in a splitter."""
+ count = splitter.count()
+ if count == 0:
+ return
+
+ if splitter.orientation() == Qt.Orientation.Horizontal:
+ total = splitter.width()
+ else:
+ total = splitter.height()
+
+ size_each = total // count
+ splitter.setSizes([size_each] * count)
+
+ def close_current_tab(self):
+ """Close the current tab in the active pane."""
+ pane = self.get_active_pane()
+ if pane:
+ pane.close_current_tab()
+
+ def close_active_pane(self):
+ """Close the currently active pane."""
+ pane = self.get_active_pane()
+ if pane:
+ panes = self.find_panes()
+ if len(panes) > 1:
+ self._remove_pane(pane)
+
+ def total_tab_count(self) -> int:
+ """Get total number of tabs across all panes."""
+ return sum(pane.tab_count() for pane in self.find_panes())
+
+ def terminate_all(self):
+ """Terminate all terminals in all panes."""
+ for pane in self.find_panes():
+ pane.terminate_all()
+
+ def focus_next_pane(self):
+ """Focus the next pane."""
+ panes = self.find_panes()
+ if len(panes) <= 1:
+ return
+
+ current = self.get_active_pane()
+ if current in panes:
+ idx = panes.index(current)
+ next_idx = (idx + 1) % len(panes)
+ next_pane = panes[next_idx]
+ self._set_active_pane(next_pane)
+ next_pane.setFocus()
+
+ def focus_previous_pane(self):
+ """Focus the previous pane."""
+ panes = self.find_panes()
+ if len(panes) <= 1:
+ return
+
+ current = self.get_active_pane()
+ if current in panes:
+ idx = panes.index(current)
+ prev_idx = (idx - 1) % len(panes)
+ prev_pane = panes[prev_idx]
+ self._set_active_pane(prev_pane)
+ prev_pane.setFocus()
+
+ def get_session_state(self) -> dict:
+ """Get the complete session state for persistence."""
+ return {
+ "layout": self._serialize_widget(self._root_widget),
+ }
+
+ def _serialize_widget(self, widget) -> dict:
+ """Recursively serialize a widget tree."""
+ if isinstance(widget, PaneWidget):
+ return {
+ "type": "pane",
+ "state": widget.get_session_state(),
+ }
+ elif isinstance(widget, QSplitter):
+ children = []
+ for i in range(widget.count()):
+ children.append(self._serialize_widget(widget.widget(i)))
+
+ return {
+ "type": "splitter",
+ "orientation": "horizontal" if widget.orientation() == Qt.Orientation.Horizontal else "vertical",
+ "sizes": widget.sizes(),
+ "children": children,
+ }
+ return {}
+
+ def restore_session(self, state: dict):
+ """Restore the workspace from session state."""
+ layout = state.get("layout")
+ if not layout:
+ return
+
+ # Check if there are any tabs to restore
+ if not self._has_tabs_in_layout(layout):
+ return
+
+ # Clear existing layout
+ old_root = self._root_widget
+ self.layout().removeWidget(old_root)
+
+ # Build new layout
+ new_root = self._deserialize_widget(layout)
+ if new_root:
+ self.layout().addWidget(new_root)
+ self._root_widget = new_root
+
+ # Clean up old root
+ if isinstance(old_root, PaneWidget):
+ old_root.terminate_all()
+ old_root.deleteLater()
+
+ # Set first pane as active
+ panes = self.find_panes()
+ if panes:
+ self._active_pane = panes[0]
+ self._active_pane.set_focused(True)
+
+ def _has_tabs_in_layout(self, layout: dict) -> bool:
+ """Check if a layout has any tabs to restore."""
+ if layout.get("type") == "pane":
+ tabs = layout.get("state", {}).get("tabs", [])
+ return len(tabs) > 0
+ elif layout.get("type") == "splitter":
+ for child in layout.get("children", []):
+ if self._has_tabs_in_layout(child):
+ return True
+ return False
+
+ def _deserialize_widget(self, data: dict) -> QWidget | None:
+ """Recursively deserialize a widget tree."""
+ widget_type = data.get("type")
+
+ if widget_type == "pane":
+ pane = self._create_pane()
+ pane.restore_tabs(data.get("state", {}))
+ return pane
+
+ elif widget_type == "splitter":
+ orientation = (
+ Qt.Orientation.Horizontal
+ if data.get("orientation") == "horizontal"
+ else Qt.Orientation.Vertical
+ )
+ splitter = QSplitter(orientation)
+
+ for child_data in data.get("children", []):
+ child = self._deserialize_widget(child_data)
+ if child:
+ splitter.addWidget(child)
+
+ # Restore sizes after adding all children
+ sizes = data.get("sizes", [])
+ if sizes and len(sizes) == splitter.count():
+ QTimer.singleShot(0, lambda s=splitter, sz=sizes: s.setSizes(sz))
+
+ return splitter
+
+ return None