"""Main Textual Application for Clide.""" from pathlib import Path from typing import ClassVar from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.widgets import Footer, Header from textual.worker import Worker, WorkerState from clide.controllers.diff import DiffController from clide.controllers.editor import EditorController from clide.controllers.git import GitController from clide.controllers.jira import JiraController from clide.controllers.problems import ProblemsController from clide.controllers.todos import TodosController from clide.extensions.manager import ExtensionManager from clide.models.config import ClideSettings from clide.services.claude_events import ( ClaudeEvent, FileEditEvent, FileReadEvent, FileWriteEvent, setup_event_parsing, ) from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching from clide.services.settings_service import get_settings_service from clide.services.syntax_service import register_languages from clide.themes.registry import get_all_themes, get_theme from clide.widgets.panels.claude import ClaudePanel from clide.widgets.panels.context import ContextPanel from clide.widgets.panels.sidebar import SidebarPanel from clide.widgets.panels.workspace import WorkspacePanel class ClideApp(App[None]): """Clide TUI Application - Claude Code IDE. Panel architecture: - Sidebar (left): Files, Git, Tree tabs + branch status - Center: Workspace (Editor/Diff/Terminal) + Claude panel - Context (right): Problems, TODOs, Jira tabs Workspace is hidden by default. Claude takes full height when workspace is hidden, 40% when visible. """ TITLE = "Clide" SUB_TITLE = "Claude Code IDE" CSS: ClassVar[str] = """ /* Main layout */ Screen { layout: horizontal; } #main-container { width: 100%; height: 100%; layout: horizontal; } #center-column { width: 1fr; height: 100%; layout: vertical; } /* Panel styling */ SidebarPanel { width: 20%; min-width: 25; max-width: 50; } ContextPanel { width: 25%; min-width: 30; max-width: 50; } /* Workspace + Claude layout */ WorkspacePanel { height: 60%; } WorkspacePanel.hidden { display: none; } ClaudePanel { height: 100%; } ClaudePanel.with-workspace { height: 40%; } /* Compact mode - applied when .compact class is on #main-container */ #main-container.compact SidebarPanel { display: none; } #main-container.compact ContextPanel { display: none; } /* Fullscreen mode */ .fullscreen { dock: top; width: 100%; height: 100%; layer: fullscreen; } /* Global button styling - outlined look */ Button { background: transparent; border: solid $secondary; color: $foreground; margin: 0 1; } Button:hover { background: $secondary 20%; border: solid $secondary; } Button:focus { border: solid $primary; } Button.-primary { border: solid $primary; color: $primary; } Button.-primary:hover { background: $primary 20%; } """ # Keybindings # OS-native shortcuts (Ctrl on Windows/Linux, Cmd on Mac mapped to ctrl in terminal) # Alt-based shortcuts for actions that shouldn't interfere with input fields # Note: priority=True ensures bindings work even when widgets have focus BINDINGS: ClassVar[list[Binding]] = [ # Global Binding("alt+q", "quit", "Quit", priority=True), Binding("alt+p", "command_palette", "Commands"), Binding("alt+o", "quick_open", "Quick Open"), Binding("alt+b", "toggle_sidebar", "Sidebar", priority=True), Binding("alt+shift+b", "toggle_context", "Context", priority=True), Binding("alt+`", "toggle_terminal", "Terminal", priority=True), Binding("alt+c", "toggle_compact", "Compact", priority=True), Binding("f11", "toggle_fullscreen", "Fullscreen"), Binding("escape", "escape", "Escape", show=False), # Navigation Binding("alt+1", "focus_claude", "Claude", show=False, priority=True), Binding("alt+2", "focus_editor", "Editor", show=False, priority=True), Binding("alt+3", "focus_terminal", "Terminal", show=False, priority=True), Binding("alt+0", "focus_sidebar", "Sidebar", show=False, priority=True), Binding("alt+w", "close_tab", "Close Tab", show=False), Binding("ctrl+w", "close_tab", "Close Tab", show=False), # Git Binding("alt+g", "open_git", "Git", show=False, priority=True), # Problems Binding("alt+m", "open_problems", "Problems", show=False, priority=True), Binding("f8", "next_problem", "Next Problem", show=False), Binding("shift+f8", "prev_problem", "Prev Problem", show=False), # Editor - OS-native shortcuts with priority Binding("ctrl+s", "save_file", "Save", priority=True), Binding("alt+s", "save_file", "Save", show=False, priority=True), Binding("ctrl+z", "undo", "Undo", show=False, priority=True), # Theme Binding("alt+t", "select_theme", "Theme", priority=True), ] # Reactive state current_file: reactive[Path | None] = reactive(None) workspace_visible: reactive[bool] = reactive(False) compact_mode: reactive[bool] = reactive(False) fullscreen_panel: reactive[str | None] = reactive(None) def __init__( self, workdir: Path | None = None, settings: ClideSettings | None = None, test_mode: bool = False, ) -> None: super().__init__() self.workdir = self._resolve_workdir(workdir) self.settings = settings or ClideSettings() self._test_mode = test_mode # User settings persistence self._settings_service = get_settings_service() self._user_settings = self._settings_service.load() # Extension manager self.extension_manager = ExtensionManager() # Controllers self.git_controller = GitController(self.workdir) self.editor_controller = EditorController() self.diff_controller = DiffController(self.workdir) self.problems_controller = ProblemsController(self.workdir) self.todos_controller = TodosController(self.workdir) # Use settings parameter if jira_enabled explicitly set, otherwise user settings jira_enabled = self.settings.jira_enabled or self._user_settings.jira_enabled self.jira_controller = JiraController( enabled=jira_enabled, ) # Register themes self._register_themes() # Register additional syntax highlighting languages register_languages() @staticmethod def _resolve_workdir(workdir: Path | None) -> Path: """Resolve the working directory to the git root when possible.""" import subprocess target = workdir.resolve() if workdir and workdir.is_dir() else Path.cwd() # Find git root -- anchors to the project root even if cwd is a subdirectory try: result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], cwd=str(target), capture_output=True, text=True, timeout=3, ) if result.returncode == 0: git_root = Path(result.stdout.strip()) if git_root.is_dir(): return git_root except (subprocess.TimeoutExpired, FileNotFoundError, OSError): pass return target def _register_themes(self) -> None: """Register all themes with Textual.""" for theme_meta in get_all_themes(): theme_def = get_theme(theme_meta.name) if theme_def: self.register_theme(theme_def.to_textual_theme()) # Set initial theme: settings parameter takes precedence over user settings # Use settings.theme if different from default, otherwise user settings if self.settings.theme != "summer-night": self.theme = self.settings.theme else: self.theme = self._user_settings.theme def set_theme(self, theme_name: str, *, save: bool = True) -> None: """Set the application theme. Args: theme_name: Name of the theme to apply save: Whether to persist the setting (default: True) """ self.theme = theme_name if save: self._settings_service.set("theme", theme_name) self.notify(f"Theme set to: {theme_name}", severity="information") def save_user_settings(self) -> None: """Save current user settings to disk.""" self._settings_service.update( theme=self.theme, compact_mode=self.compact_mode, sidebar_visible=self.query_one(SidebarPanel).display, context_visible=self.query_one(ContextPanel).display, ) def compose(self) -> ComposeResult: """Create the main layout.""" yield Header() with Horizontal(id="main-container"): # Left sidebar yield SidebarPanel(workdir=self.workdir) # Center column with workspace and claude with Vertical(id="center-column"): yield WorkspacePanel(workdir=self.workdir) yield ClaudePanel( workdir=self.workdir, auto_start=not self._test_mode, ) # Right context panel yield ContextPanel( jira_enabled=self.settings.jira_enabled, project_path=self.workdir, ) yield Footer() async def on_mount(self) -> None: """Initialize application on mount.""" # Apply saved user settings self._apply_user_settings() # Load extensions self.extension_manager.load_extensions() await self.extension_manager.trigger_app_startup(self) # Set up file watching for real-time sync self._setup_file_watching() # Set up Claude event parsing for IDE integration self._setup_claude_events() # Initial data refresh await self._refresh_git() await self._refresh_problems() await self._refresh_todos() if self._user_settings.jira_enabled: await self._refresh_jira() # Focus Claude panel self.action_focus_claude() def _setup_file_watching(self) -> None: """Set up file system watching for real-time updates.""" self._file_watcher = setup_file_watching( self.workdir, handlers=[self._on_file_changed], ) def _setup_claude_events(self) -> None: """Set up Claude event parsing for IDE integration.""" setup_event_parsing(self._on_claude_event) def _on_file_changed(self, event: FileEvent) -> None: """Handle file system changes by posting a FileEventMessage. This bridges the watchdog callback to Textual's message system, allowing widgets to subscribe to file events via on_file_event_message. Note: This is called from the watchdog thread, so we use call_from_thread to safely execute on the main thread. The FileEvent (Pydantic model) is thread-safe, but we create the Message on the main thread to avoid any potential Textual threading issues. """ def post_file_event(): self.post_message(FileEventMessage(event)) self.call_from_thread(post_file_event) async def on_file_event_message(self, message: FileEventMessage) -> None: """Handle file event messages from the file watcher. This is the central handler for all file system events. The App coordinates updates to child widgets since Textual messages bubble up (not down to children). """ event = message.event # Ignore files in .clide directory (settings, etc.) if ".clide" in str(event.path): return # Trigger extension hooks self.extension_manager.trigger_file_changed(event) # Debounce: skip if we refreshed recently (within 1 second) import time now = time.time() if hasattr(self, "_last_file_refresh") and now - self._last_file_refresh < 1.0: return self._last_file_refresh = now # Refresh file tree for created/deleted/moved files if event.event_type in ("created", "deleted", "moved"): try: sidebar = self.query_one(SidebarPanel) sidebar.refresh_files() except Exception: pass # Use call_later to avoid blocking the event loop during refreshes # This ensures UI responsiveness isn't affected by heavy git operations if event.event_type in ("created", "modified", "deleted", "moved"): self.call_later(self._async_refresh_after_file_change, event) async def _async_refresh_after_file_change(self, event: FileEvent) -> None: """Perform async refreshes after a file change without blocking UI.""" # Refresh git status for all file changes await self._refresh_git() # Only refresh problems/todos for Python/text files if event.path.suffix in (".py", ".pyi", ".txt", ".md", ".rst"): if event.event_type in ("created", "modified"): await self._refresh_problems() await self._refresh_todos() def _on_claude_event(self, event: ClaudeEvent) -> None: """Handle Claude Code events for IDE integration. When Claude reads/edits files, we can update the UI accordingly. """ # Schedule handling on the main thread self.call_later(self._handle_claude_event, event) async def _handle_claude_event(self, event: ClaudeEvent) -> None: """Async handler for Claude events.""" if isinstance(event, FileReadEvent): # Claude read a file - highlight in sidebar file tree try: sidebar = self.query_one(SidebarPanel) sidebar.highlight_file(event.path) except Exception: pass # Trigger extension hook self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)}) elif isinstance(event, FileEditEvent): # Claude edited a file - notify user # Note: Git refresh is handled by FileEventMessage from the file watcher self.notify(f"Claude edited: {event.path.name}", severity="information") # Trigger extension hook self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)}) elif isinstance(event, FileWriteEvent): # Claude created/wrote a file - notify user # Note: Git refresh and file tree refresh are handled by # FileEventMessage from the file watcher (event-driven) self.notify(f"Claude wrote: {event.path.name}", severity="information") # Trigger extension hook self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)}) def _apply_user_settings(self) -> None: """Apply saved user settings on startup.""" # Panel visibility sidebar = self.query_one(SidebarPanel) sidebar.visible = self._user_settings.sidebar_visible context = self.query_one(ContextPanel) context.visible = self._user_settings.context_visible # Compact mode self.compact_mode = self._user_settings.compact_mode # Re-apply theme after mount (Textual needs this for proper initialization) if self._user_settings.theme: self.theme = self._user_settings.theme # Reactive watchers def watch_workspace_visible(self, visible: bool) -> None: """Update panels when workspace visibility changes.""" workspace = self.query_one(WorkspacePanel) claude = self.query_one(ClaudePanel) workspace.visible = visible claude.workspace_visible = visible def watch_compact_mode(self, compact: bool) -> None: """Toggle compact mode class.""" container = self.query_one("#main-container") if compact: container.add_class("compact") else: container.remove_class("compact") # Data refresh methods async def _refresh_git(self) -> None: """Refresh git status.""" status = await self.git_controller.get_status() if status: sidebar = self.query_one(SidebarPanel) sidebar.update_git_status(status.staged, status.unstaged) sidebar.current_branch = status.branch branches = await self.git_controller.get_branches() if branches: sidebar = self.query_one(SidebarPanel) sidebar.update_branches([b.name for b in branches]) commits = await self.git_controller.get_log(limit=50) if commits: sidebar = self.query_one(SidebarPanel) sidebar.update_git_graph(commits) async def _refresh_problems(self) -> None: """Refresh linter problems.""" problems = await self.problems_controller.run_all() context = self.query_one(ContextPanel) context.update_problems(problems) async def _refresh_todos(self) -> None: """Refresh TODOs.""" code_todos, project_todos = await self.todos_controller.scan() context = self.query_one(ContextPanel) context.update_todos(code_todos, project_todos) async def _refresh_jira(self) -> None: """Refresh Jira content.""" context = self.query_one(ContextPanel) context.set_jira_loading() content = await self.jira_controller.get_content() if content: context.update_jira(content) else: context.set_jira_error("Failed to load Jira content") # Action methods def action_toggle_sidebar(self) -> None: """Toggle sidebar visibility.""" sidebar = self.query_one(SidebarPanel) sidebar.visible = not sidebar.visible self._settings_service.set("sidebar_visible", sidebar.visible) def action_toggle_context(self) -> None: """Toggle context panel visibility.""" context = self.query_one(ContextPanel) context.visible = not context.visible self._settings_service.set("context_visible", context.visible) def action_toggle_terminal(self) -> None: """Toggle terminal (shows workspace with terminal tab).""" workspace = self.query_one(WorkspacePanel) if self.workspace_visible and workspace.get_active_tab_type() == "terminal": self.workspace_visible = False else: workspace.show_terminal() self.workspace_visible = True def action_toggle_compact(self) -> None: """Toggle compact mode.""" self.compact_mode = not self.compact_mode self._settings_service.set("compact_mode", self.compact_mode) def action_toggle_fullscreen(self) -> None: """Toggle fullscreen for focused panel.""" # TODO: Implement fullscreen toggle pass def action_escape(self) -> None: """Handle escape key.""" if self.fullscreen_panel: self.fullscreen_panel = None elif self.workspace_visible: workspace = self.query_one(WorkspacePanel) if not workspace.has_unsaved_changes(): self.workspace_visible = False def action_focus_claude(self) -> None: """Focus Claude panel.""" claude = self.query_one(ClaudePanel) claude.focus_terminal() def action_focus_editor(self) -> None: """Focus editor.""" self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.focus_last_editor() def action_focus_terminal(self) -> None: """Focus terminal.""" self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.show_terminal() def action_focus_sidebar(self) -> None: """Focus sidebar.""" sidebar = self.query_one(SidebarPanel) sidebar.visible = True sidebar.focus() def action_close_tab(self) -> None: """Close current tab in workspace.""" workspace = self.query_one(WorkspacePanel) workspace.close_tab() def action_open_git(self) -> None: """Open git panel.""" sidebar = self.query_one(SidebarPanel) sidebar.visible = True sidebar.focus_tab("sidebar-git") def action_open_problems(self) -> None: """Open problems panel.""" context = self.query_one(ContextPanel) context.visible = True context.focus_problems() def action_next_problem(self) -> None: """Go to next problem.""" # TODO: Implement problem navigation pass def action_prev_problem(self) -> None: """Go to previous problem.""" # TODO: Implement problem navigation pass def action_save_file(self) -> None: """Save current file in editor.""" try: workspace = self.query_one(WorkspacePanel) if workspace.has_unsaved_changes(): workspace.save_active_editor() else: self.notify("No unsaved changes", severity="warning") except Exception as e: self.notify(f"Save failed: {e}", severity="error") def action_undo(self) -> None: """Undo last action in focused widget.""" # Undo is handled by the focused widget (TextArea has built-in undo) # This action provides feedback if no undo is available focused = self.focused if focused and hasattr(focused, "undo"): focused.undo() else: self.notify("Undo not available", severity="warning") def action_quick_open(self) -> None: """Quick file open.""" # TODO: Implement quick open pass async def action_select_theme(self) -> None: """Open theme selector.""" from clide.themes.registry import get_all_themes # Get all available themes themes = get_all_themes() theme_names = [t.name for t in themes] # Use Textual's built-in selection if available, otherwise cycle # For now, simple cycle through themes current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0 next_idx = (current_idx + 1) % len(theme_names) self.set_theme(theme_names[next_idx]) # Event handlers for panel messages async def on_sidebar_panel_file_selected( self, event: SidebarPanel.FileSelected, ) -> None: """Handle file selection from sidebar.""" self.current_file = event.path self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.open_file(event.path) async def on_sidebar_panel_git_file_selected( self, event: SidebarPanel.GitFileSelected, ) -> None: """Handle git file selection - show diff.""" diff = await self.diff_controller.get_file_diff( str(event.path), staged=event.staged, ) if diff: self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.show_diff(diff) async def on_sidebar_panel_branch_changed( self, event: SidebarPanel.BranchChanged, ) -> None: """Handle branch change.""" success = await self.git_controller.checkout_branch(event.branch) if success: await self._refresh_git() async def on_context_panel_problem_clicked( self, event: ContextPanel.ProblemClicked, ) -> None: """Handle problem click - open file at line.""" problem = event.problem self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.open_file(problem.file_path, line=problem.line) async def on_context_panel_todo_clicked( self, event: ContextPanel.TodoClicked, ) -> None: """Handle TODO click - open file at line.""" item = event.item self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.open_file(item.file_path, line=item.line) async def on_context_panel_project_todo_clicked( self, event: ContextPanel.ProjectTodoClicked, ) -> None: """Handle project TODO click - open TODO.md at line.""" item = event.item todo_path = self.workdir / "TODO.md" if todo_path.exists(): self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.open_file(todo_path, line=item.line) async def on_context_panel_todo_md_created( self, event: ContextPanel.TodoMdCreated, ) -> None: """Handle TODO.md creation - refresh TODOs and open file.""" # Refresh TODOs to pick up the new file await self._refresh_todos() # Open the new file in editor self.workspace_visible = True workspace = self.query_one(WorkspacePanel) workspace.open_file(event.path) self.notify("Created TODO.md") async def on_context_panel_jira_refresh_requested( self, _event: ContextPanel.JiraRefreshRequested, ) -> None: """Handle Jira refresh request.""" await self._refresh_jira() async def on_workspace_panel_file_saved( self, event: WorkspacePanel.FileSaved, ) -> None: """Handle file save - refresh problems and git.""" self.notify(f"Saved: {event.path.name}", severity="information") await self._refresh_git() await self._refresh_problems() async def on_workspace_panel_diff_accepted( self, event: WorkspacePanel.DiffAccepted, ) -> None: """Handle diff accept.""" await self.diff_controller.accept_proposal(event.file_path) await self._refresh_git() await self._refresh_problems() async def on_workspace_panel_diff_rejected( self, event: WorkspacePanel.DiffRejected, ) -> None: """Handle diff reject.""" await self.diff_controller.reject_proposal(event.file_path) workspace = self.query_one(WorkspacePanel) workspace.clear_diff() def on_claude_panel_claude_started( self, _event: ClaudePanel.ClaudeStarted, ) -> None: """Handle Claude Code started.""" self.notify("Claude Code started", severity="information") def on_claude_panel_claude_exited( self, event: ClaudePanel.ClaudeExited, ) -> None: """Handle Claude Code exited.""" if event.return_code != 0: self.notify(f"Claude Code exited with code {event.return_code}", severity="warning") def on_workspace_panel_maximize_requested( self, _event: WorkspacePanel.MaximizeRequested, ) -> None: """Handle workspace maximize request - hide sidebars.""" # Hide sidebars when workspace is maximized sidebar = self.query_one(SidebarPanel) context = self.query_one(ContextPanel) sidebar.display = False context.display = False # Hide Claude panel claude = self.query_one(ClaudePanel) claude.display = False def on_workspace_panel_restore_requested( self, _event: WorkspacePanel.RestoreRequested, ) -> None: """Handle workspace restore request - show sidebars.""" # Restore sidebars based on saved visibility settings sidebar = self.query_one(SidebarPanel) context = self.query_one(ContextPanel) sidebar.display = self._user_settings.sidebar_visible context.display = self._user_settings.context_visible # Show Claude panel claude = self.query_one(ClaudePanel) claude.display = True def on_workspace_panel_close_requested( self, _event: WorkspacePanel.CloseRequested, ) -> None: """Handle workspace close request.""" self.workspace_visible = False def on_sidebar_panel_claude_command_requested( self, event: SidebarPanel.ClaudeCommandRequested, ) -> None: """Handle Claude command request from git panel. Sends skill commands (e.g., /commit) to Claude terminal. Ensures the specific skill is installed before sending. """ from clide.services.skill_installer import get_skill_installer # Extract skill name from command (e.g., "/commit" -> "commit") skill_name = event.command.lstrip("/").split()[0] installer = get_skill_installer(project_dir=self.workdir) # Quick check if already installed if installer.is_installed(skill_name): self._send_claude_command(event.command) return # Need to install - show notification and do in background self.notify(f"Installing {skill_name} skill...", timeout=10) self._install_skill_and_run(skill_name, event.command) @work(thread=True) def _install_skill_and_run(self, skill_name: str, command: str) -> tuple[str, str]: """Install skill in background thread and return command to run.""" self.git_controller._ensure_skill(skill_name) return (skill_name, command) def on_worker_state_changed(self, event: Worker.StateChanged) -> None: """Handle worker completion.""" if event.state == WorkerState.SUCCESS: # Check if this was a skill installation worker if event.worker.name == "_install_skill_and_run": result = event.worker.result if result: skill_name, command = result self._send_claude_command(command) self.notify(f"{skill_name} skill installed!", severity="information", timeout=3) def _send_claude_command(self, command: str) -> None: """Send a command to Claude terminal.""" claude = self.query_one(ClaudePanel) claude.send_input(command) self.action_focus_claude()