diff --git a/clide/app.py b/clide/app.py index f5e7fc11..1721c7ca 100644 --- a/clide/app.py +++ b/clide/app.py @@ -3,11 +3,13 @@ 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 @@ -113,6 +115,32 @@ class ClideApp(App[None]): 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 @@ -182,8 +210,10 @@ class ClideApp(App[None]): 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=self._user_settings.jira_enabled, + enabled=jira_enabled, ) # Register themes @@ -199,8 +229,12 @@ class ClideApp(App[None]): if theme_def: self.register_theme(theme_def.to_textual_theme()) - # Set initial theme from user settings - self.theme = self._user_settings.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. @@ -309,9 +343,21 @@ class ClideApp(App[None]): """ 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: @@ -778,14 +824,43 @@ class ClideApp(App[None]): """Handle Claude command request from git panel. Sends skill commands (e.g., /commit) to Claude terminal. - Ensures the git-workflow skill is installed before sending. + Ensures the specific skill is installed before sending. """ - # Ensure skill is available - self.git_controller._ensure_git_skill() + from clide.services.skill_installer import get_skill_installer - # Send command to Claude terminal + # 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(event.command) - - # Focus Claude panel so user can see the response + claude.send_input(command) self.action_focus_claude() diff --git a/clide/controllers/git.py b/clide/controllers/git.py index 3902cb4e..d7ddafba 100644 --- a/clide/controllers/git.py +++ b/clide/controllers/git.py @@ -1,13 +1,23 @@ """Git controller for managing git operations.""" -from pathlib import Path +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal from textual.message import Message from clide.controllers.base import controller -from clide.models.git import GitBranch, GitCommit, GitStatus from clide.services.git_service import GitService +if TYPE_CHECKING: + from pathlib import Path + + from clide.models.git import GitBranch, GitCommit, GitStatus +from clide.services.skill_installer import get_skill_installer + +# Git operations that can be delegated to Claude via skills +GitSkillCommand = Literal["commit", "stash", "pull", "push", "branch"] + @controller class GitController: @@ -199,3 +209,95 @@ class GitController: Diff string """ return await self._service.get_diff(path, staged) + + # ========================================================================= + # Claude Skill Integration + # ========================================================================= + + class ClaudeCommandRequested(Message): + """Emitted when a git command should be sent to Claude.""" + + def __init__(self, command: str) -> None: + self.command = command + super().__init__() + + def _ensure_skill(self, skill_name: str) -> bool: + """Ensure a specific skill is installed. + + Args: + skill_name: The skill name (e.g., "commit", "stash"). + + Returns: + True if skill is available. + """ + installer = get_skill_installer() + + # Check if already installed + if installer.is_installed(skill_name): + return True + + # Try to install from template (project scope by default) + try: + installer.install(skill_name, scope="project") + return True + except ValueError: + # Template not found + return False + except FileExistsError: + # Already exists (race condition) + return True + + def _ensure_git_skill(self) -> bool: + """Ensure all git skills are installed (legacy compatibility). + + Returns: + True if commit skill is available. + """ + return self._ensure_skill("commit") + + def request_claude_commit(self) -> bool: + """Request Claude to handle the commit workflow. + + Emits ClaudeCommandRequested with /commit command. + + Returns: + True if skill is available and command was requested. + """ + # The app will handle this message and send to Claude terminal + return self._ensure_git_skill() + + def request_claude_stash(self) -> bool: + """Request Claude to handle stashing changes. + + Returns: + True if skill is available and command was requested. + """ + return self._ensure_git_skill() + + def request_claude_pull(self) -> bool: + """Request Claude to handle pulling changes. + + Returns: + True if skill is available and command was requested. + """ + return self._ensure_git_skill() + + def request_claude_push(self) -> bool: + """Request Claude to handle pushing changes. + + Returns: + True if skill is available and command was requested. + """ + return self._ensure_git_skill() + + def get_claude_command(self, action: GitSkillCommand) -> str: + """Get the Claude command string for a git action. + + Args: + action: The git action to perform. + + Returns: + The command string to send to Claude (e.g., "/commit"). + """ + self._ensure_git_skill() + return f"/{action}" diff --git a/clide/services/__init__.py b/clide/services/__init__.py index f32e3291..e63b13f3 100644 --- a/clide/services/__init__.py +++ b/clide/services/__init__.py @@ -4,6 +4,7 @@ from clide.services.git_service import GitService from clide.services.linter_service import LinterService from clide.services.process_service import ProcessService from clide.services.settings_service import SettingsService, UserSettings, get_settings_service +from clide.services.skill_installer import SkillInstaller, get_skill_installer from clide.services.todo_scanner import TodoScanner __all__ = [ @@ -11,7 +12,9 @@ __all__ = [ "LinterService", "ProcessService", "SettingsService", + "SkillInstaller", "TodoScanner", "UserSettings", "get_settings_service", + "get_skill_installer", ] diff --git a/clide/services/file_watcher.py b/clide/services/file_watcher.py index 2c50f6b5..921e8d24 100644 --- a/clide/services/file_watcher.py +++ b/clide/services/file_watcher.py @@ -6,27 +6,32 @@ enabling reactive updates when files change on disk. from __future__ import annotations -import asyncio from datetime import datetime from pathlib import Path -from typing import Callable, Literal +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Callable from pydantic import BaseModel, ConfigDict from textual.message import Message try: - from watchdog.observers import Observer as WatchdogObserver + from watchdog.events import ( + DirCreatedEvent, + DirDeletedEvent, + DirModifiedEvent, + DirMovedEvent, + FileCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, + ) from watchdog.events import ( FileSystemEventHandler as WatchdogHandler, - FileCreatedEvent, - FileModifiedEvent, - FileDeletedEvent, - FileMovedEvent, - DirCreatedEvent, - DirModifiedEvent, - DirDeletedEvent, - DirMovedEvent, ) + from watchdog.observers import Observer as WatchdogObserver + WATCHDOG_AVAILABLE = True except ImportError: WATCHDOG_AVAILABLE = False @@ -103,6 +108,8 @@ class FileWatcher: ".ruff_cache", ".pytest_cache", "*.egg-info", + ".clide", + ".claude", ] self._handlers: list[Callable[[FileEvent], None]] = [] self._observer: WatchdogObserver | None = None # type: ignore[valid-type] @@ -219,21 +226,15 @@ class _WatchdogHandler(WatchdogHandler): # type: ignore[misc, valid-type] ) def on_created(self, event) -> None: # type: ignore[no-untyped-def] - file_event = self._create_event( - event.src_path, "created", event.is_directory - ) + file_event = self._create_event(event.src_path, "created", event.is_directory) self._watcher._emit_event(file_event) def on_modified(self, event) -> None: # type: ignore[no-untyped-def] - file_event = self._create_event( - event.src_path, "modified", event.is_directory - ) + file_event = self._create_event(event.src_path, "modified", event.is_directory) self._watcher._emit_event(file_event) def on_deleted(self, event) -> None: # type: ignore[no-untyped-def] - file_event = self._create_event( - event.src_path, "deleted", event.is_directory - ) + file_event = self._create_event(event.src_path, "deleted", event.is_directory) self._watcher._emit_event(file_event) def on_moved(self, event) -> None: # type: ignore[no-untyped-def] diff --git a/clide/services/git_service.py b/clide/services/git_service.py index 3d36e9ee..d6e2e949 100644 --- a/clide/services/git_service.py +++ b/clide/services/git_service.py @@ -44,25 +44,35 @@ class GitService: # Parse status if index_status == "?": + # Untracked files go in both untracked list and unstaged untracked.append(path) + unstaged.append( + GitChange( + path=path, + status=ChangeStatus.UNTRACKED, + staged=False, + ) + ) else: if index_status != " ": - staged.append(GitChange( - path=path, - status=self._parse_status(index_status), - staged=True, - )) + staged.append( + GitChange( + path=path, + status=self._parse_status(index_status), + staged=True, + ) + ) if worktree_status != " ": - unstaged.append(GitChange( - path=path, - status=self._parse_status(worktree_status), - staged=False, - )) + unstaged.append( + GitChange( + path=path, + status=self._parse_status(worktree_status), + staged=False, + ) + ) # Get current branch - branch_result = await self._process.run( - "git", "branch", "--show-current" - ) + branch_result = await self._process.run("git", "branch", "--show-current") branch = branch_result.stdout.strip() if branch_result.success else "HEAD" # Get ahead/behind @@ -80,8 +90,7 @@ class GitService: async def _get_ahead_behind(self, branch: str) -> tuple[int, int]: """Get commits ahead/behind upstream.""" result = await self._process.run( - "git", "rev-list", "--left-right", "--count", - f"{branch}...@{{upstream}}" + "git", "rev-list", "--left-right", "--count", f"{branch}...@{{upstream}}" ) if result.success: parts = result.stdout.strip().split() @@ -146,8 +155,11 @@ class GitService: List of GitBranch objects """ result = await self._process.run( - "git", "branch", "-a", "--format", - "%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)" + "git", + "branch", + "-a", + "--format", + "%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)", ) branches: list[GitBranch] = [] @@ -159,14 +171,16 @@ class GitService: parts = line[1:].split("|") if len(parts) >= 4: name = parts[0].strip() - branches.append(GitBranch( - name=name, - is_current=is_current, - is_remote=name.startswith("remotes/"), - tracking=parts[1] or None, - commit_hash=parts[2], - commit_message=parts[3], - )) + branches.append( + GitBranch( + name=name, + is_current=is_current, + is_remote=name.startswith("remotes/"), + tracking=parts[1] or None, + commit_hash=parts[2], + commit_message=parts[3], + ) + ) return branches @@ -227,7 +241,8 @@ class GitService: List of GitCommit objects """ result = await self._process.run( - "git", "log", + "git", + "log", f"--max-count={max_count}", "--format=%H|%h|%s|%an|%ar|%P|%D", "--all", @@ -242,15 +257,17 @@ class GitService: if len(parts) >= 7: parents = tuple(parts[5].split()) if parts[5] else () refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else () - commits.append(GitCommit( - hash=parts[0], - short_hash=parts[1], - message=parts[2], - author=parts[3], - date=parts[4], - is_merge=len(parents) > 1, - parents=parents, - refs=refs, - )) + commits.append( + GitCommit( + hash=parts[0], + short_hash=parts[1], + message=parts[2], + author=parts[3], + date=parts[4], + is_merge=len(parents) > 1, + parents=parents, + refs=refs, + ) + ) return commits diff --git a/clide/widgets/components/__init__.py b/clide/widgets/components/__init__.py index 33f075a0..4eb0083a 100644 --- a/clide/widgets/components/__init__.py +++ b/clide/widgets/components/__init__.py @@ -9,6 +9,7 @@ from clide.widgets.components.git_graph import GitGraphView from clide.widgets.components.jira_view import JiraView from clide.widgets.components.problems_view import ProblemsView from clide.widgets.components.terminal_pane import TerminalPane +from clide.widgets.components.tile_list import TileItem, TileListView from clide.widgets.components.todos_view import TodosView __all__ = [ @@ -21,5 +22,7 @@ __all__ = [ "JiraView", "ProblemsView", "TerminalPane", + "TileItem", + "TileListView", "TodosView", ] diff --git a/clide/widgets/components/branch_status.py b/clide/widgets/components/branch_status.py index 97066b4f..b452e9ac 100644 --- a/clide/widgets/components/branch_status.py +++ b/clide/widgets/components/branch_status.py @@ -28,7 +28,27 @@ class BranchStatus(Vertical): } BranchStatus .branch-name { + width: auto; + min-width: 12; + } + + BranchStatus .toggle-icon { + width: auto; + margin-right: 1; + } + + BranchStatus .git-stats { width: 1fr; + text-align: right; + color: $text-muted; + } + + BranchStatus .staged-count { + color: $success; + } + + BranchStatus .unstaged-count { + color: $warning; } BranchStatus .popout { @@ -50,10 +70,22 @@ class BranchStatus(Vertical): text-style: bold; } + BranchStatus #branch-list { + height: auto; + max-height: 8; + } + BranchStatus .popout-actions { - height: 1; + height: auto; padding: 0 1; } + + BranchStatus .popout-actions Button { + width: 1fr; + min-width: 10; + height: 3; + margin: 0 1; + } """ class BranchChanged(Message): @@ -68,24 +100,30 @@ class BranchStatus(Vertical): class NewBranchRequested(Message): """Emitted when new branch creation is requested.""" + pass def __init__( self, current_branch: str = "main", branches: list[GitBranch] | None = None, + staged: int = 0, + unstaged: int = 0, **kwargs, ) -> None: super().__init__(**kwargs) self._current = current_branch self._branches = branches or [] self._popout_visible = False + self._staged = staged + self._unstaged = unstaged def compose(self) -> ComposeResult: with Horizontal(classes="status-bar"): yield Static("⎇", classes="branch-icon") yield Static(self._current, classes="branch-name", id="branch-name") yield Static("▾", classes="toggle-icon") + yield Static(self._format_stats(), classes="git-stats", id="git-stats") with Vertical(classes="popout", id="branch-popout"): yield Label("Recent branches", classes="popout-header") @@ -115,6 +153,25 @@ class BranchStatus(Vertical): """Update current branch display.""" self.branch = branch + def _format_stats(self) -> str: + """Format git stats display.""" + parts = [] + if self._staged > 0: + parts.append(f"[staged-count]staged: {self._staged}[/]") + if self._unstaged > 0: + parts.append(f"[unstaged-count]unstaged: {self._unstaged}[/]") + return " · ".join(parts) if parts else "" + + def update_stats(self, staged: int, unstaged: int) -> None: + """Update staged/unstaged counts.""" + self._staged = staged + self._unstaged = unstaged + try: + stats = self.query_one("#git-stats", Static) + stats.update(self._format_stats()) + except Exception: + pass + def update_branches(self, branches: list[GitBranch] | list[str]) -> None: """Update branches list.""" self._branches = branches # type: ignore @@ -146,14 +203,14 @@ class BranchStatus(Vertical): """Handle click on status bar.""" self.toggle_popout() - def on_button_pressed(self, _event: Button.Pressed) -> None: + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "btn-checkout": branch_list = self.query_one("#branch-list", ListView) if branch_list.highlighted_child: # Get selected branch name label = branch_list.highlighted_child.query_one(Label) - branch = label.renderable.plain.lstrip("● ○ ") + branch = str(label.renderable).lstrip("●").lstrip("○").lstrip() self.post_message(self.BranchChangeRequested(branch)) self.toggle_popout() elif event.button.id == "btn-new": diff --git a/clide/widgets/components/files_view.py b/clide/widgets/components/files_view.py index 6586876a..e305c73f 100644 --- a/clide/widgets/components/files_view.py +++ b/clide/widgets/components/files_view.py @@ -2,7 +2,6 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING from rich.text import Text @@ -10,6 +9,8 @@ from textual.message import Message from textual.widgets import DirectoryTree if TYPE_CHECKING: + from pathlib import Path + from rich.style import Style from textual.widgets._directory_tree import DirEntry from textual.widgets._tree import TreeNode @@ -55,32 +56,47 @@ class FilesView(DirectoryTree): classes=classes, ) - def render_label( - self, node: TreeNode[DirEntry], base_style: Style, style: Style - ) -> Text: + def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style) -> Text: """Render a label with minimal Unicode icons.""" path = node.data.path + is_dimmed = path.name.startswith(".") or path.name in self.DIMMED_PATHS if path.is_dir(): icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED - icon_style = "bold cyan" + icon_style = "dim cyan" if is_dimmed else "bold cyan" else: icon = ICON_FILE icon_style = "dim" label = Text() label.append(f"{icon} ", style=icon_style) - label.append(path.name, style=style) + label.append(path.name, style="dim" if is_dimmed else style) return label - def filter_paths(self, paths: list[Path]) -> list[Path]: - """Filter out hidden and ignored paths.""" - return [ - p for p in paths - if not p.name.startswith(".") - and p.name not in ("__pycache__", "node_modules", ".git", ".venv", "venv") - ] + # Directories that clutter the tree and are never needed in the IDE + HIDDEN_DIRS = { + "__pycache__", + "node_modules", + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + } + # Directories/files shown but dimmed (less important) + DIMMED_PATHS = { + ".venv", + "venv", + "dist", + "build", + } + + def filter_paths(self, paths: list[Path]) -> list[Path]: + """Filter out noisy directories but show hidden files.""" + return [ + p for p in paths if p.name not in self.HIDDEN_DIRS and not p.name.endswith(".egg-info") + ] def refresh_tree(self) -> None: """Refresh the directory tree.""" @@ -100,7 +116,7 @@ class FilesView(DirectoryTree): # Find and select the node def find_node(node, target_path): """Recursively find a node by path.""" - if node.data and hasattr(node.data, 'path'): + if node.data and hasattr(node.data, "path"): if node.data.path.resolve() == target_path: return node for child in node.children: @@ -121,9 +137,7 @@ class FilesView(DirectoryTree): self.select_node(target_node) self.scroll_to_node(target_node) - def on_directory_tree_file_selected( - self, event: DirectoryTree.FileSelected - ) -> None: + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: """Re-emit file selection as FilesView.FileSelected.""" event.stop() self.post_message(self.FileSelected(node=event.node, path=event.path)) diff --git a/clide/widgets/components/git_changes.py b/clide/widgets/components/git_changes.py index e0906d49..bec8fdb7 100644 --- a/clide/widgets/components/git_changes.py +++ b/clide/widgets/components/git_changes.py @@ -1,9 +1,9 @@ """Git changes view component.""" from textual.app import ComposeResult -from textual.containers import Vertical +from textual.containers import Horizontal, Vertical from textual.message import Message -from textual.widgets import Label, ListItem, ListView, Static +from textual.widgets import Button, Label, ListItem, ListView, Static from clide.models.git import ChangeStatus, GitChange @@ -22,15 +22,30 @@ class GitChangeItem(ListItem): ChangeStatus.IGNORED: "I", } + # Map status to Rich color styles + STATUS_COLORS = { + ChangeStatus.ADDED: "green", + ChangeStatus.MODIFIED: "yellow", + ChangeStatus.DELETED: "red", + ChangeStatus.RENAMED: "cyan", + ChangeStatus.UNTRACKED: "magenta", + ChangeStatus.COPIED: "cyan", + ChangeStatus.UNMERGED: "red bold", + ChangeStatus.IGNORED: "dim", + } + def __init__(self, change: GitChange) -> None: super().__init__() self.change = change def compose(self) -> ComposeResult: + from rich.markup import escape + icon = self.STATUS_ICONS.get(self.change.status, "?") - status_class = self.change.status.value + color = self.STATUS_COLORS.get(self.change.status, "white") + safe_path = escape(self.change.path) yield Static( - f"[{status_class}]{icon}[/] {self.change.path}", + f"[{color}]{icon}[/] {safe_path}", markup=True, ) @@ -40,24 +55,69 @@ class GitChangesView(Vertical): DEFAULT_CSS = """ GitChangesView { - height: 100%; + height: 1fr; + background: $background; } GitChangesView .section-header { background: $surface; padding: 0 1; + height: 1; text-style: bold; + border-bottom: solid $primary; } GitChangesView ListView { - height: auto; - max-height: 50%; + height: 1fr; + min-height: 3; + scrollbar-size: 1 1; + margin-bottom: 1; } - GitChangesView .added { color: $success; } - GitChangesView .modified { color: $warning; } - GitChangesView .deleted { color: $error; } - GitChangesView .untracked { color: $accent; } + GitChangesView #staged-list { + background: $panel; + } + + GitChangesView #staged-list ListItem:even { + background: $panel; + } + + GitChangesView #staged-list ListItem:odd { + background: $surface; + } + + GitChangesView #unstaged-list { + background: $background; + } + + GitChangesView #unstaged-list ListItem:even { + background: $background; + } + + GitChangesView #unstaged-list ListItem:odd { + background: $panel; + } + + GitChangesView ListItem { + height: auto; + padding: 0 1; + } + + GitChangesView ListItem Static { + width: 100%; + } + + GitChangesView .action-bar { + dock: bottom; + height: 3; + padding: 0 1; + background: $surface; + border-top: solid $primary; + } + + GitChangesView .action-bar Button { + min-width: 6; + } """ class FileClicked(Message): @@ -81,6 +141,13 @@ class GitChangesView(Vertical): self.path = path super().__init__() + class ClaudeActionRequested(Message): + """Emitted when a Claude git action is requested.""" + + def __init__(self, action: str) -> None: + self.action = action # "commit", "stash", "pull", "push" + super().__init__() + def __init__( self, staged: list[GitChange] | None = None, @@ -102,6 +169,11 @@ class GitChangesView(Vertical): *[GitChangeItem(c) for c in self._unstaged], id="unstaged-list", ) + with Horizontal(classes="action-bar"): + yield Button("Commit", id="btn-commit", variant="primary") + yield Button("Stash", id="btn-stash") + yield Button("Pull", id="btn-pull") + yield Button("Push", id="btn-push") def update_changes( self, @@ -127,3 +199,15 @@ class GitChangesView(Vertical): """Handle item selection.""" if isinstance(event.item, GitChangeItem): self.post_message(self.FileClicked(event.item.change)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle action button clicks.""" + button_id = event.button.id + action_map = { + "btn-commit": "commit", + "btn-stash": "stash", + "btn-pull": "pull", + "btn-push": "push", + } + if button_id in action_map: + self.post_message(self.ClaudeActionRequested(action_map[button_id])) diff --git a/clide/widgets/components/git_graph.py b/clide/widgets/components/git_graph.py index 842b3cfa..a9974afe 100644 --- a/clide/widgets/components/git_graph.py +++ b/clide/widgets/components/git_graph.py @@ -1,37 +1,48 @@ """Git graph visualization component.""" +from rich.markup import escape from textual.app import ComposeResult -from textual.containers import Vertical from textual.message import Message -from textual.widgets import RichLog +from textual.widgets import ListView, Static from clide.models.git import GitCommit +from clide.widgets.components.tile_list import TileItem, TileListView -class GitGraphView(Vertical): - """View for git commit graph visualization.""" +class CommitItem(TileItem): + """A single commit item displayed as a tile.""" - DEFAULT_CSS = """ - GitGraphView { - height: 100%; - } - - GitGraphView RichLog { - height: 100%; - scrollbar-size: 1 1; - } - - GitGraphView .commit-line { - height: auto; - } - """ - - # Graph drawing characters COMMIT = "●" MERGE = "◆" - LINE = "│" - BRANCH = "├" - JOIN = "┴" + + def __init__(self, commit: GitCommit) -> None: + super().__init__() + self.commit = commit + + def compose(self) -> ComposeResult: + symbol = self.MERGE if self.commit.is_merge else self.COMMIT + + # Format refs (branches, tags) + refs_str = "" + if self.commit.refs: + refs = ", ".join(self.commit.refs) + refs_str = f" [bold cyan]({escape(refs)})[/]" + + # Truncate message + message = self.commit.message[:60] + if len(self.commit.message) > 60: + message += "..." + + # Multi-line tile format + yield Static( + f"[bold yellow]{symbol}[/] [bold]{escape(message)}[/]{refs_str}\n" + f" [dim]{self.commit.short_hash} · {escape(self.commit.author)} · {self.commit.date}[/]", + markup=True, + ) + + +class GitGraphView(TileListView): + """View for git commit graph visualization.""" class CommitSelected(Message): """Emitted when a commit is selected.""" @@ -45,46 +56,23 @@ class GitGraphView(Vertical): self._commits = commits or [] def compose(self) -> ComposeResult: - yield RichLog(id="graph-log", highlight=True, markup=True) - - def on_mount(self) -> None: - """Render initial graph.""" - self._render_graph() + yield ListView( + *[CommitItem(c) for c in self._commits], + id="commit-list", + ) def update_commits(self, commits: list[GitCommit]) -> None: """Update the commit list.""" self._commits = commits - self._render_graph() + try: + commit_list = self.query_one("#commit-list", ListView) + commit_list.clear() + for commit in commits: + commit_list.append(CommitItem(commit)) + except Exception: + pass - def _render_graph(self) -> None: - """Render the commit graph.""" - log = self.query_one("#graph-log", RichLog) - log.clear() - - for commit in self._commits: - line = self._format_commit_line(commit) - log.write(line) - - def _format_commit_line(self, commit: GitCommit) -> str: - """Format a single commit line.""" - # Choose commit symbol - symbol = self.MERGE if commit.is_merge else self.COMMIT - - # Format refs (branches, tags) - refs_str = "" - if commit.refs: - refs = ", ".join(commit.refs) - refs_str = f" [bold cyan]({refs})[/]" - - # Truncate message - message = commit.message[:50] - if len(commit.message) > 50: - message += "..." - - return ( - f"[bold yellow]{symbol}[/] " - f"[dim]{commit.short_hash}[/]" - f"{refs_str} " - f"{message} " - f"[dim]- {commit.author}, {commit.date}[/]" - ) + def on_list_view_selected(self, event: ListView.Selected) -> None: + """Handle commit selection.""" + if isinstance(event.item, CommitItem): + self.post_message(self.CommitSelected(event.item.commit)) diff --git a/clide/widgets/components/jira_view.py b/clide/widgets/components/jira_view.py index 56cfb316..e969c626 100644 --- a/clide/widgets/components/jira_view.py +++ b/clide/widgets/components/jira_view.py @@ -11,35 +11,41 @@ class JiraView(Vertical): DEFAULT_CSS = """ JiraView { - height: 100%; + height: 1fr; + background: $background; } JiraView .jira-header { height: 1; background: $surface; padding: 0 1; + border-bottom: solid $primary; } JiraView Markdown { height: 1fr; padding: 1; + background: $background; } JiraView .jira-actions { height: auto; - padding: 0 1; - background: $panel; + padding: 1; + background: $surface; + border-top: solid $primary; } JiraView .disabled-message { padding: 2; text-align: center; color: $warning; + background: $panel; } """ class RefreshRequested(Message): """Emitted when refresh is requested.""" + pass class IssueClicked(Message): diff --git a/clide/widgets/components/problems_view.py b/clide/widgets/components/problems_view.py index c97e269a..de40520e 100644 --- a/clide/widgets/components/problems_view.py +++ b/clide/widgets/components/problems_view.py @@ -2,16 +2,17 @@ from pathlib import Path +from rich.markup import escape from textual.app import ComposeResult -from textual.containers import Vertical from textual.message import Message -from textual.widgets import ListItem, ListView, Static +from textual.widgets import ListView, Static from clide.models.problems import Problem +from clide.widgets.components.tile_list import TileItem, TileListView -class ProblemItem(ListItem): - """A single problem item.""" +class ProblemItem(TileItem): + """A single problem item displayed as a tile.""" def __init__(self, problem: Problem) -> None: super().__init__() @@ -20,31 +21,35 @@ class ProblemItem(ListItem): def compose(self) -> ComposeResult: icon = self.problem.severity_icon severity_class = self.problem.severity.value + # Show just filename + filename = ( + self.problem.file_path.name + if hasattr(self.problem.file_path, "name") + else str(self.problem.file_path).split("/")[-1] + ) + safe_message = escape(self.problem.message) yield Static( - f"[{severity_class}]{icon}[/] " - f"[dim]{self.problem.file_path}:{self.problem.line}[/] " - f"{self.problem.message}", + f"[{severity_class}]{icon}[/] [{severity_class}]{safe_message}[/]\n" + f" [dim]{filename}:{self.problem.line}[/]", markup=True, ) -class ProblemsView(Vertical): +class ProblemsView(TileListView): """View for linter problems/diagnostics.""" DEFAULT_CSS = """ ProblemsView { - height: 100%; + height: 1fr; + background: $background; } ProblemsView .problems-header { height: 1; background: $surface; padding: 0 1; - } - - ProblemsView ListView { - height: 1fr; + border-bottom: solid $primary; } ProblemsView .error { color: $error; } diff --git a/clide/widgets/components/tile_list.py b/clide/widgets/components/tile_list.py new file mode 100644 index 00000000..93813ed9 --- /dev/null +++ b/clide/widgets/components/tile_list.py @@ -0,0 +1,128 @@ +"""Reusable tile/card list components for consistent styling.""" + +from textual.containers import Vertical +from textual.widgets import ListItem + + +class TileItem(ListItem): + """A styled tile/card list item. + + Subclass this and override compose() to create custom tiles. + The tile will automatically get alternating background colors + and hover effects from TileListView. + """ + + pass + + +class TileListView(Vertical): + """A list view with tile/card styling. + + Provides: + - Alternating row colors using theme colors + - Hover effects + - Consistent padding and spacing + - Scrollbar styling + + Usage: + class MyTileItem(TileItem): + def __init__(self, data: MyData) -> None: + super().__init__() + self.data = data + + def compose(self) -> ComposeResult: + yield Static(f"[bold]{self.data.title}[/]\\n[dim]{self.data.subtitle}[/]", markup=True) + + class MyView(TileListView): + def compose(self) -> ComposeResult: + yield ListView(*[MyTileItem(d) for d in self.items], id="my-list") + """ + + DEFAULT_CSS = """ + TileListView { + height: 1fr; + background: $background; + } + + TileListView ListView { + height: 1fr; + scrollbar-size: 1 1; + background: $background; + } + + TileListView ListItem { + height: auto; + padding: 1 1; + } + + TileListView ListItem:even { + background: $background; + } + + TileListView ListItem:odd { + background: $panel; + } + + TileListView ListItem:hover { + background: $surface; + } + + TileListView ListItem:focus { + background: $surface; + } + + TileListView ListItem Static { + width: 100%; + } + + TileListView .tile-header { + background: $surface; + padding: 0 1; + height: 1; + text-style: bold; + border-bottom: solid $primary; + } + + TileListView .empty-message { + padding: 2; + text-align: center; + color: $secondary; + } + """ + + +# CSS that can be included in other components for tile styling +TILE_LIST_CSS = """ + /* Tile list styling - include in your component's DEFAULT_CSS */ + + ListView { + height: 1fr; + scrollbar-size: 1 1; + background: $background; + } + + ListItem { + height: auto; + padding: 1 1; + } + + ListItem:even { + background: $background; + } + + ListItem:odd { + background: $panel; + } + + ListItem:hover { + background: $surface; + } + + ListItem:focus { + background: $surface; + } + + ListItem Static { + width: 100%; + } +""" diff --git a/clide/widgets/components/todos_view.py b/clide/widgets/components/todos_view.py index 421925c4..215df61a 100644 --- a/clide/widgets/components/todos_view.py +++ b/clide/widgets/components/todos_view.py @@ -1,13 +1,15 @@ -"""TODOs view component.""" +"""TODOs view component with sub-tabs for Project and Comment TODOs.""" from pathlib import Path +from rich.markup import escape from textual.app import ComposeResult from textual.containers import Vertical from textual.message import Message -from textual.widgets import Button, Collapsible, ListItem, ListView, Static +from textual.widgets import Button, ListView, Static, TabbedContent, TabPane from clide.models.todos import ProjectTodoItem, TodoItem, TodoType +from clide.widgets.components.tile_list import TileItem, TileListView # Template for new TODO.md files TODO_MD_TEMPLATE = """# TODO @@ -42,8 +44,8 @@ Project TODO items. """ -class TodoListItem(ListItem): - """A single code TODO item.""" +class TodoListItem(TileItem): + """A single code TODO item displayed as a tile.""" def __init__(self, item: TodoItem) -> None: super().__init__() @@ -53,17 +55,23 @@ class TodoListItem(ListItem): def compose(self) -> ComposeResult: icon = self.item.type_icon type_class = self.item.todo_type.value.lower() + # Show just filename, not full path + filename = ( + self.item.file_path.name + if hasattr(self.item.file_path, "name") + else str(self.item.file_path).split("/")[-1] + ) + # Escape user content to prevent markup errors + safe_text = escape(self.item.text) yield Static( - f"[{type_class}]{icon} {self.item.todo_type.value}[/] " - f"[dim]{self.item.file_path}:{self.item.line}[/] " - f"{self.item.text}", + f"[{type_class}]{icon}[/] {safe_text}\n" f" [dim]{filename}:{self.item.line}[/]", markup=True, ) -class ProjectTodoListItem(ListItem): - """A single project TODO item from TODO.md.""" +class ProjectTodoListItem(TileItem): + """A single project TODO item from TODO.md displayed as a tile.""" def __init__(self, item: ProjectTodoItem) -> None: super().__init__() @@ -72,54 +80,54 @@ class ProjectTodoListItem(ListItem): def compose(self) -> ComposeResult: icon = self.item.icon - checked_style = "dim strike" if self.item.checked else "" - category = f"[dim]{self.item.category}[/] " if self.item.subsection else "" + category = f"[dim]{escape(self.item.category)}[/] " if self.item.subsection else "" + safe_text = escape(self.item.text) + + text_part = f"[dim strike]{safe_text}[/]" if self.item.checked else safe_text yield Static( - f"[project]{icon}[/] {category}" f"[{checked_style}]{self.item.text}[/]", + f"[project]{icon}[/] {category}{text_part}", markup=True, ) -class TodosView(Vertical): - """View for TODO/FIXME comments and project TODOs.""" +class TodosView(TileListView): + """View for TODO/FIXME comments and project TODOs with sub-tabs.""" DEFAULT_CSS = """ TodosView { - height: 100%; + height: 1fr; + background: $background; } - TodosView .todos-header { - height: 1; - background: $surface; - padding: 0 1; + TodosView TabbedContent { + height: 1fr; } - TodosView .section-header { - height: 1; - background: $panel; - padding: 0 1; - color: $text-muted; + TodosView Tabs { + width: 100%; } - TodosView ListView { - height: auto; - max-height: 50%; + TodosView Tab { + width: 1fr; } + TodosView TabPane { + height: 1fr; + padding: 0; + } + + TodosView ContentSwitcher { + height: 1fr; + } + + TodosView #project-todos-list, TodosView #code-todos-list { height: 1fr; - max-height: none; } - TodosView Collapsible { - padding: 0; - border: none; - } - - TodosView CollapsibleTitle { - background: $panel; - padding: 0 1; + TodosView ListItem { + padding: 1 1; } TodosView .todo { color: $primary; } @@ -133,7 +141,7 @@ class TodosView(Vertical): TodosView .empty-message { padding: 2; text-align: center; - color: $success; + color: $text-muted; } TodosView .create-todo-section { @@ -193,26 +201,28 @@ class TodosView(Vertical): self._has_todo_md = (self._project_path / "TODO.md").exists() def compose(self) -> ComposeResult: - total = len(self._items) + len(self._project_items) - yield Static(f"TODOs ({total})", classes="todos-header", id="todos-header") + # Calculate initial counts + unchecked_project = sum(1 for i in self._project_items if not i.checked) + code_count = len(self._items) - # Create TODO.md section (shown when file doesn't exist) - with Vertical(classes="create-todo-section", id="create-todo-section"): - yield Static( - "No TODO.md found in project", - classes="create-todo-message", - ) - yield Button("Create TODO.md", id="create-todo-btn", variant="primary") + with TabbedContent(id="todos-tabs"): + # Project TODOs tab (default, for Claude collaboration) + with TabPane(f"Project ({unchecked_project})", id="tab-project"): + # Create TODO.md section (shown when file doesn't exist) + with Vertical(classes="create-todo-section", id="create-todo-section"): + yield Static( + "No TODO.md found in project", + classes="create-todo-message", + ) + yield Button("Create TODO.md", id="create-todo-btn", variant="primary") - # Project TODOs section (collapsible) - with Collapsible(title="Project TODOs", id="project-todos-section"): - yield ListView(id="project-todos-list") + yield ListView(id="project-todos-list") + yield Static("No project TODOs", classes="empty-message", id="project-empty") - # Code TODOs section - yield Static("Code TODOs", classes="section-header", id="code-todos-header") - yield ListView(id="code-todos-list") - - yield Static("No TODOs found", classes="empty-message", id="todos-empty") + # Code Comment TODOs tab + with TabPane(f"Comments ({code_count})", id="tab-comments"): + yield ListView(id="code-todos-list") + yield Static("No code TODOs found ✓", classes="empty-message", id="code-empty") def on_mount(self) -> None: """Initialize the lists with items.""" @@ -222,11 +232,10 @@ class TodosView(Vertical): """Refresh both list views with current items.""" try: create_section = self.query_one("#create-todo-section", Vertical) - project_section = self.query_one("#project-todos-section", Collapsible) project_list = self.query_one("#project-todos-list", ListView) - code_header = self.query_one("#code-todos-header", Static) + project_empty = self.query_one("#project-empty", Static) code_list = self.query_one("#code-todos-list", ListView) - empty_msg = self.query_one("#todos-empty", Static) + code_empty = self.query_one("#code-empty", Static) # Clear both lists project_list.clear() @@ -235,54 +244,77 @@ class TodosView(Vertical): # Check if TODO.md exists self._has_todo_md = (self._project_path / "TODO.md").exists() - has_items = False - - # Show create button if no TODO.md and no project items + # Project TODOs tab if not self._has_todo_md and not self._project_items: + # Show create button create_section.display = True - project_section.display = False + project_list.display = False + project_empty.display = False else: create_section.display = False - # Populate project TODOs if self._project_items: - has_items = True - # Group by section - sections: dict[str, list[ProjectTodoItem]] = {} + # Add unchecked items for item in self._project_items: - if item.section not in sections: - sections[item.section] = [] - sections[item.section].append(item) - - # Add items (flat list, grouped display can be added later) - for item in self._project_items: - if not item.checked: # Only show unchecked by default + if not item.checked: project_list.append(ProjectTodoListItem(item)) unchecked = sum(1 for i in self._project_items if not i.checked) - project_section.title = f"Project TODOs ({unchecked})" - project_section.display = True + project_list.display = unchecked > 0 + project_empty.display = unchecked == 0 else: - project_section.display = False + project_list.display = False + project_empty.display = True - # Populate code TODOs + # Code TODOs tab if self._items: - has_items = True for item in self._items: code_list.append(TodoListItem(item)) - code_header.update(f"Code TODOs ({len(self._items)})") - code_header.display = True code_list.display = True + code_empty.display = False else: - code_header.display = False code_list.display = False + code_empty.display = True - # Show empty message only if no items at all and TODO.md exists - empty_msg.display = not has_items and self._has_todo_md + # Update tab labels with counts + self._update_tab_labels() except Exception: pass + def _update_tab_labels(self) -> None: + """Update tab labels with current counts.""" + try: + tabs = self.query_one("#todos-tabs", TabbedContent) + + unchecked_project = sum(1 for i in self._project_items if not i.checked) + code_count = len(self._items) + + # Update tab labels via the Tabs widget + # Tab IDs include the pane ID, e.g., "--content-tab-tab-project" + for tab in tabs.query("Tab"): + tab_id = str(tab.id) if tab.id else "" + if "tab-project" in tab_id: + tab.label = f"Project ({unchecked_project})" + elif "tab-comments" in tab_id: + tab.label = f"Comments ({code_count})" + except Exception: + pass + + def update_tab_counts(self, project_count: int, code_count: int) -> None: + """Update tab labels with provided counts (called from ContextPanel).""" + try: + tabs = self.query_one("#todos-tabs", TabbedContent) + + for tab in tabs.query("Tab"): + tab_id = str(tab.id) if tab.id else "" + if "tab-project" in tab_id: + tab.label = f"Project ({project_count})" + elif "tab-comments" in tab_id: + tab.label = f"Comments ({code_count})" + except Exception: + pass + def update_items( self, items: list[TodoItem], @@ -292,15 +324,6 @@ class TodosView(Vertical): self._items = items self._project_items = project_items or [] - # Update header with total count - try: - unchecked_project = sum(1 for p in self._project_items if not p.checked) - total = len(items) + unchecked_project - header = self.query_one("#todos-header", Static) - header.update(f"TODOs ({total})") - except Exception: - pass - # Refresh the lists self._refresh_lists() diff --git a/clide/widgets/panels/context.py b/clide/widgets/panels/context.py index 7187e0b5..df98f6cc 100644 --- a/clide/widgets/panels/context.py +++ b/clide/widgets/panels/context.py @@ -3,10 +3,10 @@ from pathlib import Path from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical +from textual.containers import Vertical from textual.message import Message from textual.reactive import reactive -from textual.widgets import Static, TabbedContent, TabPane +from textual.widgets import TabbedContent, TabPane from clide.models.problems import Problem from clide.models.todos import ProjectTodoItem, TodoItem @@ -30,11 +30,9 @@ class ContextPanel(Vertical): height: 1fr; } - ContextPanel .context-tab-bar { - dock: bottom; - height: 1; - background: $panel; - padding: 0 1; + ContextPanel TabPane { + height: 1fr; + padding: 0; } ContextPanel .tab-count { @@ -90,6 +88,8 @@ class ContextPanel(Vertical): # Reactive state with counts for tab badges problem_count: reactive[int] = reactive(0) todo_count: reactive[int] = reactive(0) + project_todo_count: reactive[int] = reactive(0) + code_todo_count: reactive[int] = reactive(0) visible: reactive[bool] = reactive(True) def __init__( @@ -107,17 +107,14 @@ class ContextPanel(Vertical): with TabbedContent(id="context-tabs"): with TabPane("Jira", id="context-jira"): yield JiraView(enabled=self._jira_enabled, id="jira-view") - with TabPane("TODOs", id="context-todos"): + with TabPane("TODOs (0)", id="context-todos"): yield TodosView(project_path=self._project_path, id="todos-view") - with TabPane("Problems", id="context-problems"): + with TabPane("Problems (0)", id="context-problems"): yield ProblemsView(id="problems-view") - # Tab bar with counts at bottom - with Horizontal(classes="context-tab-bar"): - yield Static("", id="tab-counts") def on_mount(self) -> None: """Initialize tab counts.""" - self._update_tab_counts() + self._update_tab_headers() def watch_visible(self, visible: bool) -> None: """Handle visibility changes.""" @@ -125,29 +122,32 @@ class ContextPanel(Vertical): def watch_problem_count(self, count: int) -> None: """Update problem count display.""" - self._update_tab_counts() + self._update_tab_headers() def watch_todo_count(self, count: int) -> None: """Update todo count display.""" - self._update_tab_counts() + self._update_tab_headers() - def _update_tab_counts(self) -> None: - """Update the tab counts display.""" + def _update_tab_headers(self) -> None: + """Update the tab headers with counts.""" try: - counts = self.query_one("#tab-counts", Static) - problem_style = "error-count" if self.problem_count > 0 else "success-count" - todo_style = "warning-count" if self.todo_count > 0 else "success-count" + tabs = self.query_one("#context-tabs", TabbedContent) - # Build count display (order matches tab order: TODOs, Problems) - parts = [] - parts.append(f"[{todo_style}]☐ {self.todo_count}[/]") + # Update tab labels via the Tabs widget + for tab in tabs.query("Tab"): + tab_id = str(tab.id) if tab.id else "" + if "context-todos" in tab_id: + tab.label = f"TODOs ({self.todo_count})" + elif "context-problems" in tab_id: + tab.label = f"Problems ({self.problem_count})" + except Exception: + pass - if self.problem_count > 0: - parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]") - else: - parts.append(f"[{problem_style}]✓ 0[/]") - - counts.update(" │ ".join(parts)) + def _update_todos_subtabs(self) -> None: + """Update the TODOs view sub-tab labels.""" + try: + view = self.query_one("#todos-view", TodosView) + view.update_tab_counts(self.project_todo_count, self.code_todo_count) except Exception: pass @@ -169,10 +169,14 @@ class ContextPanel(Vertical): project_items = project_items or [] # Count includes both code TODOs and unchecked project TODOs unchecked_project = sum(1 for p in project_items if not p.checked) - self.todo_count = len(items) + unchecked_project + self.project_todo_count = unchecked_project + self.code_todo_count = len(items) + self.todo_count = unchecked_project + len(items) try: view = self.query_one("#todos-view", TodosView) view.update_items(items, project_items) + # Update sub-tab counts + self._update_todos_subtabs() except Exception: pass diff --git a/clide/widgets/panels/sidebar.py b/clide/widgets/panels/sidebar.py index c1947b34..5ef30591 100644 --- a/clide/widgets/panels/sidebar.py +++ b/clide/widgets/panels/sidebar.py @@ -33,6 +33,15 @@ class SidebarPanel(Vertical): height: 1fr; } + SidebarPanel TabbedContent { + height: 1fr; + } + + SidebarPanel TabPane { + height: 1fr; + padding: 0; + } + SidebarPanel BranchStatus { dock: bottom; height: auto; @@ -61,6 +70,13 @@ class SidebarPanel(Vertical): self.branch = branch super().__init__() + class ClaudeCommandRequested(Message): + """Emitted when a Claude command is requested (e.g., /commit).""" + + def __init__(self, command: str) -> None: + self.command = command + super().__init__() + # Reactive state current_branch: reactive[str] = reactive("main") visible: reactive[bool] = reactive(True) @@ -101,18 +117,25 @@ class SidebarPanel(Vertical): staged: list, unstaged: list, ) -> None: - """Update git changes view.""" + """Update git changes view and branch stats.""" try: git_view = self.query_one(GitChangesView) git_view.update_changes(staged, unstaged) except Exception: pass + # Update branch status with staged/unstaged counts + try: + branch_status = self.query_one(BranchStatus) + branch_status.update_stats(len(staged), len(unstaged)) + except Exception: + pass + def update_git_graph(self, commits: list) -> None: """Update git graph view.""" try: graph = self.query_one(GitGraphView) - graph.update_graph(commits) + graph.update_commits(commits) except Exception: pass @@ -157,7 +180,7 @@ class SidebarPanel(Vertical): event: GitChangesView.FileClicked, ) -> None: """Forward git file selection.""" - self.post_message(self.GitFileSelected(event.path, event.staged)) + self.post_message(self.GitFileSelected(Path(event.change.path), event.change.staged)) def on_branch_status_branch_changed( self, @@ -166,3 +189,12 @@ class SidebarPanel(Vertical): """Forward branch change.""" self.current_branch = event.branch self.post_message(self.BranchChanged(event.branch)) + + def on_git_changes_view_claude_action_requested( + self, + event: GitChangesView.ClaudeActionRequested, + ) -> None: + """Forward Claude action request (commit, stash, pull, push).""" + # Convert action to Claude skill command + command = f"/{event.action}" + self.post_message(self.ClaudeCommandRequested(command))