diff --git a/pyproject.toml b/pyproject.toml index 4f2ae65..d30804a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "structlog>=24.4.0", "pyyaml>=6.0.0", "jinja2>=3.1.4", + "textual>=0.47.0", ] [project.optional-dependencies] @@ -119,6 +120,7 @@ omit = [ "*/__pycache__/*", "*/main.py", # CLI entry point "*/cli/*", # CLI layer (integration tested) + "*/ui/*", # TUI layer (integration tested) "*/infrastructure/logging.py", # Logging config ] diff --git a/src/agentspaces/cli/app.py b/src/agentspaces/cli/app.py index 87d1bf3..e213685 100644 --- a/src/agentspaces/cli/app.py +++ b/src/agentspaces/cli/app.py @@ -5,7 +5,7 @@ import typer from agentspaces import __version__ -from agentspaces.cli import docs, project, workspace +from agentspaces.cli import docs, project, tui, workspace from agentspaces.infrastructure.logging import configure_logging # Main application @@ -19,6 +19,7 @@ # Register subcommand groups app.add_typer(docs.app, name="docs") app.add_typer(project.app, name="project") +app.add_typer(tui.app, name="tui") app.add_typer(workspace.app, name="workspace") diff --git a/src/agentspaces/cli/tui.py b/src/agentspaces/cli/tui.py new file mode 100644 index 0000000..983ec51 --- /dev/null +++ b/src/agentspaces/cli/tui.py @@ -0,0 +1,42 @@ +"""TUI command for interactive workspace management.""" + +from __future__ import annotations + +import typer + +from agentspaces.ui.app import WorkspacesTUI + +__all__ = ["app"] + +app = typer.Typer( + name="tui", + help="Interactive TUI for workspace management.", + no_args_is_help=False, +) + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context) -> None: + """Launch interactive TUI for browsing and managing workspaces. + + Features: + - Browse workspaces with arrow keys + - Navigate to workspace (CD + activate venv + start claude) + - Remove single or multiple workspaces + - Preview workspace details before actions + + Keybindings: + ↑/↓ : Navigate list + Space : Toggle selection (for bulk removal) + Enter : Navigate to workspace + d : Remove selected workspace(s) + r : Refresh workspace list + q : Quit + + Examples: + agentspaces tui # Launch TUI + """ + # If no subcommand provided, launch TUI + if ctx.invoked_subcommand is None: + tui = WorkspacesTUI() + tui.run() diff --git a/src/agentspaces/ui/__init__.py b/src/agentspaces/ui/__init__.py new file mode 100644 index 0000000..c7ced95 --- /dev/null +++ b/src/agentspaces/ui/__init__.py @@ -0,0 +1,22 @@ +"""UI module for agentspaces TUI.""" + +from agentspaces.ui.app import WorkspacesTUI +from agentspaces.ui.terminal import detect_terminal, navigate_to_workspace +from agentspaces.ui.widgets import ( + ConfirmRemoveModal, + PreviewPanel, + WorkspaceFooter, + WorkspaceHeader, + WorkspaceTable, +) + +__all__ = [ + "ConfirmRemoveModal", + "PreviewPanel", + "WorkspaceFooter", + "WorkspaceHeader", + "WorkspaceTable", + "WorkspacesTUI", + "detect_terminal", + "navigate_to_workspace", +] diff --git a/src/agentspaces/ui/app.py b/src/agentspaces/ui/app.py new file mode 100644 index 0000000..b255919 --- /dev/null +++ b/src/agentspaces/ui/app.py @@ -0,0 +1,281 @@ +"""Textual application for workspace management.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +import structlog +from textual.app import App +from textual.binding import Binding +from textual.widgets import DataTable, Footer + +if TYPE_CHECKING: + from textual.app import ComposeResult + +from agentspaces.modules.workspace.service import ( + WorkspaceError, + WorkspaceInfo, + WorkspaceNotFoundError, + WorkspaceService, +) +from agentspaces.ui.terminal import navigate_to_workspace +from agentspaces.ui.widgets import ( + ConfirmRemoveModal, + PreviewPanel, + WorkspaceHeader, + WorkspaceTable, +) + +__all__ = ["WorkspacesTUI"] + +logger = structlog.get_logger() + + +class WorkspacesTUI(App[None]): + """Interactive TUI for workspace management. + + Features: + - Browse workspaces with arrow keys + - Navigate to workspace (CD + activate venv + start claude) + - Remove single or multiple workspaces + - Preview workspace details before actions + """ + + CSS = """ + Screen { + layout: grid; + grid-size: 2 2; + grid-rows: auto 1fr; + grid-columns: 2fr 1fr; + } + + Header { + column-span: 2; + } + + WorkspaceTable { + height: 100%; + border: solid $primary; + } + + PreviewPanel { + height: 100%; + border: solid $accent; + padding: 1 2; + } + + Footer { + column-span: 2; + } + """ + + BINDINGS: ClassVar[list[Binding]] = [ # type: ignore[assignment] + Binding("q", "quit", "Quit"), + Binding("r", "refresh", "Refresh"), + Binding("enter", "navigate", "Navigate"), + Binding("d", "remove", "Remove"), + Binding("space", "toggle_select", "Select"), + ] + + TITLE = "agentspaces" + + def __init__(self, service: WorkspaceService | None = None) -> None: + """Initialize TUI with service dependencies. + + Args: + service: Workspace service instance (optional, for testing). + """ + super().__init__() + + # Dependency injection + self.service = service or WorkspaceService() + self.workspaces: list[WorkspaceInfo] = [] + self.main_checkout: WorkspaceInfo | None = None + self.current_path = str(Path.cwd()) + self.selected_rows: set[int] = set() + + def compose(self) -> ComposeResult: + """Compose the UI layout.""" + yield WorkspaceHeader() + yield WorkspaceTable() + yield PreviewPanel() + yield Footer() + + def on_mount(self) -> None: + """Load initial data when app starts.""" + self.action_refresh() + + def action_refresh(self) -> None: + """Refresh workspace list from service.""" + # Clear selections since indices will be invalid after reload + self.selected_rows.clear() + + try: + all_workspaces = self.service.list() + + # Separate main checkout (first worktree with is_main flag) + # Main is determined by matching workspace name to project name + project = self.service.get_project_name() + main = next( + (w for w in all_workspaces if w.name == project), + None, + ) + + # Filter out main from actionable list + self.workspaces = [w for w in all_workspaces if w != main] + self.main_checkout = main + + # Update UI + table = self.query_one(WorkspaceTable) + table.load_workspaces(self.workspaces, self.current_path) + + header = self.query_one(WorkspaceHeader) + header.set_main_checkout(main) + + # Update preview with first workspace + if self.workspaces: + preview = self.query_one(PreviewPanel) + preview.update_preview(self.workspaces[0]) + + logger.info( + "workspaces_loaded", + count=len(self.workspaces), + has_main=main is not None, + ) + + except WorkspaceError as e: + self.notify(f"Error loading workspaces: {e}", severity="error") + logger.error("workspace_load_failed", error=str(e)) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """Update preview when cursor moves. + + Args: + event: Row highlighted event from DataTable. + """ + if 0 <= event.cursor_row < len(self.workspaces): + workspace = self.workspaces[event.cursor_row] + preview = self.query_one(PreviewPanel) + preview.update_preview(workspace) + + def action_toggle_select(self) -> None: + """Toggle selection of current row.""" + table = self.query_one(WorkspaceTable) + cursor_row = table.cursor_row + + # Bounds check before operating on selection + if cursor_row < 0 or cursor_row >= len(self.workspaces): + return + + if cursor_row in self.selected_rows: + self.selected_rows.remove(cursor_row) + else: + self.selected_rows.add(cursor_row) + + # Visual feedback + self.notify(f"Selected: {len(self.selected_rows)} workspace(s)") + + async def action_navigate(self) -> None: + """Navigate to selected workspace.""" + table = self.query_one(WorkspaceTable) + cursor_row = table.cursor_row + + if cursor_row < 0 or cursor_row >= len(self.workspaces): + return + + workspace = self.workspaces[cursor_row] + + # Navigate (Ghostty tab or print instructions) + navigate_to_workspace(workspace) + + # Notify user + self.notify( + f"Navigating to {workspace.name}...", + severity="information", + ) + + async def action_remove(self) -> None: + """Remove selected workspace(s) after confirmation.""" + # Determine which workspaces to remove + workspaces_to_remove = [] + + if self.selected_rows: + # Remove all selected + workspaces_to_remove = [ + self.workspaces[i] + for i in self.selected_rows + if i < len(self.workspaces) + ] + else: + # Remove current cursor row + table = self.query_one(WorkspaceTable) + cursor_row = table.cursor_row + if cursor_row < len(self.workspaces): + workspaces_to_remove = [self.workspaces[cursor_row]] + + if not workspaces_to_remove: + self.notify("No workspace selected", severity="warning") + return + + # Check for protected workspaces + protected = [] + current_cwd = str(Path.cwd()) # Get fresh current directory + + for workspace in workspaces_to_remove: + # Block removal of current workspace + if str(workspace.path) == current_cwd: + protected.append(f"{workspace.name} (current workspace)") + + if protected: + message = "Cannot remove:\n" + "\n".join( + f" • {name}" for name in protected + ) + self.notify(message, severity="error") + return + + # Show confirmation modal + workspace_names = [w.name for w in workspaces_to_remove] + confirmed = await self.push_screen_wait(ConfirmRemoveModal(workspace_names)) + + if not confirmed: + self.notify("Removal cancelled", severity="information") + return + + # Execute removal + removed_count = 0 + failed = [] + + for workspace in workspaces_to_remove: + try: + self.service.remove(workspace.name, force=False) + removed_count += 1 + logger.info("workspace_removed", name=workspace.name) + except WorkspaceNotFoundError: + failed.append(f"{workspace.name} (not found)") + except WorkspaceError as e: + failed.append(f"{workspace.name} ({e})") + logger.error( + "workspace_removal_failed", + workspace=workspace.name, + error=str(e), + ) + + # Clear selection + self.selected_rows.clear() + + # Refresh list + self.action_refresh() + + # Notify user + if removed_count > 0: + self.notify( + f"Removed {removed_count} workspace(s)", + severity="information", + ) + + if failed: + message = "Failed to remove:\n" + "\n".join( + f" • {name}" for name in failed + ) + self.notify(message, severity="error") diff --git a/src/agentspaces/ui/terminal.py b/src/agentspaces/ui/terminal.py new file mode 100644 index 0000000..434e06a --- /dev/null +++ b/src/agentspaces/ui/terminal.py @@ -0,0 +1,127 @@ +"""Terminal detection and navigation helpers for TUI.""" + +from __future__ import annotations + +import os +import shlex +import subprocess # nosec B404 - subprocess needed for Ghostty integration +from pathlib import Path +from typing import TYPE_CHECKING + +import structlog +from rich.console import Console + +if TYPE_CHECKING: + from agentspaces.modules.workspace.service import WorkspaceInfo + +__all__ = [ + "detect_terminal", + "navigate_to_workspace", +] + +logger = structlog.get_logger() + + +def detect_terminal() -> tuple[bool, str]: + """Detect if running in Ghostty terminal. + + Returns: + Tuple of (is_ghostty, shell_type). + is_ghostty: True if running in Ghostty terminal + shell_type: Name of current shell (e.g., 'bash', 'zsh', 'fish') + """ + # Check TERM_PROGRAM environment variable (set by Ghostty) + term_program = os.environ.get("TERM_PROGRAM", "") + is_ghostty = term_program == "ghostty" + + # Detect shell type + shell_path = os.environ.get("SHELL", "") + shell_type = Path(shell_path).name if shell_path else "bash" + + logger.debug( + "terminal_detected", + is_ghostty=is_ghostty, + shell_type=shell_type, + term_program=term_program, + ) + + return is_ghostty, shell_type + + +def navigate_to_workspace(workspace: WorkspaceInfo) -> None: + """Navigate to workspace - create Ghostty tab or print instructions. + + For Ghostty: Creates a new tab with CD, venv activation, and claude command. + For other terminals: Prints commands for manual execution. + + Args: + workspace: Workspace to navigate to. + """ + is_ghostty, _shell_type = detect_terminal() + + # Build command sequence + commands = [f"cd {shlex.quote(str(workspace.path))}"] + + if workspace.has_venv: + venv_activate = workspace.path / ".venv" / "bin" / "activate" + if venv_activate.exists(): + commands.append(f"source {shlex.quote(str(venv_activate))}") + + commands.append("claude") + + # Join with && for sequential execution + full_command = " && ".join(commands) + + if is_ghostty: + _navigate_ghostty(full_command, workspace.name) + else: + _navigate_fallback(commands, workspace.name) + + +def _navigate_ghostty(command: str, workspace_name: str) -> None: + """Create new Ghostty tab and execute navigation command. + + Args: + command: Full command to execute in new tab. + workspace_name: Name of workspace (for logging). + """ + try: + # Create new Ghostty tab with command + subprocess.Popen( # nosec B603,B607 + ["ghostty", "+new-tab", command], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + logger.info("ghostty_tab_created", workspace=workspace_name) + except (FileNotFoundError, OSError) as e: + logger.warning( + "ghostty_tab_failed", + workspace=workspace_name, + error=str(e), + ) + # Fallback to print mode + _navigate_fallback(command.split(" && "), workspace_name) + + +def _navigate_fallback(commands: list[str], workspace_name: str) -> None: + """Print navigation commands for manual execution. + + Args: + commands: List of commands to execute. + workspace_name: Name of workspace (for logging). + """ + console = Console() + + console.print() + console.print(f"[bold cyan]Navigate to workspace:[/bold cyan] {workspace_name}") + console.print() + console.print("[dim]Run these commands:[/dim]") + + for cmd in commands: + console.print(f" [yellow]{cmd}[/yellow]") + + console.print() + console.print("[dim]TIP: Copy and paste the commands above[/dim]") + + logger.info("navigation_commands_printed", workspace=workspace_name) diff --git a/src/agentspaces/ui/widgets.py b/src/agentspaces/ui/widgets.py new file mode 100644 index 0000000..39218fa --- /dev/null +++ b/src/agentspaces/ui/widgets.py @@ -0,0 +1,230 @@ +"""Textual widgets for workspace TUI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Static, +) + +if TYPE_CHECKING: + from textual.app import ComposeResult + + from agentspaces.modules.workspace.service import WorkspaceInfo + +__all__ = [ + "ConfirmRemoveModal", + "PreviewPanel", + "WorkspaceFooter", + "WorkspaceHeader", + "WorkspaceTable", +] + + +class WorkspaceTable(DataTable[str]): + """Interactive table showing workspaces with metadata. + + Displays: name, branch, purpose (truncated), venv indicator. + Supports keyboard navigation and multi-select. + """ + + def __init__(self) -> None: + """Initialize workspace table with columns.""" + super().__init__(zebra_stripes=True, cursor_type="row") + + # Configure columns + self.add_column("Name", key="name", width=25) + self.add_column("Branch", key="branch", width=25) + self.add_column("Purpose", key="purpose", width=40) + self.add_column("Venv", key="venv", width=6) + + def load_workspaces( + self, + workspaces: list[WorkspaceInfo], + current_path: str | None = None, + ) -> None: + """Load workspaces into table. + + Args: + workspaces: List of workspace info objects. + current_path: Path of current workspace (will be highlighted). + """ + self.clear() + + for workspace in workspaces: + # Visual indicators + name_display = workspace.name + if current_path and str(workspace.path) == current_path: + name_display = f"→ {name_display}" + + venv_display = "✓" if workspace.has_venv else "" + purpose_display = self._truncate(workspace.purpose or "", 38) + + self.add_row( + name_display, + workspace.branch, + purpose_display, + venv_display, + key=workspace.name, + ) + + @staticmethod + def _truncate(text: str, max_length: int) -> str: + """Truncate text with ellipsis if too long.""" + if len(text) <= max_length: + return text + return text[: max_length - 1] + "…" + + +class PreviewPanel(Static): + """Preview panel showing workspace details.""" + + def __init__(self) -> None: + """Initialize preview panel.""" + super().__init__() + self.border_title = "Workspace Details" + self.update_preview(None) + + def update_preview(self, workspace: WorkspaceInfo | None) -> None: + """Update preview with workspace details. + + Args: + workspace: Workspace to preview, or None to show empty state. + """ + if workspace is None: + self.update("[dim]No workspace selected[/dim]") + return + + # Format creation time + created = "Unknown" + if workspace.created_at: + created = workspace.created_at.strftime("%Y-%m-%d %H:%M") + + # Build preview content + lines = [ + f"[bold cyan]{workspace.name}[/bold cyan]", + "", + f"[bold]Path:[/bold] {workspace.path}", + f"[bold]Branch:[/bold] {workspace.branch}", + f"[bold]Base:[/bold] {workspace.base_branch}", + f"[bold]Created:[/bold] {created}", + "", + f"[bold]Python:[/bold] {workspace.python_version or 'N/A'}", + f"[bold]Venv:[/bold] {'Yes ✓' if workspace.has_venv else 'No'}", + ] + + if workspace.purpose: + lines.extend(["", "[bold]Purpose:[/bold]", f"{workspace.purpose}"]) + + self.update("\n".join(lines)) + + +class WorkspaceHeader(Header): + """Header showing main repository checkout info.""" + + def __init__(self, main_checkout: WorkspaceInfo | None = None) -> None: + """Initialize header. + + Args: + main_checkout: Main repository checkout info (protected from removal). + """ + super().__init__() + self._main_checkout = main_checkout + self._update_title() + + def set_main_checkout(self, main_checkout: WorkspaceInfo | None) -> None: + """Update main checkout display. + + Args: + main_checkout: Main repository checkout info. + """ + self._main_checkout = main_checkout + self._update_title() + + def _update_title(self) -> None: + """Update header title with main checkout info.""" + if self._main_checkout: + project = self._main_checkout.project + branch = self._main_checkout.branch + self.tall_title = True + self._text = f"agentspaces • Main: {project} ({branch})" + else: + self._text = "agentspaces" + + +class WorkspaceFooter(Footer): + """Footer showing keybindings.""" + + pass # Uses Textual's built-in Footer with app BINDINGS + + +class ConfirmRemoveModal(ModalScreen[bool]): + """Modal dialog for confirming workspace removal. + + Returns True if user confirms, False if cancelled. + """ + + DEFAULT_CSS = """ + ConfirmRemoveModal { + align: center middle; + } + + ConfirmRemoveModal > Container { + width: 60; + height: auto; + border: thick $error; + background: $surface; + padding: 1 2; + } + + ConfirmRemoveModal #buttons { + height: auto; + align: center middle; + padding-top: 1; + } + + ConfirmRemoveModal Button { + margin: 0 1; + } + """ + + def __init__(self, workspace_names: list[str]) -> None: + """Initialize modal with workspace names to confirm. + + Args: + workspace_names: List of workspace names to be removed. + """ + super().__init__() + self.workspace_names = workspace_names + + def compose(self) -> ComposeResult: + """Compose modal layout.""" + with Container(): + yield Static( + "[bold red]⚠ Remove Workspaces?[/bold red]\n\n" + "This will delete the following workspaces:\n" + + "\n".join(f" • {name}" for name in self.workspace_names) + + "\n\n[yellow]This action cannot be undone![/yellow]" + ) + + with Horizontal(id="buttons"): + yield Button("Cancel", variant="default", id="cancel") + yield Button("Remove", variant="error", id="confirm") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button clicks. + + Args: + event: Button press event. + """ + if event.button.id == "confirm": + self.dismiss(True) + else: + self.dismiss(False)