refactor(widgets): add TileList component and improve panel widgets

Add reusable TileListView component for consistent card-style lists.
Refactor sidebar and context panels to use improved git integration,
enhanced branch status display, and better component organization.

Key changes:
- New TileListView/TileItem for reusable styled lists
- Enhanced git controller with graph traversal
- Improved branch status with remote tracking info
- Better file watcher integration

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-02-01 21:22:00 +01:00
co-authored by Claude
parent f9d37e3e7e
commit 6293b93d98
16 changed files with 842 additions and 300 deletions
+85 -10
View File
@@ -3,11 +3,13 @@
from pathlib import Path from pathlib import Path
from typing import ClassVar from typing import ClassVar
from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.reactive import reactive from textual.reactive import reactive
from textual.widgets import Footer, Header from textual.widgets import Footer, Header
from textual.worker import Worker, WorkerState
from clide.controllers.diff import DiffController from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController from clide.controllers.editor import EditorController
@@ -113,6 +115,32 @@ class ClideApp(App[None]):
height: 100%; height: 100%;
layer: fullscreen; 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 # Keybindings
@@ -182,8 +210,10 @@ class ClideApp(App[None]):
self.diff_controller = DiffController(self.workdir) self.diff_controller = DiffController(self.workdir)
self.problems_controller = ProblemsController(self.workdir) self.problems_controller = ProblemsController(self.workdir)
self.todos_controller = TodosController(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( self.jira_controller = JiraController(
enabled=self._user_settings.jira_enabled, enabled=jira_enabled,
) )
# Register themes # Register themes
@@ -199,8 +229,12 @@ class ClideApp(App[None]):
if theme_def: if theme_def:
self.register_theme(theme_def.to_textual_theme()) self.register_theme(theme_def.to_textual_theme())
# Set initial theme from user settings # Set initial theme: settings parameter takes precedence over user settings
self.theme = self._user_settings.theme # 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: def set_theme(self, theme_name: str, *, save: bool = True) -> None:
"""Set the application theme. """Set the application theme.
@@ -309,9 +343,21 @@ class ClideApp(App[None]):
""" """
event = message.event event = message.event
# Ignore files in .clide directory (settings, etc.)
if ".clide" in str(event.path):
return
# Trigger extension hooks # Trigger extension hooks
self.extension_manager.trigger_file_changed(event) 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 # Refresh file tree for created/deleted/moved files
if event.event_type in ("created", "deleted", "moved"): if event.event_type in ("created", "deleted", "moved"):
try: try:
@@ -778,14 +824,43 @@ class ClideApp(App[None]):
"""Handle Claude command request from git panel. """Handle Claude command request from git panel.
Sends skill commands (e.g., /commit) to Claude terminal. 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 from clide.services.skill_installer import get_skill_installer
self.git_controller._ensure_git_skill()
# 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 = self.query_one(ClaudePanel)
claude.send_input(event.command) claude.send_input(command)
# Focus Claude panel so user can see the response
self.action_focus_claude() self.action_focus_claude()
+104 -2
View File
@@ -1,13 +1,23 @@
"""Git controller for managing git operations.""" """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 textual.message import Message
from clide.controllers.base import controller from clide.controllers.base import controller
from clide.models.git import GitBranch, GitCommit, GitStatus
from clide.services.git_service import GitService 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 @controller
class GitController: class GitController:
@@ -199,3 +209,95 @@ class GitController:
Diff string Diff string
""" """
return await self._service.get_diff(path, staged) 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}"
+3
View File
@@ -4,6 +4,7 @@ from clide.services.git_service import GitService
from clide.services.linter_service import LinterService from clide.services.linter_service import LinterService
from clide.services.process_service import ProcessService from clide.services.process_service import ProcessService
from clide.services.settings_service import SettingsService, UserSettings, get_settings_service 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 from clide.services.todo_scanner import TodoScanner
__all__ = [ __all__ = [
@@ -11,7 +12,9 @@ __all__ = [
"LinterService", "LinterService",
"ProcessService", "ProcessService",
"SettingsService", "SettingsService",
"SkillInstaller",
"TodoScanner", "TodoScanner",
"UserSettings", "UserSettings",
"get_settings_service", "get_settings_service",
"get_skill_installer",
] ]
+21 -20
View File
@@ -6,27 +6,32 @@ enabling reactive updates when files change on disk.
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path 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 pydantic import BaseModel, ConfigDict
from textual.message import Message from textual.message import Message
try: try:
from watchdog.observers import Observer as WatchdogObserver from watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
)
from watchdog.events import ( from watchdog.events import (
FileSystemEventHandler as WatchdogHandler, FileSystemEventHandler as WatchdogHandler,
FileCreatedEvent,
FileModifiedEvent,
FileDeletedEvent,
FileMovedEvent,
DirCreatedEvent,
DirModifiedEvent,
DirDeletedEvent,
DirMovedEvent,
) )
from watchdog.observers import Observer as WatchdogObserver
WATCHDOG_AVAILABLE = True WATCHDOG_AVAILABLE = True
except ImportError: except ImportError:
WATCHDOG_AVAILABLE = False WATCHDOG_AVAILABLE = False
@@ -103,6 +108,8 @@ class FileWatcher:
".ruff_cache", ".ruff_cache",
".pytest_cache", ".pytest_cache",
"*.egg-info", "*.egg-info",
".clide",
".claude",
] ]
self._handlers: list[Callable[[FileEvent], None]] = [] self._handlers: list[Callable[[FileEvent], None]] = []
self._observer: WatchdogObserver | None = None # type: ignore[valid-type] 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] def on_created(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event( file_event = self._create_event(event.src_path, "created", event.is_directory)
event.src_path, "created", event.is_directory
)
self._watcher._emit_event(file_event) self._watcher._emit_event(file_event)
def on_modified(self, event) -> None: # type: ignore[no-untyped-def] def on_modified(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event( file_event = self._create_event(event.src_path, "modified", event.is_directory)
event.src_path, "modified", event.is_directory
)
self._watcher._emit_event(file_event) self._watcher._emit_event(file_event)
def on_deleted(self, event) -> None: # type: ignore[no-untyped-def] def on_deleted(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event( file_event = self._create_event(event.src_path, "deleted", event.is_directory)
event.src_path, "deleted", event.is_directory
)
self._watcher._emit_event(file_event) self._watcher._emit_event(file_event)
def on_moved(self, event) -> None: # type: ignore[no-untyped-def] def on_moved(self, event) -> None: # type: ignore[no-untyped-def]
+53 -36
View File
@@ -44,25 +44,35 @@ class GitService:
# Parse status # Parse status
if index_status == "?": if index_status == "?":
# Untracked files go in both untracked list and unstaged
untracked.append(path) untracked.append(path)
unstaged.append(
GitChange(
path=path,
status=ChangeStatus.UNTRACKED,
staged=False,
)
)
else: else:
if index_status != " ": if index_status != " ":
staged.append(GitChange( staged.append(
path=path, GitChange(
status=self._parse_status(index_status), path=path,
staged=True, status=self._parse_status(index_status),
)) staged=True,
)
)
if worktree_status != " ": if worktree_status != " ":
unstaged.append(GitChange( unstaged.append(
path=path, GitChange(
status=self._parse_status(worktree_status), path=path,
staged=False, status=self._parse_status(worktree_status),
)) staged=False,
)
)
# Get current branch # Get current branch
branch_result = await self._process.run( branch_result = await self._process.run("git", "branch", "--show-current")
"git", "branch", "--show-current"
)
branch = branch_result.stdout.strip() if branch_result.success else "HEAD" branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
# Get ahead/behind # Get ahead/behind
@@ -80,8 +90,7 @@ class GitService:
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]: async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
"""Get commits ahead/behind upstream.""" """Get commits ahead/behind upstream."""
result = await self._process.run( result = await self._process.run(
"git", "rev-list", "--left-right", "--count", "git", "rev-list", "--left-right", "--count", f"{branch}...@{{upstream}}"
f"{branch}...@{{upstream}}"
) )
if result.success: if result.success:
parts = result.stdout.strip().split() parts = result.stdout.strip().split()
@@ -146,8 +155,11 @@ class GitService:
List of GitBranch objects List of GitBranch objects
""" """
result = await self._process.run( result = await self._process.run(
"git", "branch", "-a", "--format", "git",
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)" "branch",
"-a",
"--format",
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)",
) )
branches: list[GitBranch] = [] branches: list[GitBranch] = []
@@ -159,14 +171,16 @@ class GitService:
parts = line[1:].split("|") parts = line[1:].split("|")
if len(parts) >= 4: if len(parts) >= 4:
name = parts[0].strip() name = parts[0].strip()
branches.append(GitBranch( branches.append(
name=name, GitBranch(
is_current=is_current, name=name,
is_remote=name.startswith("remotes/"), is_current=is_current,
tracking=parts[1] or None, is_remote=name.startswith("remotes/"),
commit_hash=parts[2], tracking=parts[1] or None,
commit_message=parts[3], commit_hash=parts[2],
)) commit_message=parts[3],
)
)
return branches return branches
@@ -227,7 +241,8 @@ class GitService:
List of GitCommit objects List of GitCommit objects
""" """
result = await self._process.run( result = await self._process.run(
"git", "log", "git",
"log",
f"--max-count={max_count}", f"--max-count={max_count}",
"--format=%H|%h|%s|%an|%ar|%P|%D", "--format=%H|%h|%s|%an|%ar|%P|%D",
"--all", "--all",
@@ -242,15 +257,17 @@ class GitService:
if len(parts) >= 7: if len(parts) >= 7:
parents = tuple(parts[5].split()) if parts[5] else () parents = tuple(parts[5].split()) if parts[5] else ()
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else () refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
commits.append(GitCommit( commits.append(
hash=parts[0], GitCommit(
short_hash=parts[1], hash=parts[0],
message=parts[2], short_hash=parts[1],
author=parts[3], message=parts[2],
date=parts[4], author=parts[3],
is_merge=len(parents) > 1, date=parts[4],
parents=parents, is_merge=len(parents) > 1,
refs=refs, parents=parents,
)) refs=refs,
)
)
return commits return commits
+3
View File
@@ -9,6 +9,7 @@ from clide.widgets.components.git_graph import GitGraphView
from clide.widgets.components.jira_view import JiraView from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.terminal_pane import TerminalPane 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 from clide.widgets.components.todos_view import TodosView
__all__ = [ __all__ = [
@@ -21,5 +22,7 @@ __all__ = [
"JiraView", "JiraView",
"ProblemsView", "ProblemsView",
"TerminalPane", "TerminalPane",
"TileItem",
"TileListView",
"TodosView", "TodosView",
] ]
+60 -3
View File
@@ -28,7 +28,27 @@ class BranchStatus(Vertical):
} }
BranchStatus .branch-name { BranchStatus .branch-name {
width: auto;
min-width: 12;
}
BranchStatus .toggle-icon {
width: auto;
margin-right: 1;
}
BranchStatus .git-stats {
width: 1fr; width: 1fr;
text-align: right;
color: $text-muted;
}
BranchStatus .staged-count {
color: $success;
}
BranchStatus .unstaged-count {
color: $warning;
} }
BranchStatus .popout { BranchStatus .popout {
@@ -50,10 +70,22 @@ class BranchStatus(Vertical):
text-style: bold; text-style: bold;
} }
BranchStatus #branch-list {
height: auto;
max-height: 8;
}
BranchStatus .popout-actions { BranchStatus .popout-actions {
height: 1; height: auto;
padding: 0 1; padding: 0 1;
} }
BranchStatus .popout-actions Button {
width: 1fr;
min-width: 10;
height: 3;
margin: 0 1;
}
""" """
class BranchChanged(Message): class BranchChanged(Message):
@@ -68,24 +100,30 @@ class BranchStatus(Vertical):
class NewBranchRequested(Message): class NewBranchRequested(Message):
"""Emitted when new branch creation is requested.""" """Emitted when new branch creation is requested."""
pass pass
def __init__( def __init__(
self, self,
current_branch: str = "main", current_branch: str = "main",
branches: list[GitBranch] | None = None, branches: list[GitBranch] | None = None,
staged: int = 0,
unstaged: int = 0,
**kwargs, **kwargs,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._current = current_branch self._current = current_branch
self._branches = branches or [] self._branches = branches or []
self._popout_visible = False self._popout_visible = False
self._staged = staged
self._unstaged = unstaged
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(classes="status-bar"): with Horizontal(classes="status-bar"):
yield Static("⎇", classes="branch-icon") yield Static("⎇", classes="branch-icon")
yield Static(self._current, classes="branch-name", id="branch-name") yield Static(self._current, classes="branch-name", id="branch-name")
yield Static("▾", classes="toggle-icon") yield Static("▾", classes="toggle-icon")
yield Static(self._format_stats(), classes="git-stats", id="git-stats")
with Vertical(classes="popout", id="branch-popout"): with Vertical(classes="popout", id="branch-popout"):
yield Label("Recent branches", classes="popout-header") yield Label("Recent branches", classes="popout-header")
@@ -115,6 +153,25 @@ class BranchStatus(Vertical):
"""Update current branch display.""" """Update current branch display."""
self.branch = branch 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: def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
"""Update branches list.""" """Update branches list."""
self._branches = branches # type: ignore self._branches = branches # type: ignore
@@ -146,14 +203,14 @@ class BranchStatus(Vertical):
"""Handle click on status bar.""" """Handle click on status bar."""
self.toggle_popout() self.toggle_popout()
def on_button_pressed(self, _event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""
if event.button.id == "btn-checkout": if event.button.id == "btn-checkout":
branch_list = self.query_one("#branch-list", ListView) branch_list = self.query_one("#branch-list", ListView)
if branch_list.highlighted_child: if branch_list.highlighted_child:
# Get selected branch name # Get selected branch name
label = branch_list.highlighted_child.query_one(Label) 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.post_message(self.BranchChangeRequested(branch))
self.toggle_popout() self.toggle_popout()
elif event.button.id == "btn-new": elif event.button.id == "btn-new":
+31 -17
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from rich.text import Text from rich.text import Text
@@ -10,6 +9,8 @@ from textual.message import Message
from textual.widgets import DirectoryTree from textual.widgets import DirectoryTree
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from rich.style import Style from rich.style import Style
from textual.widgets._directory_tree import DirEntry from textual.widgets._directory_tree import DirEntry
from textual.widgets._tree import TreeNode from textual.widgets._tree import TreeNode
@@ -55,32 +56,47 @@ class FilesView(DirectoryTree):
classes=classes, classes=classes,
) )
def render_label( def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style) -> Text:
self, node: TreeNode[DirEntry], base_style: Style, style: Style
) -> Text:
"""Render a label with minimal Unicode icons.""" """Render a label with minimal Unicode icons."""
path = node.data.path path = node.data.path
is_dimmed = path.name.startswith(".") or path.name in self.DIMMED_PATHS
if path.is_dir(): if path.is_dir():
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED 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: else:
icon = ICON_FILE icon = ICON_FILE
icon_style = "dim" icon_style = "dim"
label = Text() label = Text()
label.append(f"{icon} ", style=icon_style) 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 return label
def filter_paths(self, paths: list[Path]) -> list[Path]: # Directories that clutter the tree and are never needed in the IDE
"""Filter out hidden and ignored paths.""" HIDDEN_DIRS = {
return [ "__pycache__",
p for p in paths "node_modules",
if not p.name.startswith(".") ".git",
and p.name not in ("__pycache__", "node_modules", ".git", ".venv", "venv") ".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: def refresh_tree(self) -> None:
"""Refresh the directory tree.""" """Refresh the directory tree."""
@@ -100,7 +116,7 @@ class FilesView(DirectoryTree):
# Find and select the node # Find and select the node
def find_node(node, target_path): def find_node(node, target_path):
"""Recursively find a node by 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: if node.data.path.resolve() == target_path:
return node return node
for child in node.children: for child in node.children:
@@ -121,9 +137,7 @@ class FilesView(DirectoryTree):
self.select_node(target_node) self.select_node(target_node)
self.scroll_to_node(target_node) self.scroll_to_node(target_node)
def on_directory_tree_file_selected( def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
self, event: DirectoryTree.FileSelected
) -> None:
"""Re-emit file selection as FilesView.FileSelected.""" """Re-emit file selection as FilesView.FileSelected."""
event.stop() event.stop()
self.post_message(self.FileSelected(node=event.node, path=event.path)) self.post_message(self.FileSelected(node=event.node, path=event.path))
+95 -11
View File
@@ -1,9 +1,9 @@
"""Git changes view component.""" """Git changes view component."""
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Vertical from textual.containers import Horizontal, Vertical
from textual.message import Message 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 from clide.models.git import ChangeStatus, GitChange
@@ -22,15 +22,30 @@ class GitChangeItem(ListItem):
ChangeStatus.IGNORED: "I", 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: def __init__(self, change: GitChange) -> None:
super().__init__() super().__init__()
self.change = change self.change = change
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
from rich.markup import escape
icon = self.STATUS_ICONS.get(self.change.status, "?") 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( yield Static(
f"[{status_class}]{icon}[/] {self.change.path}", f"[{color}]{icon}[/] {safe_path}",
markup=True, markup=True,
) )
@@ -40,24 +55,69 @@ class GitChangesView(Vertical):
DEFAULT_CSS = """ DEFAULT_CSS = """
GitChangesView { GitChangesView {
height: 100%; height: 1fr;
background: $background;
} }
GitChangesView .section-header { GitChangesView .section-header {
background: $surface; background: $surface;
padding: 0 1; padding: 0 1;
height: 1;
text-style: bold; text-style: bold;
border-bottom: solid $primary;
} }
GitChangesView ListView { GitChangesView ListView {
height: auto; height: 1fr;
max-height: 50%; min-height: 3;
scrollbar-size: 1 1;
margin-bottom: 1;
} }
GitChangesView .added { color: $success; } GitChangesView #staged-list {
GitChangesView .modified { color: $warning; } background: $panel;
GitChangesView .deleted { color: $error; } }
GitChangesView .untracked { color: $accent; }
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): class FileClicked(Message):
@@ -81,6 +141,13 @@ class GitChangesView(Vertical):
self.path = path self.path = path
super().__init__() 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__( def __init__(
self, self,
staged: list[GitChange] | None = None, staged: list[GitChange] | None = None,
@@ -102,6 +169,11 @@ class GitChangesView(Vertical):
*[GitChangeItem(c) for c in self._unstaged], *[GitChangeItem(c) for c in self._unstaged],
id="unstaged-list", 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( def update_changes(
self, self,
@@ -127,3 +199,15 @@ class GitChangesView(Vertical):
"""Handle item selection.""" """Handle item selection."""
if isinstance(event.item, GitChangeItem): if isinstance(event.item, GitChangeItem):
self.post_message(self.FileClicked(event.item.change)) 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]))
+49 -61
View File
@@ -1,37 +1,48 @@
"""Git graph visualization component.""" """Git graph visualization component."""
from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message from textual.message import Message
from textual.widgets import RichLog from textual.widgets import ListView, Static
from clide.models.git import GitCommit from clide.models.git import GitCommit
from clide.widgets.components.tile_list import TileItem, TileListView
class GitGraphView(Vertical): class CommitItem(TileItem):
"""View for git commit graph visualization.""" """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 = "●" COMMIT = "●"
MERGE = "◆" MERGE = "◆"
LINE = "│"
BRANCH = "├" def __init__(self, commit: GitCommit) -> None:
JOIN = "┴" 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): class CommitSelected(Message):
"""Emitted when a commit is selected.""" """Emitted when a commit is selected."""
@@ -45,46 +56,23 @@ class GitGraphView(Vertical):
self._commits = commits or [] self._commits = commits or []
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield RichLog(id="graph-log", highlight=True, markup=True) yield ListView(
*[CommitItem(c) for c in self._commits],
def on_mount(self) -> None: id="commit-list",
"""Render initial graph.""" )
self._render_graph()
def update_commits(self, commits: list[GitCommit]) -> None: def update_commits(self, commits: list[GitCommit]) -> None:
"""Update the commit list.""" """Update the commit list."""
self._commits = commits 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: def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Render the commit graph.""" """Handle commit selection."""
log = self.query_one("#graph-log", RichLog) if isinstance(event.item, CommitItem):
log.clear() self.post_message(self.CommitSelected(event.item.commit))
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}[/]"
)
+9 -3
View File
@@ -11,35 +11,41 @@ class JiraView(Vertical):
DEFAULT_CSS = """ DEFAULT_CSS = """
JiraView { JiraView {
height: 100%; height: 1fr;
background: $background;
} }
JiraView .jira-header { JiraView .jira-header {
height: 1; height: 1;
background: $surface; background: $surface;
padding: 0 1; padding: 0 1;
border-bottom: solid $primary;
} }
JiraView Markdown { JiraView Markdown {
height: 1fr; height: 1fr;
padding: 1; padding: 1;
background: $background;
} }
JiraView .jira-actions { JiraView .jira-actions {
height: auto; height: auto;
padding: 0 1; padding: 1;
background: $panel; background: $surface;
border-top: solid $primary;
} }
JiraView .disabled-message { JiraView .disabled-message {
padding: 2; padding: 2;
text-align: center; text-align: center;
color: $warning; color: $warning;
background: $panel;
} }
""" """
class RefreshRequested(Message): class RefreshRequested(Message):
"""Emitted when refresh is requested.""" """Emitted when refresh is requested."""
pass pass
class IssueClicked(Message): class IssueClicked(Message):
+18 -13
View File
@@ -2,16 +2,17 @@
from pathlib import Path from pathlib import Path
from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message 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.models.problems import Problem
from clide.widgets.components.tile_list import TileItem, TileListView
class ProblemItem(ListItem): class ProblemItem(TileItem):
"""A single problem item.""" """A single problem item displayed as a tile."""
def __init__(self, problem: Problem) -> None: def __init__(self, problem: Problem) -> None:
super().__init__() super().__init__()
@@ -20,31 +21,35 @@ class ProblemItem(ListItem):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
icon = self.problem.severity_icon icon = self.problem.severity_icon
severity_class = self.problem.severity.value 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( yield Static(
f"[{severity_class}]{icon}[/] " f"[{severity_class}]{icon}[/] [{severity_class}]{safe_message}[/]\n"
f"[dim]{self.problem.file_path}:{self.problem.line}[/] " f" [dim]{filename}:{self.problem.line}[/]",
f"{self.problem.message}",
markup=True, markup=True,
) )
class ProblemsView(Vertical): class ProblemsView(TileListView):
"""View for linter problems/diagnostics.""" """View for linter problems/diagnostics."""
DEFAULT_CSS = """ DEFAULT_CSS = """
ProblemsView { ProblemsView {
height: 100%; height: 1fr;
background: $background;
} }
ProblemsView .problems-header { ProblemsView .problems-header {
height: 1; height: 1;
background: $surface; background: $surface;
padding: 0 1; padding: 0 1;
} border-bottom: solid $primary;
ProblemsView ListView {
height: 1fr;
} }
ProblemsView .error { color: $error; } ProblemsView .error { color: $error; }
+128
View File
@@ -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%;
}
"""
+114 -91
View File
@@ -1,13 +1,15 @@
"""TODOs view component.""" """TODOs view component with sub-tabs for Project and Comment TODOs."""
from pathlib import Path from pathlib import Path
from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Vertical from textual.containers import Vertical
from textual.message import Message 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.models.todos import ProjectTodoItem, TodoItem, TodoType
from clide.widgets.components.tile_list import TileItem, TileListView
# Template for new TODO.md files # Template for new TODO.md files
TODO_MD_TEMPLATE = """# TODO TODO_MD_TEMPLATE = """# TODO
@@ -42,8 +44,8 @@ Project TODO items.
""" """
class TodoListItem(ListItem): class TodoListItem(TileItem):
"""A single code TODO item.""" """A single code TODO item displayed as a tile."""
def __init__(self, item: TodoItem) -> None: def __init__(self, item: TodoItem) -> None:
super().__init__() super().__init__()
@@ -53,17 +55,23 @@ class TodoListItem(ListItem):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
icon = self.item.type_icon icon = self.item.type_icon
type_class = self.item.todo_type.value.lower() 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( yield Static(
f"[{type_class}]{icon} {self.item.todo_type.value}[/] " f"[{type_class}]{icon}[/] {safe_text}\n" f" [dim]{filename}:{self.item.line}[/]",
f"[dim]{self.item.file_path}:{self.item.line}[/] "
f"{self.item.text}",
markup=True, markup=True,
) )
class ProjectTodoListItem(ListItem): class ProjectTodoListItem(TileItem):
"""A single project TODO item from TODO.md.""" """A single project TODO item from TODO.md displayed as a tile."""
def __init__(self, item: ProjectTodoItem) -> None: def __init__(self, item: ProjectTodoItem) -> None:
super().__init__() super().__init__()
@@ -72,54 +80,54 @@ class ProjectTodoListItem(ListItem):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
icon = self.item.icon icon = self.item.icon
checked_style = "dim strike" if self.item.checked else "" category = f"[dim]{escape(self.item.category)}[/] " if self.item.subsection else ""
category = f"[dim]{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( yield Static(
f"[project]{icon}[/] {category}" f"[{checked_style}]{self.item.text}[/]", f"[project]{icon}[/] {category}{text_part}",
markup=True, markup=True,
) )
class TodosView(Vertical): class TodosView(TileListView):
"""View for TODO/FIXME comments and project TODOs.""" """View for TODO/FIXME comments and project TODOs with sub-tabs."""
DEFAULT_CSS = """ DEFAULT_CSS = """
TodosView { TodosView {
height: 100%; height: 1fr;
background: $background;
} }
TodosView .todos-header { TodosView TabbedContent {
height: 1; height: 1fr;
background: $surface;
padding: 0 1;
} }
TodosView .section-header { TodosView Tabs {
height: 1; width: 100%;
background: $panel;
padding: 0 1;
color: $text-muted;
} }
TodosView ListView { TodosView Tab {
height: auto; width: 1fr;
max-height: 50%;
} }
TodosView TabPane {
height: 1fr;
padding: 0;
}
TodosView ContentSwitcher {
height: 1fr;
}
TodosView #project-todos-list,
TodosView #code-todos-list { TodosView #code-todos-list {
height: 1fr; height: 1fr;
max-height: none;
} }
TodosView Collapsible { TodosView ListItem {
padding: 0; padding: 1 1;
border: none;
}
TodosView CollapsibleTitle {
background: $panel;
padding: 0 1;
} }
TodosView .todo { color: $primary; } TodosView .todo { color: $primary; }
@@ -133,7 +141,7 @@ class TodosView(Vertical):
TodosView .empty-message { TodosView .empty-message {
padding: 2; padding: 2;
text-align: center; text-align: center;
color: $success; color: $text-muted;
} }
TodosView .create-todo-section { TodosView .create-todo-section {
@@ -193,26 +201,28 @@ class TodosView(Vertical):
self._has_todo_md = (self._project_path / "TODO.md").exists() self._has_todo_md = (self._project_path / "TODO.md").exists()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
total = len(self._items) + len(self._project_items) # Calculate initial counts
yield Static(f"TODOs ({total})", classes="todos-header", id="todos-header") 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 TabbedContent(id="todos-tabs"):
with Vertical(classes="create-todo-section", id="create-todo-section"): # Project TODOs tab (default, for Claude collaboration)
yield Static( with TabPane(f"Project ({unchecked_project})", id="tab-project"):
"No TODO.md found in project", # Create TODO.md section (shown when file doesn't exist)
classes="create-todo-message", with Vertical(classes="create-todo-section", id="create-todo-section"):
) yield Static(
yield Button("Create TODO.md", id="create-todo-btn", variant="primary") "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) yield ListView(id="project-todos-list")
with Collapsible(title="Project TODOs", id="project-todos-section"): yield Static("No project TODOs", classes="empty-message", id="project-empty")
yield ListView(id="project-todos-list")
# Code TODOs section # Code Comment TODOs tab
yield Static("Code TODOs", classes="section-header", id="code-todos-header") with TabPane(f"Comments ({code_count})", id="tab-comments"):
yield ListView(id="code-todos-list") yield ListView(id="code-todos-list")
yield Static("No code TODOs found ✓", classes="empty-message", id="code-empty")
yield Static("No TODOs found", classes="empty-message", id="todos-empty")
def on_mount(self) -> None: def on_mount(self) -> None:
"""Initialize the lists with items.""" """Initialize the lists with items."""
@@ -222,11 +232,10 @@ class TodosView(Vertical):
"""Refresh both list views with current items.""" """Refresh both list views with current items."""
try: try:
create_section = self.query_one("#create-todo-section", Vertical) 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) 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) 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 # Clear both lists
project_list.clear() project_list.clear()
@@ -235,54 +244,77 @@ class TodosView(Vertical):
# Check if TODO.md exists # Check if TODO.md exists
self._has_todo_md = (self._project_path / "TODO.md").exists() self._has_todo_md = (self._project_path / "TODO.md").exists()
has_items = False # Project TODOs tab
# Show create button if no TODO.md and no project items
if not self._has_todo_md and not self._project_items: if not self._has_todo_md and not self._project_items:
# Show create button
create_section.display = True create_section.display = True
project_section.display = False project_list.display = False
project_empty.display = False
else: else:
create_section.display = False create_section.display = False
# Populate project TODOs
if self._project_items: if self._project_items:
has_items = True # Add unchecked items
# Group by section
sections: dict[str, list[ProjectTodoItem]] = {}
for item in self._project_items: for item in self._project_items:
if item.section not in sections: if not item.checked:
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
project_list.append(ProjectTodoListItem(item)) project_list.append(ProjectTodoListItem(item))
unchecked = sum(1 for i in self._project_items if not i.checked) unchecked = sum(1 for i in self._project_items if not i.checked)
project_section.title = f"Project TODOs ({unchecked})" project_list.display = unchecked > 0
project_section.display = True project_empty.display = unchecked == 0
else: else:
project_section.display = False project_list.display = False
project_empty.display = True
# Populate code TODOs # Code TODOs tab
if self._items: if self._items:
has_items = True
for item in self._items: for item in self._items:
code_list.append(TodoListItem(item)) code_list.append(TodoListItem(item))
code_header.update(f"Code TODOs ({len(self._items)})")
code_header.display = True
code_list.display = True code_list.display = True
code_empty.display = False
else: else:
code_header.display = False
code_list.display = False code_list.display = False
code_empty.display = True
# Show empty message only if no items at all and TODO.md exists # Update tab labels with counts
empty_msg.display = not has_items and self._has_todo_md self._update_tab_labels()
except Exception: except Exception:
pass 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( def update_items(
self, self,
items: list[TodoItem], items: list[TodoItem],
@@ -292,15 +324,6 @@ class TodosView(Vertical):
self._items = items self._items = items
self._project_items = project_items or [] 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 # Refresh the lists
self._refresh_lists() self._refresh_lists()
+34 -30
View File
@@ -3,10 +3,10 @@
from pathlib import Path from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Vertical
from textual.message import Message from textual.message import Message
from textual.reactive import reactive 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.problems import Problem
from clide.models.todos import ProjectTodoItem, TodoItem from clide.models.todos import ProjectTodoItem, TodoItem
@@ -30,11 +30,9 @@ class ContextPanel(Vertical):
height: 1fr; height: 1fr;
} }
ContextPanel .context-tab-bar { ContextPanel TabPane {
dock: bottom; height: 1fr;
height: 1; padding: 0;
background: $panel;
padding: 0 1;
} }
ContextPanel .tab-count { ContextPanel .tab-count {
@@ -90,6 +88,8 @@ class ContextPanel(Vertical):
# Reactive state with counts for tab badges # Reactive state with counts for tab badges
problem_count: reactive[int] = reactive(0) problem_count: reactive[int] = reactive(0)
todo_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) visible: reactive[bool] = reactive(True)
def __init__( def __init__(
@@ -107,17 +107,14 @@ class ContextPanel(Vertical):
with TabbedContent(id="context-tabs"): with TabbedContent(id="context-tabs"):
with TabPane("Jira", id="context-jira"): with TabPane("Jira", id="context-jira"):
yield JiraView(enabled=self._jira_enabled, id="jira-view") 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") 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") 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: def on_mount(self) -> None:
"""Initialize tab counts.""" """Initialize tab counts."""
self._update_tab_counts() self._update_tab_headers()
def watch_visible(self, visible: bool) -> None: def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes.""" """Handle visibility changes."""
@@ -125,29 +122,32 @@ class ContextPanel(Vertical):
def watch_problem_count(self, count: int) -> None: def watch_problem_count(self, count: int) -> None:
"""Update problem count display.""" """Update problem count display."""
self._update_tab_counts() self._update_tab_headers()
def watch_todo_count(self, count: int) -> None: def watch_todo_count(self, count: int) -> None:
"""Update todo count display.""" """Update todo count display."""
self._update_tab_counts() self._update_tab_headers()
def _update_tab_counts(self) -> None: def _update_tab_headers(self) -> None:
"""Update the tab counts display.""" """Update the tab headers with counts."""
try: try:
counts = self.query_one("#tab-counts", Static) tabs = self.query_one("#context-tabs", TabbedContent)
problem_style = "error-count" if self.problem_count > 0 else "success-count"
todo_style = "warning-count" if self.todo_count > 0 else "success-count"
# Build count display (order matches tab order: TODOs, Problems) # Update tab labels via the Tabs widget
parts = [] for tab in tabs.query("Tab"):
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]") 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: def _update_todos_subtabs(self) -> None:
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]") """Update the TODOs view sub-tab labels."""
else: try:
parts.append(f"[{problem_style}]✓ 0[/]") view = self.query_one("#todos-view", TodosView)
view.update_tab_counts(self.project_todo_count, self.code_todo_count)
counts.update(" │ ".join(parts))
except Exception: except Exception:
pass pass
@@ -169,10 +169,14 @@ class ContextPanel(Vertical):
project_items = project_items or [] project_items = project_items or []
# Count includes both code TODOs and unchecked project TODOs # Count includes both code TODOs and unchecked project TODOs
unchecked_project = sum(1 for p in project_items if not p.checked) 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: try:
view = self.query_one("#todos-view", TodosView) view = self.query_one("#todos-view", TodosView)
view.update_items(items, project_items) view.update_items(items, project_items)
# Update sub-tab counts
self._update_todos_subtabs()
except Exception: except Exception:
pass pass
+35 -3
View File
@@ -33,6 +33,15 @@ class SidebarPanel(Vertical):
height: 1fr; height: 1fr;
} }
SidebarPanel TabbedContent {
height: 1fr;
}
SidebarPanel TabPane {
height: 1fr;
padding: 0;
}
SidebarPanel BranchStatus { SidebarPanel BranchStatus {
dock: bottom; dock: bottom;
height: auto; height: auto;
@@ -61,6 +70,13 @@ class SidebarPanel(Vertical):
self.branch = branch self.branch = branch
super().__init__() 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 # Reactive state
current_branch: reactive[str] = reactive("main") current_branch: reactive[str] = reactive("main")
visible: reactive[bool] = reactive(True) visible: reactive[bool] = reactive(True)
@@ -101,18 +117,25 @@ class SidebarPanel(Vertical):
staged: list, staged: list,
unstaged: list, unstaged: list,
) -> None: ) -> None:
"""Update git changes view.""" """Update git changes view and branch stats."""
try: try:
git_view = self.query_one(GitChangesView) git_view = self.query_one(GitChangesView)
git_view.update_changes(staged, unstaged) git_view.update_changes(staged, unstaged)
except Exception: except Exception:
pass 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: def update_git_graph(self, commits: list) -> None:
"""Update git graph view.""" """Update git graph view."""
try: try:
graph = self.query_one(GitGraphView) graph = self.query_one(GitGraphView)
graph.update_graph(commits) graph.update_commits(commits)
except Exception: except Exception:
pass pass
@@ -157,7 +180,7 @@ class SidebarPanel(Vertical):
event: GitChangesView.FileClicked, event: GitChangesView.FileClicked,
) -> None: ) -> None:
"""Forward git file selection.""" """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( def on_branch_status_branch_changed(
self, self,
@@ -166,3 +189,12 @@ class SidebarPanel(Vertical):
"""Forward branch change.""" """Forward branch change."""
self.current_branch = event.branch self.current_branch = event.branch
self.post_message(self.BranchChanged(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))