Restructure project and add user settings persistence

- Move src/clide/ to clide/ (flatten directory structure)
- Add SettingsService for persistent user preferences
- Settings stored in ~/.clide/settings.json
- Persist theme, panel visibility, and compact mode state
- Fix terminal rendering glitch with throttled refresh (~60fps)
- Update pyproject.toml, Makefile, and tests for new structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-02-01 00:42:46 +01:00
co-authored by Claude Opus 4.5
parent 6b7c3ecf9b
commit 1ed21d2a0f
88 changed files with 378 additions and 226 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Clide - A TUI CLI IDE for Claude Code CLI."""
__version__ = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m clide."""
from clide.cli import app
if __name__ == "__main__":
app()
+558
View File
@@ -0,0 +1,558 @@
"""Main Textual Application for Clide."""
from pathlib import Path
from typing import ClassVar
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 clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings
from clide.services.settings_service import SettingsService, get_settings_service
from clide.themes.registry import get_all_themes, get_theme
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
from clide.widgets.panels.sidebar import SidebarPanel
from clide.widgets.panels.workspace import WorkspacePanel
class ClideApp(App[None]):
"""Clide TUI Application - Claude Code IDE.
Panel architecture:
- Sidebar (left): Files, Git, Tree tabs + branch status
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
- Context (right): Problems, TODOs, Jira tabs
Workspace is hidden by default. Claude takes full height when
workspace is hidden, 40% when visible.
"""
TITLE = "Clide"
SUB_TITLE = "Claude Code IDE"
CSS: ClassVar[str] = """
/* Main layout */
Screen {
layout: horizontal;
}
#main-container {
width: 100%;
height: 100%;
layout: horizontal;
}
#center-column {
width: 1fr;
height: 100%;
layout: vertical;
}
/* Panel styling */
SidebarPanel {
width: 20%;
min-width: 25;
}
ContextPanel {
width: 25%;
min-width: 30;
}
/* Workspace + Claude layout */
WorkspacePanel {
height: 60%;
}
WorkspacePanel.hidden {
display: none;
}
ClaudePanel {
height: 100%;
}
ClaudePanel.with-workspace {
height: 40%;
}
/* Compact mode - applied when .compact class is on #main-container */
#main-container.compact SidebarPanel {
display: none;
}
#main-container.compact ContextPanel {
display: none;
}
/* Fullscreen mode */
.fullscreen {
dock: top;
width: 100%;
height: 100%;
layer: fullscreen;
}
"""
# VSCode-style keybindings
BINDINGS: ClassVar[list[Binding]] = [
# Global
Binding("ctrl+q", "quit", "Quit"),
Binding("ctrl+shift+p", "command_palette", "Commands"),
Binding("ctrl+p", "quick_open", "Quick Open"),
Binding("ctrl+b", "toggle_sidebar", "Toggle Sidebar"),
Binding("ctrl+shift+b", "toggle_context", "Toggle Context"),
Binding("ctrl+`", "toggle_terminal", "Toggle Terminal"),
Binding("ctrl+shift+c", "toggle_compact", "Compact Mode"),
Binding("f11", "toggle_fullscreen", "Fullscreen"),
Binding("escape", "escape", "Escape", show=False),
# Navigation
Binding("ctrl+1", "focus_claude", "Focus Claude", show=False),
Binding("ctrl+2", "focus_editor", "Focus Editor", show=False),
Binding("ctrl+3", "focus_terminal", "Focus Terminal", show=False),
Binding("ctrl+0", "focus_sidebar", "Focus Sidebar", show=False),
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
# Git
Binding("ctrl+shift+g", "open_git", "Git", show=False),
# Problems
Binding("ctrl+shift+m", "open_problems", "Problems", show=False),
Binding("f8", "next_problem", "Next Problem", show=False),
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
# Editor
Binding("ctrl+s", "save_file", "Save", show=False),
Binding("ctrl+g", "goto_line", "Go to Line", show=False),
# Theme
Binding("ctrl+k ctrl+t", "select_theme", "Select Theme", show=False),
]
# Reactive state
current_file: reactive[Path | None] = reactive(None)
workspace_visible: reactive[bool] = reactive(False)
compact_mode: reactive[bool] = reactive(False)
fullscreen_panel: reactive[str | None] = reactive(None)
def __init__(
self,
workdir: Path | None = None,
settings: ClideSettings | None = None,
test_mode: bool = False,
) -> None:
super().__init__()
self.workdir = workdir or Path.cwd()
self.settings = settings or ClideSettings()
self._test_mode = test_mode
# User settings persistence
self._settings_service = get_settings_service()
self._user_settings = self._settings_service.load()
# Extension manager
self.extension_manager = ExtensionManager()
# Controllers
self.git_controller = GitController(self.workdir)
self.editor_controller = EditorController()
self.diff_controller = DiffController(self.workdir)
self.problems_controller = ProblemsController(self.workdir)
self.todos_controller = TodosController(self.workdir)
self.jira_controller = JiraController(
enabled=self._user_settings.jira_enabled,
)
# Register themes
self._register_themes()
def _register_themes(self) -> None:
"""Register all themes with Textual."""
for theme_meta in get_all_themes():
theme_def = get_theme(theme_meta.name)
if theme_def:
self.register_theme(theme_def.to_textual_theme())
# Set initial theme from user settings
self.theme = self._user_settings.theme
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
"""Set the application theme.
Args:
theme_name: Name of the theme to apply
save: Whether to persist the setting (default: True)
"""
self.theme = theme_name
if save:
self._settings_service.set("theme", theme_name)
def save_user_settings(self) -> None:
"""Save current user settings to disk."""
self._settings_service.update(
theme=self.theme,
compact_mode=self.compact_mode,
sidebar_visible=self.query_one(SidebarPanel).display,
context_visible=self.query_one(ContextPanel).display,
)
def compose(self) -> ComposeResult:
"""Create the main layout."""
yield Header()
with Horizontal(id="main-container"):
# Left sidebar
yield SidebarPanel(workdir=self.workdir)
# Center column with workspace and claude
with Vertical(id="center-column"):
yield WorkspacePanel(workdir=self.workdir)
yield ClaudePanel(
workdir=self.workdir,
auto_start=not self._test_mode,
)
# Right context panel
yield ContextPanel(
jira_enabled=self.settings.jira_enabled,
)
yield Footer()
async def on_mount(self) -> None:
"""Initialize application on mount."""
# Apply saved user settings
self._apply_user_settings()
# Load extensions
self.extension_manager.load_extensions()
await self.extension_manager.trigger_app_startup(self)
# Initial data refresh
await self._refresh_git()
await self._refresh_problems()
await self._refresh_todos()
if self._user_settings.jira_enabled:
await self._refresh_jira()
# Focus Claude panel
self.action_focus_claude()
def _apply_user_settings(self) -> None:
"""Apply saved user settings on startup."""
# Panel visibility
sidebar = self.query_one(SidebarPanel)
sidebar.visible = self._user_settings.sidebar_visible
context = self.query_one(ContextPanel)
context.visible = self._user_settings.context_visible
# Compact mode
self.compact_mode = self._user_settings.compact_mode
# Reactive watchers
def watch_workspace_visible(self, visible: bool) -> None:
"""Update panels when workspace visibility changes."""
workspace = self.query_one(WorkspacePanel)
claude = self.query_one(ClaudePanel)
workspace.visible = visible
claude.workspace_visible = visible
def watch_compact_mode(self, compact: bool) -> None:
"""Toggle compact mode class."""
container = self.query_one("#main-container")
if compact:
container.add_class("compact")
else:
container.remove_class("compact")
# Data refresh methods
async def _refresh_git(self) -> None:
"""Refresh git status."""
status = await self.git_controller.get_status()
if status:
sidebar = self.query_one(SidebarPanel)
sidebar.update_git_status(status.staged, status.unstaged)
sidebar.current_branch = status.branch
branches = await self.git_controller.get_branches()
if branches:
sidebar = self.query_one(SidebarPanel)
sidebar.update_branches([b.name for b in branches])
commits = await self.git_controller.get_log(limit=50)
if commits:
sidebar = self.query_one(SidebarPanel)
sidebar.update_git_graph(commits)
async def _refresh_problems(self) -> None:
"""Refresh linter problems."""
problems = await self.problems_controller.run_all()
context = self.query_one(ContextPanel)
context.update_problems(problems)
async def _refresh_todos(self) -> None:
"""Refresh TODOs."""
todos = await self.todos_controller.scan()
context = self.query_one(ContextPanel)
context.update_todos(todos)
async def _refresh_jira(self) -> None:
"""Refresh Jira content."""
context = self.query_one(ContextPanel)
context.set_jira_loading()
content = await self.jira_controller.get_content()
if content:
context.update_jira(content)
else:
context.set_jira_error("Failed to load Jira content")
# Action methods
def action_toggle_sidebar(self) -> None:
"""Toggle sidebar visibility."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = not sidebar.visible
self._settings_service.set("sidebar_visible", sidebar.visible)
def action_toggle_context(self) -> None:
"""Toggle context panel visibility."""
context = self.query_one(ContextPanel)
context.visible = not context.visible
self._settings_service.set("context_visible", context.visible)
def action_toggle_terminal(self) -> None:
"""Toggle terminal (shows workspace with terminal tab)."""
workspace = self.query_one(WorkspacePanel)
if self.workspace_visible and workspace.active_tab == "terminal":
self.workspace_visible = False
else:
workspace.show_terminal()
self.workspace_visible = True
def action_toggle_compact(self) -> None:
"""Toggle compact mode."""
self.compact_mode = not self.compact_mode
self._settings_service.set("compact_mode", self.compact_mode)
def action_toggle_fullscreen(self) -> None:
"""Toggle fullscreen for focused panel."""
# TODO: Implement fullscreen toggle
pass
def action_escape(self) -> None:
"""Handle escape key."""
if self.fullscreen_panel:
self.fullscreen_panel = None
elif self.workspace_visible:
workspace = self.query_one(WorkspacePanel)
if not workspace.has_unsaved_changes():
self.workspace_visible = False
def action_focus_claude(self) -> None:
"""Focus Claude panel."""
claude = self.query_one(ClaudePanel)
claude.focus_terminal()
def action_focus_editor(self) -> None:
"""Focus editor."""
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.focus_tab("editor")
def action_focus_terminal(self) -> None:
"""Focus terminal."""
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.show_terminal()
def action_focus_sidebar(self) -> None:
"""Focus sidebar."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = True
sidebar.focus()
def action_close_tab(self) -> None:
"""Close current tab/editor."""
# TODO: Implement tab closing
pass
def action_open_git(self) -> None:
"""Open git panel."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = True
sidebar.focus_tab("sidebar-git")
def action_open_problems(self) -> None:
"""Open problems panel."""
context = self.query_one(ContextPanel)
context.visible = True
context.focus_problems()
def action_next_problem(self) -> None:
"""Go to next problem."""
# TODO: Implement problem navigation
pass
def action_prev_problem(self) -> None:
"""Go to previous problem."""
# TODO: Implement problem navigation
pass
def action_save_file(self) -> None:
"""Save current file."""
# EditorPane handles save internally
pass
def action_goto_line(self) -> None:
"""Go to line dialog."""
# TODO: Implement go to line
pass
def action_quick_open(self) -> None:
"""Quick file open."""
# TODO: Implement quick open
pass
async def action_select_theme(self) -> None:
"""Open theme selector."""
from clide.themes.registry import get_all_themes
# Get all available themes
themes = get_all_themes()
theme_names = [t.name for t in themes]
# Use Textual's built-in selection if available, otherwise cycle
# For now, simple cycle through themes
current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0
next_idx = (current_idx + 1) % len(theme_names)
self.set_theme(theme_names[next_idx])
# Event handlers for panel messages
async def on_sidebar_panel_file_selected(
self,
event: SidebarPanel.FileSelected,
) -> None:
"""Handle file selection from sidebar."""
self.current_file = event.path
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(event.path)
async def on_sidebar_panel_git_file_selected(
self,
event: SidebarPanel.GitFileSelected,
) -> None:
"""Handle git file selection - show diff."""
diff = await self.diff_controller.get_file_diff(
str(event.path),
staged=event.staged,
)
if diff:
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.show_diff(diff)
async def on_sidebar_panel_branch_changed(
self,
event: SidebarPanel.BranchChanged,
) -> None:
"""Handle branch change."""
success = await self.git_controller.checkout_branch(event.branch)
if success:
await self._refresh_git()
async def on_context_panel_problem_clicked(
self,
event: ContextPanel.ProblemClicked,
) -> None:
"""Handle problem click - open file at line."""
problem = event.problem
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(problem.file_path, line=problem.line)
async def on_context_panel_todo_clicked(
self,
event: ContextPanel.TodoClicked,
) -> None:
"""Handle TODO click - open file at line."""
item = event.item
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(item.file_path, line=item.line)
async def on_context_panel_jira_refresh_requested(
self,
_event: ContextPanel.JiraRefreshRequested,
) -> None:
"""Handle Jira refresh request."""
await self._refresh_jira()
async def on_workspace_panel_file_saved(
self,
_event: WorkspacePanel.FileSaved,
) -> None:
"""Handle file save - refresh problems and git."""
await self._refresh_git()
await self._refresh_problems()
async def on_workspace_panel_diff_accepted(
self,
event: WorkspacePanel.DiffAccepted,
) -> None:
"""Handle diff accept."""
await self.diff_controller.accept_proposal(event.file_path)
await self._refresh_git()
await self._refresh_problems()
async def on_workspace_panel_diff_rejected(
self,
event: WorkspacePanel.DiffRejected,
) -> None:
"""Handle diff reject."""
await self.diff_controller.reject_proposal(event.file_path)
workspace = self.query_one(WorkspacePanel)
workspace.clear_diff()
async def on_workspace_panel_command_submitted(
self,
event: WorkspacePanel.CommandSubmitted,
) -> None:
"""Handle terminal command - run and show output."""
from clide.services.process_service import ProcessService
workspace = self.query_one(WorkspacePanel)
result = await ProcessService.run_async(
event.command,
cwd=self.workdir,
shell=True,
)
if result.stdout:
workspace.write_terminal_output(result.stdout)
if result.stderr:
workspace.write_terminal_error(result.stderr)
# Refresh after command
await self._refresh_git()
def on_claude_panel_claude_started(
self,
_event: ClaudePanel.ClaudeStarted,
) -> None:
"""Handle Claude Code started."""
self.notify("Claude Code started", severity="information")
def on_claude_panel_claude_exited(
self,
event: ClaudePanel.ClaudeExited,
) -> None:
"""Handle Claude Code exited."""
if event.return_code != 0:
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
+54
View File
@@ -0,0 +1,54 @@
"""Typer CLI entry point for Clide."""
from pathlib import Path
from typing import Annotated
import typer
from clide import __version__
app = typer.Typer(
name="clide",
help="A TUI CLI IDE for Claude Code CLI",
add_completion=True,
no_args_is_help=False,
)
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
typer.echo(f"clide {__version__}")
raise typer.Exit()
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
_version: Annotated[
bool | None,
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
] = None,
workdir: Annotated[
Path | None,
typer.Option("--workdir", "-w", help="Working directory to open"),
] = None,
) -> None:
"""Launch Clide TUI application."""
if ctx.invoked_subcommand is None:
from clide.app import ClideApp
app_instance = ClideApp(workdir=workdir)
app_instance.run()
@app.command()
def config() -> None:
"""Open configuration editor."""
typer.echo("Configuration editor not yet implemented")
@app.command()
def extensions() -> None:
"""List installed extensions."""
typer.echo("Extension manager not yet implemented")
+20
View File
@@ -0,0 +1,20 @@
"""Domain controllers for Clide."""
from clide.controllers.base import ControllerMixin, controller
from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
__all__ = [
"controller",
"ControllerMixin",
"GitController",
"EditorController",
"DiffController",
"ProblemsController",
"TodosController",
"JiraController",
]
+102
View File
@@ -0,0 +1,102 @@
"""Base controller utilities using decorator pattern."""
from functools import wraps
from typing import TYPE_CHECKING
from textual.message import Message
if TYPE_CHECKING:
from textual.app import App
def controller[T](cls: type[T]) -> type[T]:
"""Decorator to add controller capabilities to a class.
Adds:
- _app attribute for parent application reference
- set_app() method to set the application
- post_message() method to emit messages
- initialize() and shutdown() lifecycle hooks (if not defined)
Usage:
@controller
class GitController:
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
async def get_status(self) -> GitStatus:
...
"""
original_init = cls.__init__
@wraps(original_init)
def new_init(self, *args, **kwargs):
self._app = None
original_init(self, *args, **kwargs)
cls.__init__ = new_init
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
# Only add methods if they don't exist
if not hasattr(cls, "set_app"):
cls.set_app = set_app
if not hasattr(cls, "post_message"):
cls.post_message = post_message
if not hasattr(cls, "initialize"):
cls.initialize = initialize
if not hasattr(cls, "shutdown"):
cls.shutdown = shutdown
return cls
class ControllerMixin:
"""Mixin alternative for controller capabilities.
Use this if you prefer inheritance over decorators.
Usage:
class GitController(ControllerMixin):
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
"""
_app: "App[object] | None" = None
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
@property
def app(self) -> "App[object] | None":
"""Get the parent application."""
return self._app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
+330
View File
@@ -0,0 +1,330 @@
"""Diff controller for viewing and managing diffs."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.services.git_service import GitService
@controller
class DiffController:
"""Controller for diff viewing and Claude-proposed changes."""
class DiffLoaded(Message):
"""Emitted when a diff is loaded."""
def __init__(self, diff: DiffContent) -> None:
self.diff = diff
super().__init__()
class HunkAccepted(Message):
"""Emitted when a hunk is accepted."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class HunkRejected(Message):
"""Emitted when a hunk is rejected."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class AllChangesAccepted(Message):
"""Emitted when all changes are accepted."""
pass
class AllChangesRejected(Message):
"""Emitted when all changes are rejected."""
pass
def __init__(self, repo_path: Path) -> None:
self._git_service = GitService(repo_path)
self._state = DiffViewState()
@property
def state(self) -> DiffViewState:
"""Get diff view state."""
return self._state
@property
def diff(self) -> DiffContent | None:
"""Get current diff content."""
return self._state.diff
@property
def is_proposal(self) -> bool:
"""Check if current diff is a Claude proposal."""
return self._state.is_proposal
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Load diff from git.
Args:
path: File path
staged: Whether to load staged diff
Returns:
DiffContent or None if no diff
"""
diff_text = await self._git_service.get_diff(path, staged)
if not diff_text:
return None
diff = self._parse_diff(path, diff_text)
self._state.diff = diff
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
"""Load a Claude-proposed change as a diff.
Args:
path: File path
old_content: Original content
new_content: Proposed content
Returns:
DiffContent of the proposal
"""
diff = self._create_diff_from_content(path, old_content, new_content)
self._state.diff = diff
self._state.is_proposal = True
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def accept_hunk(self, index: int) -> None:
"""Accept a specific hunk.
Args:
index: Hunk index
"""
self._state.accepted_hunks.add(index)
self._state.rejected_hunks.discard(index)
def reject_hunk(self, index: int) -> None:
"""Reject a specific hunk.
Args:
index: Hunk index
"""
self._state.rejected_hunks.add(index)
self._state.accepted_hunks.discard(index)
def accept_all(self) -> None:
"""Accept all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.accepted_hunks.add(i)
self._state.rejected_hunks.clear()
def reject_all(self) -> None:
"""Reject all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.rejected_hunks.add(i)
self._state.accepted_hunks.clear()
def clear(self) -> None:
"""Clear current diff."""
self._state.diff = None
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
def toggle_side_by_side(self) -> bool:
"""Toggle side-by-side view.
Returns:
New side_by_side value
"""
self._state.side_by_side = not self._state.side_by_side
return self._state.side_by_side
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
"""Parse git diff output into DiffContent."""
hunks: list[DiffHunk] = []
current_hunk_lines: list[DiffLine] = []
current_header = ""
old_start = old_count = new_start = new_count = 0
for line in diff_text.split("\n"):
if line.startswith("@@"):
# Save previous hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
current_hunk_lines = []
# Parse hunk header
current_header = line
# Format: @@ -old_start,old_count +new_start,new_count @@
import re
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
if match:
old_start = int(match.group(1))
old_count = int(match.group(2)) if match.group(2) else 1
new_start = int(match.group(3))
new_count = int(match.group(4)) if match.group(4) else 1
elif line.startswith("+") and not line.startswith("+++"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line[1:],
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
))
elif line.startswith("-") and not line.startswith("---"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line[1:],
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
))
elif line.startswith(" "):
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
current_hunk_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line[1:],
old_line_num=old_num,
new_line_num=new_num,
))
# Save last hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
return DiffContent(
file_path=path,
hunks=tuple(hunks),
)
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Get diff for a file (alias for load_git_diff).
Args:
path: File path
staged: Whether to get staged diff
Returns:
DiffContent or None
"""
return await self.load_git_diff(path, staged)
async def accept_proposal(self, _file_path: str) -> bool:
"""Accept a proposed change and apply it.
Args:
file_path: Path to the file
Returns:
True if successful
"""
if not self._state.diff or not self._state.is_proposal:
return False
self.accept_all()
# TODO: Apply the changes to the file
self.clear()
return True
async def reject_proposal(self, _file_path: str) -> bool:
"""Reject a proposed change.
Args:
file_path: Path to the file
Returns:
True if successful
"""
self.reject_all()
self.clear()
return True
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
"""Create diff from old and new content."""
import difflib
old_lines = old.splitlines(keepends=True)
new_lines = new.splitlines(keepends=True)
diff_lines: list[DiffLine] = []
old_num = new_num = 1
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
if tag == "equal":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line.rstrip("\n"),
old_line_num=old_num,
new_line_num=new_num,
))
old_num += 1
new_num += 1
elif tag == "delete":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
elif tag == "insert":
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
elif tag == "replace":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
hunk = DiffHunk(
header="@@ -1 +1 @@",
old_start=1,
old_count=len(old_lines),
new_start=1,
new_count=len(new_lines),
lines=tuple(diff_lines),
)
return DiffContent(
file_path=path,
hunks=(hunk,),
)
+215
View File
@@ -0,0 +1,215 @@
"""Editor controller for managing open files."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.editor import CursorPosition, EditorState, FileBuffer
@controller
class EditorController:
"""Controller for editor state and file operations."""
class FileOpened(Message):
"""Emitted when a file is opened."""
def __init__(self, buffer: FileBuffer) -> None:
self.buffer = buffer
super().__init__()
class FileClosed(Message):
"""Emitted when a file is closed."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileSaved(Message):
"""Emitted when a file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileModified(Message):
"""Emitted when file content changes."""
def __init__(self, path: Path, is_modified: bool) -> None:
self.path = path
self.is_modified = is_modified
super().__init__()
class ActiveBufferChanged(Message):
"""Emitted when active buffer changes."""
def __init__(self, buffer: FileBuffer | None) -> None:
self.buffer = buffer
super().__init__()
def __init__(self, project_path: Path | None = None) -> None:
self._project_path = project_path or Path.cwd()
self._state = EditorState()
@property
def state(self) -> EditorState:
"""Get editor state."""
return self._state
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
return self._state.active_buffer
@property
def open_files(self) -> list[FileBuffer]:
"""Get list of open file buffers."""
return self._state.buffers
@property
def has_unsaved_changes(self) -> bool:
"""Check if any buffer has unsaved changes."""
return any(b.is_modified for b in self._state.buffers)
async def open_file(self, path: Path, line: int | None = None) -> FileBuffer:
"""Open a file in the editor.
Args:
path: File path to open
line: Optional line number to jump to
Returns:
FileBuffer for the opened file
"""
# Check if already open
existing = self._state.get_buffer_by_path(path)
if existing:
self._set_active_buffer(existing)
if line:
existing.cursor = CursorPosition(line=line - 1, column=0)
return existing
# Read file content
content = await self._service.read_file(path)
language = await self._service.get_language(path)
cursor = CursorPosition(line=line - 1 if line else 0, column=0)
buffer = FileBuffer(
path=path,
content=content,
language=language,
cursor=cursor,
)
self._state.buffers.append(buffer)
self._set_active_buffer(buffer)
# Add to recent files
if path not in self._state.recent_files:
self._state.recent_files.insert(0, path)
self._state.recent_files = self._state.recent_files[:20]
return buffer
async def close_file(self, path: Path) -> bool:
"""Close a file buffer.
Args:
path: File path to close
Returns:
True if closed (may be False if unsaved and user cancels)
"""
buffer = self._state.get_buffer_by_path(path)
if not buffer:
return True
# Remove from buffers
self._state.buffers.remove(buffer)
# Update active buffer
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(self._state.buffers):
self._state.active_buffer_index = (
len(self._state.buffers) - 1 if self._state.buffers else None
)
return True
async def save_file(self, path: Path | None = None) -> bool:
"""Save a file.
Args:
path: File path (defaults to active buffer)
Returns:
True if saved successfully
"""
if path:
buffer = self._state.get_buffer_by_path(path)
else:
buffer = self.active_buffer
if not buffer:
return False
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
return True
async def save_all(self) -> int:
"""Save all modified buffers.
Returns:
Number of files saved
"""
count = 0
for buffer in self._state.buffers:
if buffer.is_modified:
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
count += 1
return count
def update_content(self, path: Path, content: str) -> None:
"""Update buffer content.
Args:
path: File path
content: New content
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.content = content
buffer.is_modified = True
def update_cursor(self, path: Path, line: int, column: int) -> None:
"""Update cursor position.
Args:
path: File path
line: Line number (0-indexed)
column: Column number (0-indexed)
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.cursor = CursorPosition(line=line, column=column)
def set_active_by_index(self, index: int) -> None:
"""Set active buffer by index.
Args:
index: Buffer index
"""
if 0 <= index < len(self._state.buffers):
self._state.active_buffer_index = index
def _set_active_buffer(self, buffer: FileBuffer) -> None:
"""Set active buffer."""
try:
index = self._state.buffers.index(buffer)
self._state.active_buffer_index = index
except ValueError:
pass
+201
View File
@@ -0,0 +1,201 @@
"""Git controller for managing git operations."""
from pathlib import Path
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
@controller
class GitController:
"""Controller for git operations."""
class StatusUpdated(Message):
"""Emitted when git status changes."""
def __init__(self, status: GitStatus) -> None:
self.status = status
super().__init__()
class BranchesUpdated(Message):
"""Emitted when branches list changes."""
def __init__(self, branches: list[GitBranch]) -> None:
self.branches = branches
super().__init__()
class LogUpdated(Message):
"""Emitted when commit log is refreshed."""
def __init__(self, commits: list[GitCommit]) -> None:
self.commits = commits
super().__init__()
class FileStaged(Message):
"""Emitted when a file is staged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
class FileUnstaged(Message):
"""Emitted when a file is unstaged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
def __init__(self, repo_path: Path) -> None:
self._service = GitService(repo_path)
self._status: GitStatus | None = None
self._branches: list[GitBranch] = []
self._commits: list[GitCommit] = []
@property
def status(self) -> GitStatus | None:
"""Get current git status."""
return self._status
@property
def branches(self) -> list[GitBranch]:
"""Get list of branches."""
return self._branches
@property
def commits(self) -> list[GitCommit]:
"""Get commit log."""
return self._commits
@property
def current_branch(self) -> str:
"""Get current branch name."""
return self._status.branch if self._status else "unknown"
async def refresh_status(self) -> GitStatus:
"""Refresh git status.
Returns:
Updated GitStatus
"""
self._status = await self._service.get_status()
return self._status
async def get_status(self) -> GitStatus | None:
"""Get git status (refreshes if needed)."""
return await self.refresh_status()
async def refresh_branches(self) -> list[GitBranch]:
"""Refresh branches list.
Returns:
Updated list of branches
"""
self._branches = await self._service.get_branches()
return self._branches
async def get_branches(self) -> list[GitBranch]:
"""Get branches list (refreshes if needed)."""
return await self.refresh_branches()
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
"""Refresh commit log.
Args:
max_count: Maximum commits to fetch
Returns:
Updated list of commits
"""
self._commits = await self._service.get_log(max_count)
return self._commits
async def get_log(self, limit: int = 50) -> list[GitCommit]:
"""Get commit log (refreshes if needed)."""
return await self.refresh_log(limit)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
success = await self._service.stage_file(path)
if success:
await self.refresh_status()
return success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
success = await self._service.unstage_file(path)
if success:
await self.refresh_status()
return success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path
Returns:
True if successful
"""
success = await self._service.discard_changes(path)
if success:
await self.refresh_status()
return success
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name
Returns:
True if successful
"""
success = await self._service.checkout_branch(branch)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def create_branch(self, name: str) -> bool:
"""Create and checkout a new branch.
Args:
name: New branch name
Returns:
True if successful
"""
success = await self._service.create_branch(name)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def get_file_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff string
"""
return await self._service.get_diff(path, staged)
+158
View File
@@ -0,0 +1,158 @@
"""Jira controller for Jira CLI integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.services.process_service import ProcessService
@controller
class JiraController:
"""Controller for Jira CLI integration."""
class JiraOutputUpdated(Message):
"""Emitted when Jira output is updated."""
def __init__(self, output: str) -> None:
self.output = output
super().__init__()
class JiraError(Message):
"""Emitted when Jira command fails."""
def __init__(self, error: str) -> None:
self.error = error
super().__init__()
def __init__(
self,
project_path: Path | None = None,
jira_cli_path: str = "jira",
enabled: bool = False,
) -> None:
self._project_path = project_path or Path.cwd()
self._jira_cli = jira_cli_path
self._enabled = enabled
self._last_output: str = ""
self._last_error: str = ""
@property
def enabled(self) -> bool:
"""Check if Jira integration is enabled."""
return self._enabled
@property
def last_output(self) -> str:
"""Get last Jira output."""
return self._last_output
@property
def last_error(self) -> str:
"""Get last error message."""
return self._last_error
def enable(self) -> None:
"""Enable Jira integration."""
self._enabled = True
def disable(self) -> None:
"""Disable Jira integration."""
self._enabled = False
async def run_command(self, *args: str) -> str:
"""Run a Jira CLI command.
Args:
*args: Command arguments
Returns:
Command output
"""
if not self._enabled:
return "Jira integration is disabled"
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, *args)
if result.success:
self._last_output = result.stdout
self._last_error = ""
return result.stdout
else:
self._last_error = result.stderr
return f"Error: {result.stderr}"
async def list_issues(self, project: str | None = None) -> str:
"""List Jira issues.
Args:
project: Optional project key
Returns:
Formatted issue list
"""
args = ["issue", "list"]
if project:
args.extend(["--project", project])
return await self.run_command(*args)
async def get_issue(self, issue_key: str) -> str:
"""Get a specific issue.
Args:
issue_key: Issue key (e.g., PROJ-123)
Returns:
Issue details
"""
return await self.run_command("issue", "view", issue_key)
async def get_my_issues(self) -> str:
"""Get issues assigned to current user.
Returns:
Formatted issue list
"""
return await self.run_command("issue", "list", "--assignee", "@me")
async def get_sprint_issues(self) -> str:
"""Get issues in current sprint.
Returns:
Formatted issue list
"""
return await self.run_command("sprint", "list", "--current")
async def refresh(self) -> str:
"""Refresh Jira data (get my issues).
Returns:
Updated output
"""
return await self.get_my_issues()
async def check_available(self) -> bool:
"""Check if Jira CLI is available.
Returns:
True if available
"""
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, "--version")
return result.success
async def get_content(self) -> str | None:
"""Get Jira content for display.
Returns:
Markdown content or None
"""
if not self._enabled:
return None
try:
return await self.get_my_issues()
except Exception:
return None
+180
View File
@@ -0,0 +1,180 @@
"""Problems controller for linter integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.services.linter_service import LinterService
@controller
class ProblemsController:
"""Controller for problems/diagnostics from linters."""
class ProblemsUpdated(Message):
"""Emitted when problems list is updated."""
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
self.problems = problems
self.summary = summary
super().__init__()
class ProblemSelected(Message):
"""Emitted when a problem is selected."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
self._service = LinterService(project_path)
self._linters = linters or ["ruff"]
self._state = ProblemsState()
@property
def state(self) -> ProblemsState:
"""Get problems state."""
return self._state
@property
def problems(self) -> list[Problem]:
"""Get list of problems."""
return self._state.problems
@property
def summary(self) -> ProblemsSummary:
"""Get problems summary."""
return self._state.summary
@property
def error_count(self) -> int:
"""Get error count."""
return self._state.summary.errors
@property
def warning_count(self) -> int:
"""Get warning count."""
return self._state.summary.warnings
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
"""Refresh problems from all linters.
Returns:
Tuple of (problems, summary)
"""
problems, summary = await self._service.run_all(self._linters)
self._state.problems = problems
self._state.summary = summary
return problems, summary
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
"""Filter problems by severity.
Args:
severity: Severity to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_severity = severity
if severity is None:
return self._state.problems
return [p for p in self._state.problems if p.severity == severity]
def filter_by_source(self, source: str | None) -> list[Problem]:
"""Filter problems by source linter.
Args:
source: Source to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_source = source
if source is None:
return self._state.problems
return [p for p in self._state.problems if p.source == source]
def get_problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file.
Args:
path: File path
Returns:
List of problems for that file
"""
return self._state.problems_for_file(path)
def select_problem(self, index: int) -> Problem | None:
"""Select a problem by index.
Args:
index: Problem index
Returns:
Selected problem or None
"""
if 0 <= index < len(self._state.problems):
self._state.selected_index = index
return self._state.problems[index]
return None
def next_problem(self) -> Problem | None:
"""Select next problem.
Returns:
Next problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = 0
else:
self._state.selected_index = (
self._state.selected_index + 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def prev_problem(self) -> Problem | None:
"""Select previous problem.
Returns:
Previous problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = len(self._state.problems) - 1
else:
self._state.selected_index = (
self._state.selected_index - 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def clear(self) -> None:
"""Clear all problems."""
self._state.problems = []
self._state.summary = ProblemsSummary()
self._state.selected_index = None
async def run_all(self) -> list[Problem]:
"""Run all linters and return problems.
Returns:
List of problems
"""
problems, _ = await self.refresh()
return problems
+138
View File
@@ -0,0 +1,138 @@
"""TODOs controller for tracking TODO comments."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
from clide.services.todo_scanner import TodoScanner
@controller
class TodosController:
"""Controller for TODO/FIXME comment tracking."""
class TodosUpdated(Message):
"""Emitted when TODOs list is updated."""
def __init__(self, items: list[TodoItem], summary: TodosSummary) -> None:
self.items = items
self.summary = summary
super().__init__()
class TodoSelected(Message):
"""Emitted when a TODO is selected."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
def __init__(self, project_path: Path) -> None:
self._scanner = TodoScanner(project_path)
self._state = TodosState()
@property
def state(self) -> TodosState:
"""Get TODOs state."""
return self._state
@property
def items(self) -> list[TodoItem]:
"""Get list of TODO items."""
return self._state.items
@property
def summary(self) -> TodosSummary:
"""Get TODOs summary."""
return self._state.summary
@property
def total_count(self) -> int:
"""Get total TODO count."""
return self._state.summary.total
async def refresh(self) -> tuple[list[TodoItem], TodosSummary]:
"""Refresh TODOs from project.
Returns:
Tuple of (items, summary)
"""
items, summary = await self._scanner.scan()
self._state.items = items
self._state.summary = summary
return items, summary
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
"""Filter TODOs by type.
Args:
todo_type: Type to filter by, or None for all
Returns:
Filtered list of TODOs
"""
self._state.filter_type = todo_type
if todo_type is None:
return self._state.items
return self._state.items_by_type(todo_type)
def get_items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODOs for a specific file.
Args:
path: File path
Returns:
List of TODOs for that file
"""
return self._state.items_for_file(path)
def select_item(self, index: int) -> TodoItem | None:
"""Select a TODO by index.
Args:
index: Item index
Returns:
Selected item or None
"""
if 0 <= index < len(self._state.items):
self._state.selected_index = index
return self._state.items[index]
return None
def toggle_group_by_file(self) -> bool:
"""Toggle grouping by file.
Returns:
New group_by_file value
"""
self._state.group_by_file = not self._state.group_by_file
return self._state.group_by_file
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
"""Get TODOs grouped by file.
Returns:
Dictionary mapping file paths to TODO lists
"""
grouped: dict[Path, list[TodoItem]] = {}
for item in self._state.items:
if item.file_path not in grouped:
grouped[item.file_path] = []
grouped[item.file_path].append(item)
return grouped
async def scan(self) -> list[TodoItem]:
"""Scan for TODOs and return items.
Returns:
List of TODO items
"""
items, _ = await self.refresh()
return items
+6
View File
@@ -0,0 +1,6 @@
"""Extension system for Clide using pluggy."""
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
from clide.extensions.manager import ExtensionManager
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
+1
View File
@@ -0,0 +1 @@
"""Built-in extensions for Clide."""
+77
View File
@@ -0,0 +1,77 @@
"""Pluggy hook specifications for Clide extensions."""
from typing import TYPE_CHECKING, Any
import pluggy
if TYPE_CHECKING:
from textual.app import App
from textual.widget import Widget
hookspec = pluggy.HookspecMarker("clide")
hookimpl = pluggy.HookimplMarker("clide")
class ClideHookSpec:
"""Hook specifications for Clide extensions.
Extensions implement these hooks to extend functionality.
"""
@hookspec
def clide_register_panel(self) -> dict[str, Any] | None:
"""Register a custom panel for the UI.
Returns:
Dictionary with panel configuration:
- name: Panel identifier
- widget: Widget class to instantiate
- position: "left", "right", or "bottom"
- keybinding: Optional keyboard shortcut
"""
@hookspec
def clide_register_commands(self) -> list[dict[str, Any]] | None:
"""Register custom commands for the command palette.
Returns:
List of command dictionaries:
- name: Command display name
- callback: Async callable to execute
- description: Help text
"""
@hookspec
def clide_on_app_startup(self, app: "App[object]") -> None:
"""Called when the application starts.
Args:
app: The Clide application instance
"""
@hookspec
def clide_on_app_shutdown(self, app: "App[object]") -> None:
"""Called when the application is shutting down.
Args:
app: The Clide application instance
"""
@hookspec
def clide_on_file_open(self, path: str) -> None:
"""Called when a file is opened in the file browser.
Args:
path: Absolute path to the opened file
"""
@hookspec
def clide_modify_widget(self, widget: "Widget") -> "Widget":
"""Modify a widget before it's mounted.
Args:
widget: The widget about to be mounted
Returns:
The modified (or original) widget
"""
+80
View File
@@ -0,0 +1,80 @@
"""Extension manager for loading and managing Clide extensions."""
from importlib.metadata import entry_points
from typing import TYPE_CHECKING
import pluggy
from clide.extensions.hookspecs import ClideHookSpec
if TYPE_CHECKING:
from textual.app import App
EXTENSION_NAMESPACE = "clide.extensions"
class ExtensionManager:
"""Manages loading and lifecycle of Clide extensions."""
def __init__(self) -> None:
self._pm = pluggy.PluginManager("clide")
self._pm.add_hookspecs(ClideHookSpec)
self._loaded: list[str] = []
@property
def hook(self) -> pluggy.HookRelay:
"""Access the hook relay for calling hooks."""
return self._pm.hook
def load_extensions(self) -> None:
"""Load all extensions from entry points."""
eps = entry_points(group=EXTENSION_NAMESPACE)
for ep in eps:
try:
plugin = ep.load()
self._pm.register(plugin, name=ep.name)
self._loaded.append(ep.name)
except Exception as e:
# Log but don't crash on extension load failure
print(f"Failed to load extension {ep.name}: {e}")
def register_plugin(self, plugin: object, name: str) -> None:
"""Manually register a plugin instance.
Args:
plugin: Plugin object with hookimpl methods
name: Unique name for the plugin
"""
self._pm.register(plugin, name=name)
self._loaded.append(name)
def unregister_plugin(self, name: str) -> None:
"""Unregister a plugin by name.
Args:
name: Name of the plugin to unregister
"""
plugin = self._pm.get_plugin(name)
if plugin:
self._pm.unregister(plugin)
self._loaded.remove(name)
def list_extensions(self) -> list[str]:
"""Get list of loaded extension names."""
return self._loaded.copy()
async def trigger_app_startup(self, app: "App[object]") -> None:
"""Trigger startup hooks for all extensions.
Args:
app: The Clide application instance
"""
self.hook.clide_on_app_startup(app=app)
async def trigger_app_shutdown(self, app: "App[object]") -> None:
"""Trigger shutdown hooks for all extensions.
Args:
app: The Clide application instance
"""
self.hook.clide_on_app_shutdown(app=app)
+1
View File
@@ -0,0 +1 @@
"""Shared utility functions and helpers for Clide."""
+54
View File
@@ -0,0 +1,54 @@
"""Pydantic models for Clide."""
from clide.models.config import ClideSettings, PanelConfig
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitGraph,
GitStatus,
)
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
__all__ = [
# Config
"ClideSettings",
"PanelConfig",
# Git
"ChangeStatus",
"GitBranch",
"GitChange",
"GitCommit",
"GitGraph",
"GitStatus",
# Editor
"CursorPosition",
"EditorState",
"FileBuffer",
"Selection",
# Diff
"ChangeType",
"DiffContent",
"DiffHunk",
"DiffLine",
"DiffViewState",
# Problems
"Problem",
"ProblemsSummary",
"ProblemsState",
"Severity",
# Todos
"TodoItem",
"TodosSummary",
"TodosState",
"TodoType",
# Theme
"ThemeColors",
"ThemeDefinition",
"ThemeMetadata",
]
+94
View File
@@ -0,0 +1,94 @@
"""Configuration models using Pydantic Settings."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from pydantic_settings import BaseSettings, SettingsConfigDict
class ClideSettings(BaseSettings):
"""Main application settings loaded from environment and config files."""
model_config = SettingsConfigDict(
env_prefix="CLIDE_",
env_file=".config/.env",
env_file_encoding="utf-8",
extra="ignore",
)
# Appearance
theme: str = "summer-night" # Default to Summer Night
# Paths
claude_path: str = "claude"
default_workdir: Path = Path.cwd()
jira_cli_path: str = "jira"
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
confluence_enabled: bool = False
imagin_enabled: bool = False
# Linters
linters: list[str] = ["ruff"]
class PanelConfig(BaseModel):
"""Panel visibility and layout configuration."""
model_config = ConfigDict(strict=True, frozen=True)
sidebar_visible: bool = True
context_visible: bool = True
workspace_visible: bool = False # Hidden by default
sidebar_width_percent: int = 20
context_width_percent: int = 25
class KeybindingsConfig(BaseModel):
"""Keybinding configuration."""
model_config = ConfigDict(strict=True)
# Global
command_palette: str = "ctrl+shift+p"
quick_open: str = "ctrl+p"
toggle_sidebar: str = "ctrl+b"
toggle_context: str = "ctrl+shift+b"
toggle_terminal: str = "ctrl+`"
toggle_compact: str = "ctrl+shift+c"
fullscreen: str = "f11"
# Navigation
focus_claude: str = "ctrl+1"
focus_editor: str = "ctrl+2"
focus_terminal: str = "ctrl+3"
focus_sidebar: str = "ctrl+0"
next_tab: str = "ctrl+tab"
prev_tab: str = "ctrl+shift+tab"
close_tab: str = "ctrl+w"
# Git
git_panel: str = "ctrl+shift+g"
stage_file: str = "ctrl+enter"
unstage_file: str = "ctrl+backspace"
# Search
find_in_file: str = "ctrl+f"
find_in_project: str = "ctrl+shift+f"
problems_panel: str = "ctrl+shift+m"
next_problem: str = "f8"
prev_problem: str = "shift+f8"
# Editor
save: str = "ctrl+s"
undo: str = "ctrl+z"
redo: str = "ctrl+shift+z"
go_to_line: str = "ctrl+g"
# Theme
select_theme: str = "ctrl+k ctrl+t"
+66
View File
@@ -0,0 +1,66 @@
"""Diff-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeType(str, Enum):
"""Type of change in a diff line."""
ADDED = "added"
REMOVED = "removed"
CONTEXT = "context"
HEADER = "header"
class DiffLine(BaseModel):
"""A single line in a diff."""
model_config = ConfigDict(strict=True, frozen=True)
change_type: ChangeType
content: str
old_line_num: int | None = None
new_line_num: int | None = None
class DiffHunk(BaseModel):
"""A hunk (section) of a diff."""
model_config = ConfigDict(strict=True, frozen=True)
header: str
old_start: int
old_count: int
new_start: int
new_count: int
lines: tuple[DiffLine, ...]
class DiffContent(BaseModel):
"""Complete diff for a file."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: str
old_path: str | None = None # For renames
hunks: tuple[DiffHunk, ...]
is_binary: bool = False
is_new_file: bool = False
is_deleted: bool = False
class DiffViewState(BaseModel):
"""State of the diff viewer."""
model_config = ConfigDict(strict=True)
diff: DiffContent | None = None
scroll_offset: int = 0
selected_hunk_index: int | None = None
side_by_side: bool = True
# For Claude-proposed changes
is_proposal: bool = False
accepted_hunks: set[int] = set()
rejected_hunks: set[int] = set()
+72
View File
@@ -0,0 +1,72 @@
"""Editor-related Pydantic models."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class CursorPosition(BaseModel):
"""Cursor position in editor."""
model_config = ConfigDict(strict=True, frozen=True)
line: int
column: int
class Selection(BaseModel):
"""Text selection range."""
model_config = ConfigDict(strict=True, frozen=True)
start: CursorPosition
end: CursorPosition
class FileBuffer(BaseModel):
"""A file buffer in the editor."""
model_config = ConfigDict(strict=True)
path: Path
content: str
language: str | None = None
is_modified: bool = False
cursor: CursorPosition = CursorPosition(line=0, column=0)
selection: Selection | None = None
scroll_offset: int = 0
@property
def filename(self) -> str:
"""Get the filename from path."""
return self.path.name
@property
def display_name(self) -> str:
"""Get display name with modification indicator."""
prefix = "● " if self.is_modified else ""
return f"{prefix}{self.filename}"
class EditorState(BaseModel):
"""State of the editor panel."""
model_config = ConfigDict(strict=True)
buffers: list[FileBuffer] = []
active_buffer_index: int | None = None
recent_files: list[Path] = []
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
if self.active_buffer_index is not None and self.buffers:
return self.buffers[self.active_buffer_index]
return None
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
"""Find a buffer by its file path."""
for buffer in self.buffers:
if buffer.path == path:
return buffer
return None
+80
View File
@@ -0,0 +1,80 @@
"""Git-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeStatus(str, Enum):
"""Git file change status."""
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
RENAMED = "renamed"
COPIED = "copied"
UNTRACKED = "untracked"
IGNORED = "ignored"
UNMERGED = "unmerged"
class GitChange(BaseModel):
"""A single file change in git."""
model_config = ConfigDict(strict=True, frozen=True)
path: str
status: ChangeStatus
staged: bool
old_path: str | None = None # For renames
class GitStatus(BaseModel):
"""Current git repository status."""
model_config = ConfigDict(strict=True, frozen=True)
branch: str
ahead: int = 0
behind: int = 0
staged: tuple[GitChange, ...]
unstaged: tuple[GitChange, ...]
untracked: tuple[str, ...] = ()
has_conflicts: bool = False
class GitBranch(BaseModel):
"""Git branch information."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
is_current: bool = False
is_remote: bool = False
tracking: str | None = None
commit_hash: str | None = None
commit_message: str | None = None
class GitCommit(BaseModel):
"""Git commit information for graph view."""
model_config = ConfigDict(strict=True, frozen=True)
hash: str
short_hash: str
message: str
author: str
date: str
is_merge: bool = False
refs: tuple[str, ...] = () # branch names, tags
parents: tuple[str, ...] = ()
class GitGraph(BaseModel):
"""Git log graph data."""
model_config = ConfigDict(strict=True, frozen=True)
commits: tuple[GitCommit, ...]
branches: tuple[GitBranch, ...]
+88
View File
@@ -0,0 +1,88 @@
"""Problems (linter errors) Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class Severity(str, Enum):
"""Problem severity level."""
ERROR = "error"
WARNING = "warning"
INFO = "info"
HINT = "hint"
class Problem(BaseModel):
"""A single linter problem/diagnostic."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
end_line: int | None = None
end_column: int | None = None
severity: Severity
message: str
source: str # e.g., "ruff", "mypy", "eslint"
code: str | None = None # e.g., "E501", "W0612"
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}:{self.column}"
@property
def severity_icon(self) -> str:
"""Icon for severity level."""
icons = {
Severity.ERROR: "✖",
Severity.WARNING: "⚠",
Severity.INFO: "ℹ",
Severity.HINT: "💡",
}
return icons[self.severity]
class ProblemsSummary(BaseModel):
"""Summary of problems in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
errors: int = 0
warnings: int = 0
infos: int = 0
hints: int = 0
@property
def total(self) -> int:
"""Total number of problems."""
return self.errors + self.warnings + self.infos + self.hints
@property
def display_text(self) -> str:
"""Text for tab badge."""
if self.errors:
return f"⚠ {self.errors}"
if self.warnings:
return f"⚠ {self.warnings}"
return f"✓ {self.total}"
class ProblemsState(BaseModel):
"""State of the problems panel."""
model_config = ConfigDict(strict=True)
problems: list[Problem] = []
summary: ProblemsSummary = ProblemsSummary()
filter_severity: Severity | None = None
filter_source: str | None = None
selected_index: int | None = None
def problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file."""
return [p for p in self.problems if p.file_path == path]
+102
View File
@@ -0,0 +1,102 @@
"""Theme-related Pydantic models."""
import re
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, field_validator
if TYPE_CHECKING:
from textual.theme import Theme
def validate_hex_color(value: str) -> str:
"""Validate hex color format."""
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
raise ValueError(f"Invalid hex color: {value}")
return value.lower()
class ThemeColors(BaseModel):
"""Color definitions for a theme."""
model_config = ConfigDict(strict=True, frozen=True)
# Core colors
primary: str
secondary: str
accent: str
# Backgrounds
background: str
surface: str
panel: str
# Text
foreground: str
# Status
success: str
warning: str
error: str
@field_validator("*", mode="before")
@classmethod
def validate_colors(cls, v: str) -> str:
"""Validate all color fields are valid hex."""
return validate_hex_color(v)
class ThemeDefinition(BaseModel):
"""Complete theme definition."""
model_config = ConfigDict(strict=True)
name: str # e.g., "summer-night"
display_name: str # e.g., "Summer Night"
dark: bool # True for dark themes
colors: ThemeColors
def to_textual_colors(self) -> dict[str, str]:
"""Convert to Textual theme color dict."""
return {
"primary": self.colors.primary,
"secondary": self.colors.secondary,
"accent": self.colors.accent,
"background": self.colors.background,
"surface": self.colors.surface,
"panel": self.colors.panel,
"foreground": self.colors.foreground,
"success": self.colors.success,
"warning": self.colors.warning,
"error": self.colors.error,
}
def to_textual_theme(self) -> "Theme":
"""Convert to a Textual Theme object."""
from textual.theme import Theme
return Theme(
name=self.name,
primary=self.colors.primary,
secondary=self.colors.secondary,
accent=self.colors.accent,
background=self.colors.background,
surface=self.colors.surface,
panel=self.colors.panel,
foreground=self.colors.foreground,
success=self.colors.success,
warning=self.colors.warning,
error=self.colors.error,
dark=self.dark,
)
class ThemeMetadata(BaseModel):
"""Theme metadata for listing themes."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
display_name: str
dark: bool
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
+93
View File
@@ -0,0 +1,93 @@
"""TODO comments Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class TodoType(str, Enum):
"""Type of TODO comment."""
TODO = "TODO"
FIXME = "FIXME"
HACK = "HACK"
XXX = "XXX"
NOTE = "NOTE"
BUG = "BUG"
OPTIMIZE = "OPTIMIZE"
REVIEW = "REVIEW"
class TodoItem(BaseModel):
"""A single TODO comment found in code."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
todo_type: TodoType
text: str
context_line: str # The full line containing the TODO
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}"
@property
def type_icon(self) -> str:
"""Icon for TODO type."""
icons = {
TodoType.TODO: "☐",
TodoType.FIXME: "🔧",
TodoType.HACK: "⚡",
TodoType.XXX: "❗",
TodoType.NOTE: "📝",
TodoType.BUG: "🐛",
TodoType.OPTIMIZE: "⚡",
TodoType.REVIEW: "👀",
}
return icons[self.todo_type]
class TodosSummary(BaseModel):
"""Summary of TODOs in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
todo_count: int = 0
fixme_count: int = 0
hack_count: int = 0
other_count: int = 0
@property
def total(self) -> int:
"""Total number of TODOs."""
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
@property
def display_text(self) -> str:
"""Text for tab badge."""
return f"✓{self.total}"
class TodosState(BaseModel):
"""State of the TODOs panel."""
model_config = ConfigDict(strict=True)
items: list[TodoItem] = []
summary: TodosSummary = TodosSummary()
filter_type: TodoType | None = None
selected_index: int | None = None
group_by_file: bool = True
def items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODO items for a specific file."""
return [item for item in self.items if item.file_path == path]
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Get TODO items of a specific type."""
return [item for item in self.items if item.todo_type == todo_type]
+17
View File
@@ -0,0 +1,17 @@
"""Business logic services for Clide."""
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.todo_scanner import TodoScanner
__all__ = [
"GitService",
"LinterService",
"ProcessService",
"SettingsService",
"TodoScanner",
"UserSettings",
"get_settings_service",
]
+124
View File
@@ -0,0 +1,124 @@
"""File operations service."""
from pathlib import Path
class FileService:
"""Service for file I/O operations."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
async def read_file(self, path: Path) -> str:
"""Read file contents.
Args:
path: Path to file (relative or absolute)
Returns:
File contents as string
"""
full_path = self._resolve_path(path)
return full_path.read_text(encoding="utf-8")
async def write_file(self, path: Path, content: str) -> None:
"""Write content to file.
Args:
path: Path to file
content: Content to write
"""
full_path = self._resolve_path(path)
full_path.write_text(content, encoding="utf-8")
async def file_exists(self, path: Path) -> bool:
"""Check if file exists.
Args:
path: Path to check
Returns:
True if file exists
"""
full_path = self._resolve_path(path)
return full_path.exists() and full_path.is_file()
async def get_language(self, path: Path) -> str | None:
"""Detect language from file extension.
Args:
path: File path
Returns:
Language identifier or None
"""
extension_map = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".jsx": "jsx",
".tsx": "tsx",
".html": "html",
".css": "css",
".scss": "scss",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".md": "markdown",
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".rb": "ruby",
".php": "php",
".sh": "bash",
".bash": "bash",
".sql": "sql",
".xml": "xml",
".vue": "vue",
".svelte": "svelte",
}
return extension_map.get(path.suffix.lower())
def _resolve_path(self, path: Path) -> Path:
"""Resolve path relative to project root.
Args:
path: Path to resolve
Returns:
Absolute path
"""
if path.is_absolute():
return path
return self.project_path / path
def list_directory(self, path: Path | None = None) -> list[Path]:
"""List directory contents.
Args:
path: Directory path (defaults to project root)
Returns:
List of paths in directory
"""
dir_path = self._resolve_path(path) if path else self.project_path
if not dir_path.is_dir():
return []
entries = []
for entry in sorted(dir_path.iterdir()):
# Skip hidden files and common excludes
if entry.name.startswith("."):
continue
if entry.name in ("__pycache__", "node_modules", ".git"):
continue
entries.append(entry)
# Sort: directories first, then files
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
return entries
+256
View File
@@ -0,0 +1,256 @@
"""Git operations service."""
from pathlib import Path
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitStatus,
)
from clide.services.process_service import ProcessService
class GitService:
"""Service for git operations."""
def __init__(self, repo_path: Path) -> None:
self.repo_path = repo_path
self._process = ProcessService(cwd=repo_path)
async def get_status(self) -> GitStatus:
"""Get current git status.
Returns:
GitStatus with staged/unstaged changes
"""
# Get porcelain status
result = await self._process.run("git", "status", "--porcelain", "-z")
staged: list[GitChange] = []
unstaged: list[GitChange] = []
untracked: list[str] = []
if result.success and result.stdout:
entries = result.stdout.split("\0")
for entry in entries:
if not entry or len(entry) < 3:
continue
index_status = entry[0]
worktree_status = entry[1]
path = entry[3:]
# Parse status
if index_status == "?":
untracked.append(path)
else:
if index_status != " ":
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,
))
# Get current branch
branch_result = await self._process.run(
"git", "branch", "--show-current"
)
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
# Get ahead/behind
ahead, behind = await self._get_ahead_behind(branch)
return GitStatus(
branch=branch,
ahead=ahead,
behind=behind,
staged=tuple(staged),
unstaged=tuple(unstaged),
untracked=tuple(untracked),
)
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}}"
)
if result.success:
parts = result.stdout.strip().split()
if len(parts) == 2:
return int(parts[0]), int(parts[1])
return 0, 0
def _parse_status(self, char: str) -> ChangeStatus:
"""Parse git status character to ChangeStatus."""
mapping = {
"A": ChangeStatus.ADDED,
"M": ChangeStatus.MODIFIED,
"D": ChangeStatus.DELETED,
"R": ChangeStatus.RENAMED,
"C": ChangeStatus.COPIED,
"?": ChangeStatus.UNTRACKED,
"!": ChangeStatus.IGNORED,
"U": ChangeStatus.UNMERGED,
}
return mapping.get(char, ChangeStatus.MODIFIED)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
result = await self._process.run("git", "add", path)
return result.success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
result = await self._process.run("git", "restore", "--staged", path)
return result.success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path to discard
Returns:
True if successful
"""
result = await self._process.run("git", "restore", path)
return result.success
async def get_branches(self) -> list[GitBranch]:
"""Get list of branches.
Returns:
List of GitBranch objects
"""
result = await self._process.run(
"git", "branch", "-a", "--format",
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)"
)
branches: list[GitBranch] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
is_current = line.startswith("*")
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],
))
return branches
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name to checkout
Returns:
True if successful
"""
result = await self._process.run("git", "checkout", branch)
return result.success
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
"""Create a new branch.
Args:
name: New branch name
start_point: Optional starting commit/branch
Returns:
True if successful
"""
args = ["git", "checkout", "-b", name]
if start_point:
args.append(start_point)
result = await self._process.run(*args)
return result.success
async def get_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff output string
"""
args = ["git", "diff"]
if staged:
args.append("--cached")
args.append("--")
args.append(path)
result = await self._process.run(*args)
return result.stdout if result.success else ""
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
"""Get commit log.
Args:
max_count: Maximum number of commits to return
Returns:
List of GitCommit objects
"""
result = await self._process.run(
"git", "log",
f"--max-count={max_count}",
"--format=%H|%h|%s|%an|%ar|%P|%D",
"--all",
)
commits: list[GitCommit] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|")
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,
))
return commits
+127
View File
@@ -0,0 +1,127 @@
"""Linter integration service."""
import json
from pathlib import Path
from clide.models.problems import Problem, ProblemsSummary, Severity
from clide.services.process_service import ProcessService
class LinterService:
"""Service for running linters and parsing output."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def run_ruff(self) -> list[Problem]:
"""Run ruff linter.
Returns:
List of problems found
"""
result = await self._process.run(
"ruff", "check", "--output-format=json", "."
)
problems: list[Problem] = []
if result.stdout:
try:
data = json.loads(result.stdout)
for item in data:
severity = self._ruff_severity(item.get("code", ""))
problems.append(Problem(
file_path=Path(item["filename"]),
line=item["location"]["row"],
column=item["location"]["column"],
end_line=item.get("end_location", {}).get("row"),
end_column=item.get("end_location", {}).get("column"),
severity=severity,
message=item["message"],
source="ruff",
code=item.get("code"),
))
except json.JSONDecodeError:
pass
return problems
def _ruff_severity(self, code: str) -> Severity:
"""Map ruff code to severity."""
if code.startswith("E") or code.startswith("F"):
return Severity.ERROR
if code.startswith("W"):
return Severity.WARNING
return Severity.INFO
async def run_mypy(self) -> list[Problem]:
"""Run mypy type checker.
Returns:
List of problems found
"""
result = await self._process.run(
"mypy", "--output=json", "."
)
problems: list[Problem] = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
try:
data = json.loads(line)
severity = self._mypy_severity(data.get("severity", "error"))
problems.append(Problem(
file_path=Path(data["file"]),
line=data["line"],
column=data.get("column", 1),
severity=severity,
message=data["message"],
source="mypy",
code=data.get("code"),
))
except (json.JSONDecodeError, KeyError):
continue
return problems
def _mypy_severity(self, severity: str) -> Severity:
"""Map mypy severity to Severity enum."""
mapping = {
"error": Severity.ERROR,
"warning": Severity.WARNING,
"note": Severity.INFO,
}
return mapping.get(severity, Severity.ERROR)
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
"""Run all configured linters.
Args:
linters: List of linter names to run
Returns:
Tuple of (problems list, summary)
"""
all_problems: list[Problem] = []
for linter in linters:
if linter == "ruff":
all_problems.extend(await self.run_ruff())
elif linter == "mypy":
all_problems.extend(await self.run_mypy())
# Create summary
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
summary = ProblemsSummary(
errors=errors,
warnings=warnings,
infos=infos,
hints=hints,
)
return all_problems, summary
+124
View File
@@ -0,0 +1,124 @@
"""Generic subprocess management service."""
import asyncio
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CommandResult:
"""Result of a command execution."""
returncode: int
stdout: str
stderr: str
@property
def success(self) -> bool:
"""Check if command succeeded."""
return self.returncode == 0
class ProcessService:
"""Service for running subprocess commands."""
def __init__(self, cwd: Path | None = None) -> None:
self.cwd = cwd or Path.cwd()
async def run(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
env: dict[str, str] | None = None,
) -> CommandResult:
"""Run a command asynchronously.
Args:
*args: Command and arguments
cwd: Working directory (defaults to service cwd)
timeout: Timeout in seconds
env: Environment variables to add
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
env=env,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
return CommandResult(
returncode=process.returncode or 0,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
)
except TimeoutError:
process.kill()
return CommandResult(
returncode=-1,
stdout="",
stderr="Command timed out",
)
except Exception as e:
return CommandResult(
returncode=-1,
stdout="",
stderr=str(e),
)
def run_sync(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
) -> CommandResult:
"""Run a command synchronously (for use in threads).
Args:
*args: Command and arguments
cwd: Working directory
timeout: Timeout in seconds
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
result = subprocess.run(
args,
capture_output=True,
cwd=working_dir,
timeout=timeout,
text=True,
)
return CommandResult(
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
)
except subprocess.TimeoutExpired:
return CommandResult(
returncode=-1,
stdout="",
stderr="Command timed out",
)
except Exception as e:
return CommandResult(
returncode=-1,
stdout="",
stderr=str(e),
)
+159
View File
@@ -0,0 +1,159 @@
"""Settings persistence service for user preferences."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pydantic import BaseModel
class UserSettings(BaseModel):
"""User settings that persist across sessions.
Stored in ~/.clide/settings.json
"""
# Appearance
theme: str = "summer-night"
# Panel state
sidebar_visible: bool = True
context_visible: bool = True
# Window
compact_mode: bool = False
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
class SettingsService:
"""Service for loading and saving user settings.
Settings are stored in ~/.clide/settings.json
"""
def __init__(self, settings_dir: Path | None = None) -> None:
"""Initialize the settings service.
Args:
settings_dir: Override the settings directory (default: ~/.clide)
"""
self._settings_dir = settings_dir or Path.home() / ".clide"
self._settings_file = self._settings_dir / "settings.json"
self._settings: UserSettings | None = None
@property
def settings_dir(self) -> Path:
"""Get the settings directory path."""
return self._settings_dir
@property
def settings_file(self) -> Path:
"""Get the settings file path."""
return self._settings_file
def load(self) -> UserSettings:
"""Load settings from disk, creating defaults if needed.
Returns:
The loaded or default UserSettings
"""
if self._settings is not None:
return self._settings
if self._settings_file.exists():
try:
data = json.loads(self._settings_file.read_text())
self._settings = UserSettings.model_validate(data)
except (json.JSONDecodeError, ValueError):
# Invalid JSON or schema, use defaults
self._settings = UserSettings()
else:
self._settings = UserSettings()
return self._settings
def save(self) -> None:
"""Save current settings to disk."""
if self._settings is None:
return
# Ensure directory exists
self._settings_dir.mkdir(parents=True, exist_ok=True)
# Write settings as formatted JSON
data = self._settings.model_dump(mode="json")
self._settings_file.write_text(
json.dumps(data, indent=2, sort_keys=True) + "\n"
)
def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value.
Args:
key: The setting key (attribute name)
default: Default value if key doesn't exist
Returns:
The setting value or default
"""
settings = self.load()
return getattr(settings, key, default)
def set(self, key: str, value: Any, *, save: bool = True) -> None:
"""Set a setting value.
Args:
key: The setting key (attribute name)
value: The value to set
save: Whether to save immediately (default: True)
"""
settings = self.load()
if hasattr(settings, key):
# Create new settings with updated value
data = settings.model_dump()
data[key] = value
self._settings = UserSettings.model_validate(data)
if save:
self.save()
def update(self, **kwargs: Any) -> None:
"""Update multiple settings at once.
Args:
**kwargs: Key-value pairs to update
"""
settings = self.load()
data = settings.model_dump()
for key, value in kwargs.items():
if hasattr(settings, key):
data[key] = value
self._settings = UserSettings.model_validate(data)
self.save()
def reset(self) -> None:
"""Reset settings to defaults."""
self._settings = UserSettings()
self.save()
# Global instance for convenience
_settings_service: SettingsService | None = None
def get_settings_service() -> SettingsService:
"""Get the global settings service instance."""
global _settings_service
if _settings_service is None:
_settings_service = SettingsService()
return _settings_service
+151
View File
@@ -0,0 +1,151 @@
"""TODO comment scanner service."""
import re
from pathlib import Path
from clide.models.todos import TodoItem, TodosSummary, TodoType
from clide.services.process_service import ProcessService
class TodoScanner:
"""Service for scanning TODO/FIXME comments in code."""
# Pattern to match TODO-style comments
TODO_PATTERN = re.compile(
r"(?:#|//|/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\s*:?\s*(.+?)(?:\*/|-->)?$",
re.IGNORECASE,
)
# File extensions to scan
SCAN_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
".go", ".rs", ".rb", ".php", ".css", ".scss", ".html", ".vue",
".svelte", ".md", ".sh", ".bash", ".yaml", ".yml", ".toml",
}
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def scan(self) -> tuple[list[TodoItem], TodosSummary]:
"""Scan project for TODO comments.
Returns:
Tuple of (todo items, summary)
"""
items: list[TodoItem] = []
# Use ripgrep if available for speed
result = await self._process.run(
"rg", "--line-number", "--no-heading",
"-e", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
"--type-add", "code:*.py",
"--type-add", "code:*.js",
"--type-add", "code:*.ts",
"--type", "code",
".",
)
if result.success:
items = self._parse_ripgrep_output(result.stdout)
else:
# Fallback to Python-based scanning
items = await self._scan_with_python()
# Create summary
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
other_count = len(items) - todo_count - fixme_count - hack_count
summary = TodosSummary(
todo_count=todo_count,
fixme_count=fixme_count,
hack_count=hack_count,
other_count=other_count,
)
return items, summary
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
"""Parse ripgrep output into TodoItems."""
items: list[TodoItem] = []
for line in output.strip().split("\n"):
if not line:
continue
# Format: path:line:content
parts = line.split(":", 2)
if len(parts) < 3:
continue
file_path = Path(parts[0])
try:
line_num = int(parts[1])
except ValueError:
continue
content = parts[2]
# Parse the TODO type and text
match = self.TODO_PATTERN.search(content)
if match:
todo_type_str = match.group(1).upper()
todo_text = match.group(2).strip()
try:
todo_type = TodoType(todo_type_str)
except ValueError:
todo_type = TodoType.TODO
items.append(TodoItem(
file_path=file_path,
line=line_num,
column=content.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=content.strip(),
))
return items
async def _scan_with_python(self) -> list[TodoItem]:
"""Fallback Python-based scanning."""
items: list[TodoItem] = []
for ext in self.SCAN_EXTENSIONS:
for file_path in self.project_path.rglob(f"*{ext}"):
# Skip hidden directories and common excludes
if any(part.startswith(".") for part in file_path.parts):
continue
if "node_modules" in file_path.parts:
continue
if "__pycache__" in file_path.parts:
continue
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
for line_num, line in enumerate(content.split("\n"), 1):
match = self.TODO_PATTERN.search(line)
if match:
todo_type_str = match.group(1).upper()
todo_text = match.group(2).strip()
try:
todo_type = TodoType(todo_type_str)
except ValueError:
todo_type = TodoType.TODO
items.append(TodoItem(
file_path=file_path.relative_to(self.project_path),
line=line_num,
column=line.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=line.strip(),
))
except (OSError, UnicodeDecodeError):
continue
return items
+17
View File
@@ -0,0 +1,17 @@
"""Theme system for Clide."""
from clide.themes.registry import (
DEFAULT_THEME,
get_all_themes,
get_theme,
get_themes_by_category,
register_theme,
)
__all__ = [
"get_theme",
"get_all_themes",
"get_themes_by_category",
"register_theme",
"DEFAULT_THEME",
]
+51
View File
@@ -0,0 +1,51 @@
"""Built-in themes for Clide."""
from clide.themes.builtin import (
all_hallows_eve,
christmas,
dark_autumn,
dracula,
fall,
gamma,
gruvbox_dark,
gruvbox_light,
hacker_style,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
__all__ = [
"summer_night",
"summer_day",
"one_dark",
"one_dark_pro",
"one_light",
"dracula",
"nord",
"gruvbox_dark",
"gruvbox_light",
"one_dark_teal",
"gamma",
"winter_is_coming",
"monokai_winter",
"fall",
"dark_autumn",
"all_hallows_eve",
"halloween",
"christmas",
"santa_baby",
"pro_hacker",
"hacker_style",
"houston",
]
+21
View File
@@ -0,0 +1,21 @@
"""All Hallows' Eve Plus theme - Halloween."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="all-hallows-eve",
display_name="All Hallows' Eve",
dark=True,
colors=ThemeColors(
primary="#ff7518", # Pumpkin orange
secondary="#9932cc", # Dark orchid
accent="#ff6347",
background="#1a0a1a",
surface="#2d1a2d",
panel="#401a40",
foreground="#dda0dd",
success="#32cd32",
warning="#ff7518",
error="#dc143c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="christmas",
display_name="Christmas",
dark=True,
colors=ThemeColors(
primary="#ff0000", # Christmas red
secondary="#228b22", # Forest green
accent="#ffd700", # Gold
background="#0a1a0a",
surface="#1a2a1a",
panel="#2a3a2a",
foreground="#f0f0f0",
success="#228b22",
warning="#ffd700",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Dark Autumn Frost theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dark-autumn",
display_name="Dark Autumn Frost",
dark=True,
colors=ThemeColors(
primary="#c49a6c",
secondary="#8b7355",
accent="#a0522d",
background="#1c1410",
surface="#2a1f18",
panel="#382a20",
foreground="#d2b48c",
success="#6b8e23",
warning="#b8860b",
error="#8b0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Dracula theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dracula",
display_name="Dracula",
dark=True,
colors=ThemeColors(
primary="#bd93f9",
secondary="#8be9fd",
accent="#ff79c6",
background="#282a36",
surface="#21222c",
panel="#343746",
foreground="#f8f8f2",
success="#50fa7b",
warning="#ffb86c",
error="#ff5555",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Fall theme - Autumn colors."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="fall",
display_name="Fall",
dark=True,
colors=ThemeColors(
primary="#e9967a",
secondary="#daa520",
accent="#cd853f",
background="#2d1f1f",
surface="#3d2929",
panel="#4d3333",
foreground="#f5deb3",
success="#8fbc8f",
warning="#daa520",
error="#cd5c5c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gamma theme - GitKraken Gamma style."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gamma",
display_name="Gamma",
dark=True,
colors=ThemeColors(
primary="#00d4aa",
secondary="#7c3aed",
accent="#f472b6",
background="#0f172a",
surface="#1e293b",
panel="#334155",
foreground="#e2e8f0",
success="#22c55e",
warning="#f59e0b",
error="#ef4444",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gruvbox Dark theme - Retro groove color scheme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gruvbox-dark",
display_name="Gruvbox Dark",
dark=True,
colors=ThemeColors(
primary="#83a598",
secondary="#8ec07c",
accent="#d3869b",
background="#282828",
surface="#3c3836",
panel="#504945",
foreground="#ebdbb2",
success="#b8bb26",
warning="#fabd2f",
error="#fb4934",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gruvbox Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gruvbox-light",
display_name="Gruvbox Light",
dark=False,
colors=ThemeColors(
primary="#076678",
secondary="#427b58",
accent="#8f3f71",
background="#fbf1c7",
surface="#ebdbb2",
panel="#d5c4a1",
foreground="#3c3836",
success="#79740e",
warning="#b57614",
error="#9d0006",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Hacker Style theme - Matrix inspired."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="hacker-style",
display_name="Hacker Style",
dark=True,
colors=ThemeColors(
primary="#20c20e",
secondary="#33ff33",
accent="#66ff66",
background="#0c0c0c",
surface="#121212",
panel="#1a1a1a",
foreground="#33ff33",
success="#20c20e",
warning="#c0c020",
error="#c02020",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Halloween theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="halloween",
display_name="Halloween",
dark=True,
colors=ThemeColors(
primary="#ff6600",
secondary="#8a2be2",
accent="#ff4500",
background="#0d0d0d",
surface="#1a1a1a",
panel="#262626",
foreground="#e6e6e6",
success="#00ff00",
warning="#ff6600",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Houston theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="houston",
display_name="Houston",
dark=True,
colors=ThemeColors(
primary="#ff6f00",
secondary="#00bcd4",
accent="#ff4081",
background="#17212b",
surface="#232e3c",
panel="#2e3a48",
foreground="#eeffff",
success="#4caf50",
warning="#ff9800",
error="#f44336",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Monokai Winter Night theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="monokai-winter",
display_name="Monokai Winter Night",
dark=True,
colors=ThemeColors(
primary="#66d9ef",
secondary="#a6e22e",
accent="#f92672",
background="#1a1a2e",
surface="#16213e",
panel="#0f3460",
foreground="#f8f8f2",
success="#a6e22e",
warning="#e6db74",
error="#f92672",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Nord theme - Arctic, north-bluish color palette."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="nord",
display_name="Nord",
dark=True,
colors=ThemeColors(
primary="#88c0d0",
secondary="#81a1c1",
accent="#b48ead",
background="#2e3440",
surface="#3b4252",
panel="#434c5e",
foreground="#eceff4",
success="#a3be8c",
warning="#ebcb8b",
error="#bf616a",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Dark theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark",
display_name="One Dark",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#98c379",
warning="#e5c07b",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""One Dark Pro theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark-pro",
display_name="One Dark Pro",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#1e2227",
panel="#333842",
foreground="#abb2bf",
success="#98c379",
warning="#d19a66",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""One Dark Teal theme - GitKraken signature teal accent."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark-teal",
display_name="One Dark Teal",
dark=True,
colors=ThemeColors(
primary="#2acf9f", # GitKraken teal
secondary="#61afef",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#2acf9f",
warning="#e5c07b",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-light",
display_name="One Light",
dark=False,
colors=ThemeColors(
primary="#4078f2",
secondary="#0184bc",
accent="#a626a4",
background="#fafafa",
surface="#f0f0f0",
panel="#e5e5e6",
foreground="#383a42",
success="#50a14f",
warning="#c18401",
error="#e45649",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Pro Hacker theme - Green on black."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="pro-hacker",
display_name="Pro Hacker",
dark=True,
colors=ThemeColors(
primary="#00ff00",
secondary="#00cc00",
accent="#00ff88",
background="#000000",
surface="#0a0a0a",
panel="#141414",
foreground="#00ff00",
success="#00ff00",
warning="#ffff00",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Santa Baby theme - Light Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="santa-baby",
display_name="Santa Baby",
dark=False,
colors=ThemeColors(
primary="#c41e3a", # Cardinal red
secondary="#228b22", # Forest green
accent="#b8860b", # Dark goldenrod
background="#fff8f0",
surface="#f0e8e0",
panel="#e0d8d0",
foreground="#2f1f1f",
success="#228b22",
warning="#daa520",
error="#c41e3a",
),
)
+24
View File
@@ -0,0 +1,24 @@
"""Summer Day theme - Light variant of Summer Night.
Inverted lightness scale with adjusted accent hues for readability.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="summer-day",
display_name="Summer Day",
dark=False,
colors=ThemeColors(
primary="#0088b0",
secondary="#008a99",
accent="#d03060",
background="#f5f7fa",
surface="#e8ebf0",
panel="#dde1e8",
foreground="#21262f",
success="#008a7a",
warning="#b06830",
error="#c04048",
),
)
+45
View File
@@ -0,0 +1,45 @@
"""Summer Night theme - Default dark theme.
Based on jackw01/summer-night-vscode-theme.
Vibrant colors with HCL-based monochrome scale.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
# Monochrome scale (HCL equidistant lightness)
# mono_1: #e2e8f5 - Lightest text
# mono_2: #c4c9d6 - Secondary text
# mono_3: #a6abb8 - Muted text
# mono_4: #898e9a - Comments
# mono_5: #6d727e - Subtle
# mono_6: #525762 - Borders
# mono_7: #393e48 - Surface
# mono_8: #21262f - Background
# Accent colors (HCL analogous scales)
# cyan: #00a3d2 - Primary accent
# teal: #00a9b9 - Links
# pink: #fa5f8b - Keywords
# yellow: #d3ab58 - Strings
# red: #f06c6f - Errors
# orange: #d08447 - Warnings
# coral: #e17954 - Functions
# green: #00ab9a - Success
theme = ThemeDefinition(
name="summer-night",
display_name="Summer Night",
dark=True,
colors=ThemeColors(
primary="#00a3d2",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Winter Is Coming theme - Bluish, icy vibe."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="winter-is-coming",
display_name="Winter Is Coming",
dark=True,
colors=ThemeColors(
primary="#89ddff",
secondary="#82aaff",
accent="#c792ea",
background="#011627",
surface="#0d293e",
panel="#1d3b53",
foreground="#d6deeb",
success="#22da6e",
warning="#ecc48d",
error="#ef5350",
),
)
+87
View File
@@ -0,0 +1,87 @@
"""Custom theme loader for user-defined themes."""
import tomllib
from pathlib import Path
from clide.models.theme import ThemeColors, ThemeDefinition
from clide.themes.registry import register_theme
def load_custom_themes(themes_dir: Path) -> list[str]:
"""Load custom themes from a directory.
Args:
themes_dir: Directory containing .toml theme files
Returns:
List of loaded theme names
"""
loaded = []
if not themes_dir.exists():
return loaded
for theme_file in themes_dir.glob("*.toml"):
try:
theme = load_theme_file(theme_file)
if theme:
register_theme(theme, "custom")
loaded.append(theme.name)
except Exception as e:
# Log but don't crash on bad theme files
print(f"Failed to load theme {theme_file}: {e}")
return loaded
def load_theme_file(path: Path) -> ThemeDefinition | None:
"""Load a single theme from a TOML file.
Args:
path: Path to the theme TOML file
Returns:
Theme definition or None if invalid
Example TOML format:
name = "my-theme"
display_name = "My Custom Theme"
dark = true
[colors]
primary = "#007acc"
secondary = "#3c3c3c"
accent = "#0e639c"
background = "#1e1e1e"
surface = "#252526"
panel = "#2d2d2d"
foreground = "#d4d4d4"
success = "#4ec9b0"
warning = "#dcdcaa"
error = "#f14c4c"
"""
with open(path, "rb") as f:
data = tomllib.load(f)
# Validate required fields
required = ["name", "display_name", "dark", "colors"]
for field in required:
if field not in data:
raise ValueError(f"Missing required field: {field}")
colors_data = data["colors"]
color_fields = [
"primary", "secondary", "accent", "background", "surface",
"panel", "foreground", "success", "warning", "error"
]
for field in color_fields:
if field not in colors_data:
raise ValueError(f"Missing color field: {field}")
colors = ThemeColors(**colors_data)
return ThemeDefinition(
name=data["name"],
display_name=data["display_name"],
dark=data["dark"],
colors=colors,
)
+139
View File
@@ -0,0 +1,139 @@
"""Theme registry for managing available themes."""
from clide.models.theme import ThemeDefinition, ThemeMetadata
# Theme registry
_themes: dict[str, ThemeDefinition] = {}
_theme_metadata: dict[str, ThemeMetadata] = {}
DEFAULT_THEME = "summer-night"
def register_theme(
theme: ThemeDefinition,
category: str = "custom",
) -> None:
"""Register a theme in the registry.
Args:
theme: Theme definition to register
category: Theme category (core, popular, seasonal, custom)
"""
_themes[theme.name] = theme
_theme_metadata[theme.name] = ThemeMetadata(
name=theme.name,
display_name=theme.display_name,
dark=theme.dark,
category=category,
)
def get_theme(name: str) -> ThemeDefinition | None:
"""Get a theme by name.
Args:
name: Theme name
Returns:
Theme definition or None if not found
"""
return _themes.get(name)
def get_all_themes() -> list[ThemeMetadata]:
"""Get metadata for all registered themes.
Returns:
List of theme metadata sorted by category then name
"""
themes = list(_theme_metadata.values())
# Sort: core first, then alphabetically by category, then by name
category_order = {"core": 0, "popular": 1, "gitkraken": 2, "seasonal": 3, "hacker": 4, "custom": 5}
themes.sort(key=lambda t: (category_order.get(t.category, 99), t.name))
return themes
def get_themes_by_category(category: str) -> list[ThemeMetadata]:
"""Get themes filtered by category.
Args:
category: Category to filter by
Returns:
List of theme metadata in that category
"""
return [t for t in _theme_metadata.values() if t.category == category]
def _load_builtin_themes() -> None:
"""Load all built-in themes."""
# Import here to avoid circular imports
from clide.themes.builtin import (
all_hallows_eve,
christmas,
dark_autumn,
dracula,
fall,
gamma,
gruvbox_dark,
gruvbox_light,
hacker_style,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
# Core themes
register_theme(summer_night.theme, "core")
register_theme(summer_day.theme, "core")
# Popular themes
register_theme(one_dark.theme, "popular")
register_theme(one_dark_pro.theme, "popular")
register_theme(one_light.theme, "popular")
register_theme(dracula.theme, "popular")
register_theme(nord.theme, "popular")
register_theme(gruvbox_dark.theme, "popular")
register_theme(gruvbox_light.theme, "popular")
# GitKraken style
register_theme(one_dark_teal.theme, "gitkraken")
register_theme(gamma.theme, "gitkraken")
# Seasonal - Winter
register_theme(winter_is_coming.theme, "seasonal")
register_theme(monokai_winter.theme, "seasonal")
# Seasonal - Fall
register_theme(fall.theme, "seasonal")
register_theme(dark_autumn.theme, "seasonal")
# Seasonal - Halloween
register_theme(all_hallows_eve.theme, "seasonal")
register_theme(halloween.theme, "seasonal")
# Seasonal - Christmas
register_theme(christmas.theme, "seasonal")
register_theme(santa_baby.theme, "seasonal")
# Hacker style
register_theme(pro_hacker.theme, "hacker")
register_theme(hacker_style.theme, "hacker")
# Bonus
register_theme(houston.theme, "popular")
# Load built-in themes on module import
_load_builtin_themes()
+25
View File
@@ -0,0 +1,25 @@
"""Textual widgets for Clide UI."""
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
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.todos_view import TodosView
__all__ = [
"BranchStatus",
"DiffPane",
"EditorPane",
"FilesView",
"GitChangesView",
"GitGraphView",
"JiraView",
"ProblemsView",
"TerminalPane",
"TodosView",
]
+25
View File
@@ -0,0 +1,25 @@
"""Reusable UI components for Clide."""
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
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.todos_view import TodosView
__all__ = [
"BranchStatus",
"DiffPane",
"EditorPane",
"FilesView",
"GitChangesView",
"GitGraphView",
"JiraView",
"ProblemsView",
"TerminalPane",
"TodosView",
]
+161
View File
@@ -0,0 +1,161 @@
"""Branch status bar component."""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widgets import Button, Label, ListItem, ListView, Static
from clide.models.git import GitBranch
class BranchStatus(Vertical):
"""Branch status bar with popout branch selector."""
DEFAULT_CSS = """
BranchStatus {
height: auto;
dock: bottom;
}
BranchStatus .status-bar {
height: 1;
background: $surface;
padding: 0 1;
}
BranchStatus .branch-icon {
width: 2;
}
BranchStatus .branch-name {
width: 1fr;
}
BranchStatus .popout {
display: none;
height: auto;
max-height: 15;
background: $panel;
border: solid $primary;
layer: popout;
}
BranchStatus .popout.visible {
display: block;
}
BranchStatus .popout-header {
background: $surface;
padding: 0 1;
text-style: bold;
}
BranchStatus .popout-actions {
height: 1;
padding: 0 1;
}
"""
class BranchChanged(Message):
"""Emitted when branch is changed."""
def __init__(self, branch: str) -> None:
self.branch = branch
super().__init__()
# Alias for backwards compatibility
BranchChangeRequested = BranchChanged
class NewBranchRequested(Message):
"""Emitted when new branch creation is requested."""
pass
def __init__(
self,
current_branch: str = "main",
branches: list[GitBranch] | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._current = current_branch
self._branches = branches or []
self._popout_visible = False
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")
with Vertical(classes="popout", id="branch-popout"):
yield Label("Recent branches", classes="popout-header")
yield ListView(
*[ListItem(Label(b.name)) for b in self._branches[:5]],
id="branch-list",
)
with Horizontal(classes="popout-actions"):
yield Button("Checkout", id="btn-checkout", variant="primary")
yield Button("New", id="btn-new")
@property
def branch(self) -> str:
"""Get current branch."""
return self._current
@branch.setter
def branch(self, value: str) -> None:
"""Set current branch."""
self._current = value
try:
self.query_one("#branch-name", Static).update(value)
except Exception:
pass
def update_branch(self, branch: str) -> None:
"""Update current branch display."""
self.branch = branch
def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
"""Update branches list."""
self._branches = branches # type: ignore
try:
branch_list = self.query_one("#branch-list", ListView)
branch_list.clear()
for branch in branches[:5]:
if isinstance(branch, str):
name = branch
is_current = name == self._current
else:
name = branch.name
is_current = branch.is_current
marker = "● " if is_current else "○ "
branch_list.append(ListItem(Label(f"{marker}{name}")))
except Exception:
pass
def toggle_popout(self) -> None:
"""Toggle popout visibility."""
self._popout_visible = not self._popout_visible
popout = self.query_one("#branch-popout")
if self._popout_visible:
popout.add_class("visible")
else:
popout.remove_class("visible")
def on_click(self) -> None:
"""Handle click on status bar."""
self.toggle_popout()
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("● ○ ")
self.post_message(self.BranchChangeRequested(branch))
self.toggle_popout()
elif event.button.id == "btn-new":
self.post_message(self.NewBranchRequested())
self.toggle_popout()
+148
View File
@@ -0,0 +1,148 @@
"""Diff pane component for viewing diffs."""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widgets import Button, RichLog, Static
from clide.models.diff import ChangeType, DiffContent
class DiffPane(Vertical):
"""Diff viewer pane with accept/reject for proposals."""
DEFAULT_CSS = """
DiffPane {
height: 100%;
}
DiffPane .diff-header {
height: 1;
background: $surface;
padding: 0 1;
}
DiffPane .diff-content {
height: 1fr;
}
DiffPane .diff-actions {
height: auto;
padding: 1;
background: $panel;
}
DiffPane .added {
background: #1e3a1e;
color: #4ec9b0;
}
DiffPane .removed {
background: #3a1e1e;
color: #f14c4c;
}
DiffPane .hunk-header {
color: $accent;
text-style: bold;
}
"""
class AcceptClicked(Message):
"""Emitted when accept is clicked."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
class RejectClicked(Message):
"""Emitted when reject is clicked."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
def __init__(
self,
diff: DiffContent | None = None,
is_proposal: bool = False,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._diff = diff
self._is_proposal = is_proposal
def compose(self) -> ComposeResult:
if self._diff:
yield Static(f"Diff: {self._diff.file_path}", classes="diff-header")
else:
yield Static("No diff loaded", classes="diff-header")
yield RichLog(id="diff-log", highlight=True, markup=True, classes="diff-content")
if self._is_proposal:
with Horizontal(classes="diff-actions"):
yield Button("Accept", id="btn-accept", variant="success")
yield Button("Reject", id="btn-reject", variant="error")
def on_mount(self) -> None:
"""Render diff on mount."""
self._render_diff()
def load_diff(self, diff: DiffContent, is_proposal: bool = False) -> None:
"""Load a diff into the viewer."""
self._diff = diff
self._is_proposal = is_proposal
# Update header
header = self.query_one(".diff-header", Static)
header.update(f"Diff: {diff.file_path}")
# Show/hide action buttons
try:
actions = self.query_one(".diff-actions")
actions.display = is_proposal
except Exception:
pass
self._render_diff()
def _render_diff(self) -> None:
"""Render the diff content."""
log = self.query_one("#diff-log", RichLog)
log.clear()
if not self._diff:
log.write("[dim]No diff to display[/]")
return
for hunk in self._diff.hunks:
# Hunk header
log.write(f"[hunk-header]{hunk.header}[/]")
for line in hunk.lines:
if line.change_type == ChangeType.ADDED:
log.write(f"[green]+{line.content}[/]")
elif line.change_type == ChangeType.REMOVED:
log.write(f"[red]-{line.content}[/]")
else:
log.write(f" {line.content}")
def clear(self) -> None:
"""Clear the diff viewer."""
self._diff = None
log = self.query_one("#diff-log", RichLog)
log.clear()
header = self.query_one(".diff-header", Static)
header.update("No diff loaded")
def on_button_pressed(self, _event: Button.Pressed) -> None:
"""Handle button presses."""
if not self._diff:
return
if event.button.id == "btn-accept":
self.post_message(self.AcceptClicked(self._diff.file_path))
elif event.button.id == "btn-reject":
self.post_message(self.RejectClicked(self._diff.file_path))
+199
View File
@@ -0,0 +1,199 @@
"""Editor pane component with syntax highlighting."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Static, TextArea
from clide.models.editor import CursorPosition, FileBuffer
class EditorPane(Vertical):
"""Editor pane with TextArea and status bar."""
DEFAULT_CSS = """
EditorPane {
height: 100%;
}
EditorPane TextArea {
height: 1fr;
}
EditorPane .editor-status {
height: 1;
background: $surface;
padding: 0 1;
}
EditorPane .file-tab {
height: 1;
background: $panel;
}
EditorPane .modified {
color: $warning;
}
"""
class ContentChanged(Message):
"""Emitted when content changes."""
def __init__(self, path: Path, content: str) -> None:
self.path = path
self.content = content
super().__init__()
class CursorMoved(Message):
"""Emitted when cursor moves."""
def __init__(self, path: Path, line: int, column: int) -> None:
self.path = path
self.line = line
self.column = column
super().__init__()
class SaveRequested(Message):
"""Emitted when save is requested."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileSaved(Message):
"""Emitted when file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
def __init__(self, buffer: FileBuffer | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._buffer = buffer
def compose(self) -> ComposeResult:
if self._buffer:
yield Static(self._buffer.display_name, classes="file-tab")
yield TextArea(
self._buffer.content,
language=self._buffer.language,
id="editor-textarea",
show_line_numbers=True,
)
yield Static(
self._get_status_text(),
classes="editor-status",
id="editor-status",
)
else:
yield Static("No file open", classes="file-tab")
yield TextArea(id="editor-textarea", show_line_numbers=True)
yield Static("", classes="editor-status", id="editor-status")
def load_buffer(self, buffer: FileBuffer) -> None: # noqa: ARG002
"""Load a file buffer into the editor."""
self._buffer = buffer
textarea = self.query_one("#editor-textarea", TextArea)
textarea.load_text(buffer.content)
textarea.language = buffer.language
# Update tab
tab = self.query_one(".file-tab", Static)
tab.update(buffer.display_name)
# Update status
self._update_status()
# Set cursor position
if buffer.cursor:
textarea.cursor_location = (buffer.cursor.line, buffer.cursor.column)
def get_content(self) -> str:
"""Get current editor content."""
textarea = self.query_one("#editor-textarea", TextArea)
return textarea.text
def _get_status_text(self) -> str:
"""Generate status bar text."""
if not self._buffer:
return ""
line = self._buffer.cursor.line + 1 if self._buffer.cursor else 1
col = self._buffer.cursor.column + 1 if self._buffer.cursor else 1
lang = self._buffer.language or "plain text"
return f"Ln {line}, Col {col} | {lang}"
def _update_status(self) -> None:
"""Update status bar."""
status = self.query_one("#editor-status", Static)
status.update(self._get_status_text())
def on_text_area_changed(self, event: TextArea.Changed) -> None:
"""Handle text changes."""
if self._buffer:
self._buffer.content = event.text_area.text
self._buffer.is_modified = True
# Update tab to show modified indicator
tab = self.query_one(".file-tab", Static)
tab.update(self._buffer.display_name)
self.post_message(self.ContentChanged(self._buffer.path, event.text_area.text))
def on_text_area_selection_changed(self, event: TextArea.SelectionChanged) -> None:
"""Handle cursor movement."""
if self._buffer:
line, col = event.selection.end
# CursorPosition is frozen, so create new one
self._buffer.cursor = CursorPosition(line=line, column=col)
self._update_status()
self.post_message(self.CursorMoved(self._buffer.path, line, col))
@property
def current_file(self) -> Path | None:
"""Get currently open file path."""
return self._buffer.path if self._buffer else None
@property
def modified(self) -> bool:
"""Check if buffer has unsaved changes."""
return self._buffer.is_modified if self._buffer else False
def load_file(self, path: Path, goto_line: int | None = None) -> None:
"""Load a file from disk into the editor."""
from clide.services.file_service import FileService
content = FileService.read_file(path)
language = FileService.detect_language(path)
buffer = FileBuffer(
path=path,
content=content,
language=language,
is_modified=False,
)
self.load_buffer(buffer)
if goto_line is not None:
textarea = self.query_one("#editor-textarea", TextArea)
textarea.cursor_location = (goto_line - 1, 0)
def save(self) -> bool:
"""Save current buffer to disk."""
if not self._buffer:
return False
from clide.services.file_service import FileService
success = FileService.write_file(self._buffer.path, self._buffer.content)
if success:
self._buffer.is_modified = False
# Update tab
tab = self.query_one(".file-tab", Static)
tab.update(self._buffer.display_name)
self.post_message(self.FileSaved(self._buffer.path))
return success
+87
View File
@@ -0,0 +1,87 @@
"""File browser component using DirectoryTree."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich.text import Text
from textual.message import Message
from textual.widgets import DirectoryTree
if TYPE_CHECKING:
from rich.style import Style
from textual.widgets._directory_tree import DirEntry
from textual.widgets._tree import TreeNode
# Minimal Unicode icons (works with any font)
ICON_FOLDER_OPEN = "▾"
ICON_FOLDER_CLOSED = "▸"
ICON_FILE = "◦"
class FilesView(DirectoryTree):
"""File browser widget wrapping DirectoryTree."""
class FileSelected(Message):
"""Emitted when a file is selected."""
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
self.node = node
self.path = path
super().__init__()
class DirectorySelected(Message):
"""Emitted when a directory is selected."""
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
self.node = node
self.path = path
super().__init__()
def __init__(
self,
path: Path,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(
path,
name=name,
id=id,
classes=classes,
)
def render_label(
self, node: TreeNode[DirEntry], base_style: Style, style: Style
) -> Text:
"""Render a label with minimal Unicode icons."""
path = node.data.path
if path.is_dir():
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED
icon_style = "bold cyan"
else:
icon = ICON_FILE
icon_style = "dim"
label = Text()
label.append(f"{icon} ", style=icon_style)
label.append(path.name, style=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")
]
def refresh_tree(self) -> None:
"""Refresh the directory tree."""
self.reload()
+129
View File
@@ -0,0 +1,129 @@
"""Git changes view component."""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Label, ListItem, ListView, Static
from clide.models.git import ChangeStatus, GitChange
class GitChangeItem(ListItem):
"""A single git change item."""
STATUS_ICONS = {
ChangeStatus.ADDED: "+",
ChangeStatus.MODIFIED: "~",
ChangeStatus.DELETED: "-",
ChangeStatus.RENAMED: "→",
ChangeStatus.UNTRACKED: "?",
ChangeStatus.COPIED: "C",
ChangeStatus.UNMERGED: "!",
ChangeStatus.IGNORED: "I",
}
def __init__(self, change: GitChange) -> None:
super().__init__()
self.change = change
def compose(self) -> ComposeResult:
icon = self.STATUS_ICONS.get(self.change.status, "?")
status_class = self.change.status.value
yield Static(
f"[{status_class}]{icon}[/] {self.change.path}",
markup=True,
)
class GitChangesView(Vertical):
"""View for staged and unstaged git changes."""
DEFAULT_CSS = """
GitChangesView {
height: 100%;
}
GitChangesView .section-header {
background: $surface;
padding: 0 1;
text-style: bold;
}
GitChangesView ListView {
height: auto;
max-height: 50%;
}
GitChangesView .added { color: $success; }
GitChangesView .modified { color: $warning; }
GitChangesView .deleted { color: $error; }
GitChangesView .untracked { color: $accent; }
"""
class FileClicked(Message):
"""Emitted when a file is clicked."""
def __init__(self, change: GitChange) -> None:
self.change = change
super().__init__()
class StageRequested(Message):
"""Emitted when staging is requested."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
class UnstageRequested(Message):
"""Emitted when unstaging is requested."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
def __init__(
self,
staged: list[GitChange] | None = None,
unstaged: list[GitChange] | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._staged = staged or []
self._unstaged = unstaged or []
def compose(self) -> ComposeResult:
yield Label("Staged Changes", classes="section-header")
yield ListView(
*[GitChangeItem(c) for c in self._staged],
id="staged-list",
)
yield Label("Changes", classes="section-header")
yield ListView(
*[GitChangeItem(c) for c in self._unstaged],
id="unstaged-list",
)
def update_changes(
self,
staged: list[GitChange],
unstaged: list[GitChange],
) -> None:
"""Update the changes lists."""
self._staged = staged
self._unstaged = unstaged
staged_list = self.query_one("#staged-list", ListView)
unstaged_list = self.query_one("#unstaged-list", ListView)
staged_list.clear()
for change in staged:
staged_list.append(GitChangeItem(change))
unstaged_list.clear()
for change in unstaged:
unstaged_list.append(GitChangeItem(change))
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, GitChangeItem):
self.post_message(self.FileClicked(event.item.change))
+90
View File
@@ -0,0 +1,90 @@
"""Git graph visualization component."""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import RichLog
from clide.models.git import GitCommit
class GitGraphView(Vertical):
"""View for git commit graph visualization."""
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 = "┴"
class CommitSelected(Message):
"""Emitted when a commit is selected."""
def __init__(self, commit: GitCommit) -> None:
self.commit = commit
super().__init__()
def __init__(self, commits: list[GitCommit] | None = None, **kwargs) -> None:
super().__init__(**kwargs)
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()
def update_commits(self, commits: list[GitCommit]) -> None:
"""Update the commit list."""
self._commits = commits
self._render_graph()
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}[/]"
)
+96
View File
@@ -0,0 +1,96 @@
"""Jira view component for Jira CLI output."""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Button, Markdown, Static
class JiraView(Vertical):
"""View for Jira CLI output."""
DEFAULT_CSS = """
JiraView {
height: 100%;
}
JiraView .jira-header {
height: 1;
background: $surface;
padding: 0 1;
}
JiraView Markdown {
height: 1fr;
padding: 1;
}
JiraView .jira-actions {
height: auto;
padding: 0 1;
background: $panel;
}
JiraView .disabled-message {
padding: 2;
text-align: center;
color: $warning;
}
"""
class RefreshRequested(Message):
"""Emitted when refresh is requested."""
pass
class IssueClicked(Message):
"""Emitted when an issue is clicked."""
def __init__(self, issue_key: str) -> None:
self.issue_key = issue_key
super().__init__()
def __init__(
self,
content: str = "",
enabled: bool = True,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._content = content
self._enabled = enabled
def compose(self) -> ComposeResult:
yield Static("Jira", classes="jira-header")
if self._enabled:
yield Markdown(self._content or "*Loading...*", id="jira-content")
yield Button("↻ Refresh", id="btn-refresh", classes="jira-actions")
else:
yield Static(
"Jira integration is disabled.\n\n"
"Enable it in settings with CLIDE_JIRA_ENABLED=true",
classes="disabled-message",
)
def update_content(self, content: str) -> None:
"""Update Jira output content."""
self._content = content
try:
markdown = self.query_one("#jira-content", Markdown)
markdown.update(content)
except Exception:
pass
def set_loading(self) -> None:
"""Show loading state."""
self.update_content("*Loading...*")
def set_error(self, error: str) -> None:
"""Show error state."""
self.update_content(f"**Error:** {error}")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "btn-refresh":
self.set_loading()
self.post_message(self.RefreshRequested())
+110
View File
@@ -0,0 +1,110 @@
"""Problems view component for linter errors."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import ListItem, ListView, Static
from clide.models.problems import Problem
class ProblemItem(ListItem):
"""A single problem item."""
def __init__(self, problem: Problem) -> None:
super().__init__()
self.problem = problem
def compose(self) -> ComposeResult:
icon = self.problem.severity_icon
severity_class = self.problem.severity.value
yield Static(
f"[{severity_class}]{icon}[/] "
f"[dim]{self.problem.file_path}:{self.problem.line}[/] "
f"{self.problem.message}",
markup=True,
)
class ProblemsView(Vertical):
"""View for linter problems/diagnostics."""
DEFAULT_CSS = """
ProblemsView {
height: 100%;
}
ProblemsView .problems-header {
height: 1;
background: $surface;
padding: 0 1;
}
ProblemsView ListView {
height: 1fr;
}
ProblemsView .error { color: $error; }
ProblemsView .warning { color: $warning; }
ProblemsView .info { color: $primary; }
ProblemsView .hint { color: $secondary; }
ProblemsView .empty-message {
padding: 2;
text-align: center;
color: $success;
}
"""
class ProblemClicked(Message):
"""Emitted when a problem is clicked."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, problems: list[Problem] | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._problems = problems or []
def compose(self) -> ComposeResult:
count = len(self._problems)
yield Static(f"Problems ({count})", classes="problems-header", id="problems-header")
if self._problems:
yield ListView(
*[ProblemItem(p) for p in self._problems],
id="problems-list",
)
else:
yield Static("No problems found ✓", classes="empty-message")
def update_problems(self, problems: list[Problem]) -> None:
"""Update the problems list."""
self._problems = problems
# Update header
header = self.query_one("#problems-header", Static)
header.update(f"Problems ({len(problems)})")
# Update list
try:
problems_list = self.query_one("#problems-list", ListView)
problems_list.clear()
for problem in problems:
problems_list.append(ProblemItem(problem))
except Exception:
# List might not exist yet, will be created on next compose
pass
def filter_by_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file."""
return [p for p in self._problems if p.file_path == path]
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, ProblemItem):
self.post_message(self.ProblemClicked(event.item.problem))
+140
View File
@@ -0,0 +1,140 @@
"""Terminal pane component."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Input, RichLog, Static
class TerminalPane(Vertical):
"""Simple terminal/command runner pane."""
DEFAULT_CSS = """
TerminalPane {
height: 100%;
}
TerminalPane .terminal-header {
height: 1;
background: $surface;
padding: 0 1;
}
TerminalPane RichLog {
height: 1fr;
background: $background;
}
TerminalPane Input {
dock: bottom;
height: 1;
}
TerminalPane .prompt {
color: $primary;
}
TerminalPane .output {
color: $foreground;
}
TerminalPane .error {
color: $error;
}
"""
class CommandSubmitted(Message):
"""Emitted when a command is submitted."""
def __init__(self, command: str) -> None:
self.command = command
super().__init__()
def __init__(self, cwd: Path | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._cwd = cwd or Path.cwd()
self._history: list[str] = []
self._history_index = 0
def compose(self) -> ComposeResult:
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
yield RichLog(id="terminal-log", highlight=True, markup=True)
yield Input(placeholder="Enter command...", id="terminal-input")
@property
def cwd(self) -> Path:
"""Get current working directory."""
return self._cwd
@cwd.setter
def cwd(self, path: Path) -> None:
"""Set current working directory."""
self._cwd = path
header = self.query_one(".terminal-header", Static)
header.update(f"Terminal - {path}")
def write_output(self, text: str, style: str = "output") -> None:
"""Write output to terminal.
Args:
text: Text to write
style: Style class (output, error, prompt)
"""
log = self.query_one("#terminal-log", RichLog)
if style == "error":
log.write(f"[red]{text}[/]")
elif style == "prompt":
log.write(f"[bold cyan]$ {text}[/]")
else:
log.write(text)
def write_command(self, command: str) -> None:
"""Write a command to terminal (with prompt)."""
self.write_output(command, "prompt")
def write_error(self, error: str) -> None:
"""Write an error to terminal."""
self.write_output(error, "error")
def clear(self) -> None:
"""Clear terminal output."""
log = self.query_one("#terminal-log", RichLog)
log.clear()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle command submission."""
command = event.value.strip()
if not command:
return
# Add to history
self._history.append(command)
self._history_index = len(self._history)
# Clear input
event.input.clear()
# Write command to output
self.write_command(command)
# Post message for handling
self.post_message(self.CommandSubmitted(command))
def history_up(self) -> None:
"""Navigate history up."""
if self._history and self._history_index > 0:
self._history_index -= 1
input_widget = self.query_one("#terminal-input", Input)
input_widget.value = self._history[self._history_index]
def history_down(self) -> None:
"""Navigate history down."""
input_widget = self.query_one("#terminal-input", Input)
if self._history_index < len(self._history) - 1:
self._history_index += 1
input_widget.value = self._history[self._history_index]
else:
self._history_index = len(self._history)
input_widget.clear()
+110
View File
@@ -0,0 +1,110 @@
"""TODOs view component."""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import ListItem, ListView, Static
from clide.models.todos import TodoItem, TodoType
class TodoListItem(ListItem):
"""A single TODO item."""
def __init__(self, item: TodoItem) -> None:
super().__init__()
self.item = item
def compose(self) -> ComposeResult:
icon = self.item.type_icon
type_class = self.item.todo_type.value.lower()
yield Static(
f"[{type_class}]{icon} {self.item.todo_type.value}[/] "
f"[dim]{self.item.file_path}:{self.item.line}[/] "
f"{self.item.text}",
markup=True,
)
class TodosView(Vertical):
"""View for TODO/FIXME comments."""
DEFAULT_CSS = """
TodosView {
height: 100%;
}
TodosView .todos-header {
height: 1;
background: $surface;
padding: 0 1;
}
TodosView ListView {
height: 1fr;
}
TodosView .todo { color: $primary; }
TodosView .fixme { color: $warning; }
TodosView .hack { color: $error; }
TodosView .xxx { color: $error; }
TodosView .note { color: $secondary; }
TodosView .bug { color: $error; }
TodosView .empty-message {
padding: 2;
text-align: center;
color: $success;
}
"""
class TodoClicked(Message):
"""Emitted when a TODO is clicked."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
def __init__(self, items: list[TodoItem] | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._items = items or []
def compose(self) -> ComposeResult:
count = len(self._items)
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header")
if self._items:
yield ListView(
*[TodoListItem(item) for item in self._items],
id="todos-list",
)
else:
yield Static("No TODOs found ✓", classes="empty-message")
def update_items(self, items: list[TodoItem]) -> None:
"""Update the TODOs list."""
self._items = items
# Update header
header = self.query_one("#todos-header", Static)
header.update(f"TODOs ({len(items)})")
# Update list
try:
todos_list = self.query_one("#todos-list", ListView)
todos_list.clear()
for item in items:
todos_list.append(TodoListItem(item))
except Exception:
pass
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Filter by TODO type."""
return [i for i in self._items if i.todo_type == todo_type]
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, TodoListItem):
self.post_message(self.TodoClicked(event.item.item))
+13
View File
@@ -0,0 +1,13 @@
"""Main layout panels for Clide."""
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
from clide.widgets.panels.sidebar import SidebarPanel
from clide.widgets.panels.workspace import WorkspacePanel
__all__ = [
"ClaudePanel",
"ContextPanel",
"SidebarPanel",
"WorkspacePanel",
]
+444
View File
@@ -0,0 +1,444 @@
"""Claude panel with embedded terminal running Claude Code."""
from __future__ import annotations
import asyncio
import fcntl
import os
import pty
import shutil
import struct
import termios
import time
from pathlib import Path
from typing import TYPE_CHECKING
import pyte
from rich.text import Text
from textual.containers import Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.strip import Strip
from textual.widget import Widget
if TYPE_CHECKING:
from textual.app import ComposeResult
class TerminalDisplay(Widget, can_focus=True):
"""A terminal emulator widget using pyte."""
DEFAULT_CSS = """
TerminalDisplay {
height: 100%;
width: 100%;
background: $background;
}
"""
def __init__(
self,
cols: int = 80,
rows: int = 24,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._cols = cols
self._rows = rows
self._screen = pyte.Screen(cols, rows)
self._stream = pyte.Stream(self._screen)
self._master_fd: int | None = None
self._pid: int | None = None
self._read_task: asyncio.Task | None = None
self._refresh_task: asyncio.Task | None = None
self._needs_refresh: bool = False
self._last_refresh: float = 0
def on_resize(self, event) -> None:
"""Handle terminal resize."""
# Get new size from widget
new_cols = max(event.size.width, 20)
new_rows = max(event.size.height, 5)
if new_cols != self._cols or new_rows != self._rows:
self._cols = new_cols
self._rows = new_rows
self._screen.resize(new_rows, new_cols)
# Update PTY size if running
if self._master_fd is not None:
self._set_pty_size(self._master_fd, new_rows, new_cols)
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
"""Set the PTY window size."""
try:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
except OSError:
pass
def start(self, command: str, cwd: str) -> None:
"""Start a process in the terminal."""
# Fork a PTY
pid, master_fd = pty.fork()
if pid == 0:
# Child process
os.chdir(cwd)
os.environ["TERM"] = "xterm-256color"
os.environ["COLORTERM"] = "truecolor"
os.execlp(command, command)
else:
# Parent process
self._pid = pid
self._master_fd = master_fd
# Set non-blocking
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Set initial size
self._set_pty_size(master_fd, self._rows, self._cols)
# Start reading
self._read_task = asyncio.create_task(self._read_output())
def _schedule_refresh(self) -> None:
"""Schedule a throttled refresh to avoid glitching."""
self._needs_refresh = True
if self._refresh_task is None or self._refresh_task.done():
self._refresh_task = asyncio.create_task(self._throttled_refresh())
async def _throttled_refresh(self) -> None:
"""Refresh at most every 16ms (~60fps) to avoid glitching."""
min_interval = 0.016 # ~60fps
while self._needs_refresh:
now = time.monotonic()
elapsed = now - self._last_refresh
if elapsed < min_interval:
await asyncio.sleep(min_interval - elapsed)
self._needs_refresh = False
self._last_refresh = time.monotonic()
self.refresh()
# Small delay to batch rapid updates
await asyncio.sleep(0.008)
async def _read_output(self) -> None:
"""Read output from the PTY."""
if self._master_fd is None:
return
while True:
try:
# Wait for data to be available
await asyncio.sleep(0.005)
try:
data = os.read(self._master_fd, 65536)
if not data:
break
# Feed data to pyte
self._stream.feed(data.decode("utf-8", errors="replace"))
self._schedule_refresh()
except BlockingIOError:
# No data available
continue
except OSError:
# PTY closed
break
except asyncio.CancelledError:
break
def stop(self) -> None:
"""Stop the terminal process."""
if self._read_task:
self._read_task.cancel()
self._read_task = None
if self._refresh_task:
self._refresh_task.cancel()
self._refresh_task = None
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
if self._pid is not None:
try:
os.kill(self._pid, 9)
os.waitpid(self._pid, 0)
except (OSError, ChildProcessError):
pass
self._pid = None
def is_running(self) -> bool:
"""Check if the process is still running."""
if self._pid is None:
return False
try:
pid, status = os.waitpid(self._pid, os.WNOHANG)
if pid == 0:
return True # Still running
else:
self._pid = None
return False
except ChildProcessError:
self._pid = None
return False
def send(self, data: str) -> None:
"""Send data to the terminal."""
if self._master_fd is not None:
try:
os.write(self._master_fd, data.encode("utf-8"))
except OSError:
pass
def render_line(self, y: int) -> Strip:
"""Render a line of the terminal."""
if y >= self._rows:
return Strip.blank(self._cols)
line = self._screen.buffer[y]
text = Text()
for x in range(self._cols):
char = line[x]
char_data = char.data if char.data else " "
# Build style from pyte character attributes
style_parts = []
if char.fg and char.fg != "default":
style_parts.append(f"color({char.fg})" if char.fg.startswith("#") else char.fg)
if char.bg and char.bg != "default":
style_parts.append(f"on color({char.bg})" if char.bg.startswith("#") else f"on {char.bg}")
if char.bold:
style_parts.append("bold")
if char.italics:
style_parts.append("italic")
if char.underscore:
style_parts.append("underline")
if char.reverse:
style_parts.append("reverse")
style = " ".join(style_parts) if style_parts else None
text.append(char_data, style=style)
# Render text to segments for Strip
segments = list(text.render(self.app.console))
return Strip(segments)
def on_key(self, event) -> None:
"""Handle key presses."""
# Map special keys
key_map = {
"enter": "\r",
"tab": "\t",
"backspace": "\x7f",
"delete": "\x1b[3~",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"home": "\x1b[H",
"end": "\x1b[F",
"pageup": "\x1b[5~",
"pagedown": "\x1b[6~",
"escape": "\x1b",
}
if event.key in key_map:
self.send(key_map[event.key])
event.prevent_default()
event.stop()
elif event.key == "ctrl+c":
self.send("\x03")
event.prevent_default()
event.stop()
elif event.key == "ctrl+d":
self.send("\x04")
event.prevent_default()
event.stop()
elif event.key == "ctrl+z":
self.send("\x1a")
event.prevent_default()
event.stop()
elif event.key == "ctrl+l":
self.send("\x0c")
event.prevent_default()
event.stop()
elif event.character and len(event.character) == 1:
self.send(event.character)
event.prevent_default()
event.stop()
class ClaudePanel(Vertical):
"""Terminal panel running Claude Code CLI.
Automatically starts Claude Code and restarts when it exits.
Takes 100% height when workspace is hidden, 40% when visible.
"""
DEFAULT_CSS = """
ClaudePanel {
height: 100%;
background: $background;
}
ClaudePanel.with-workspace {
height: 40%;
}
ClaudePanel TerminalDisplay {
height: 100%;
}
"""
class ClaudeExited(Message):
"""Emitted when Claude Code process exits."""
def __init__(self, return_code: int) -> None:
self.return_code = return_code
super().__init__()
class ClaudeStarted(Message):
"""Emitted when Claude Code process starts."""
pass
# Reactive state
workspace_visible: reactive[bool] = reactive(False)
def __init__(
self,
workdir: Path | None = None,
auto_start: bool = True,
restart_on_exit: bool = True,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.id = "panel-claude"
self._workdir = workdir or Path.cwd()
self._auto_start = auto_start
self._restart_on_exit = restart_on_exit
self._claude_command = self._find_claude_command()
self._terminal: TerminalDisplay | None = None
self._monitor_task: asyncio.Task | None = None
def _find_claude_command(self) -> str:
"""Find the Claude Code CLI command."""
# Check common locations
claude_paths = [
"claude", # In PATH
str(Path.home() / ".claude" / "local" / "claude"),
str(Path.home() / ".local" / "bin" / "claude"),
"/usr/local/bin/claude",
]
for path in claude_paths:
if path and shutil.which(path):
return path
# Fallback to 'claude' and hope it's in PATH
return "claude"
def compose(self) -> ComposeResult:
self._terminal = TerminalDisplay(id="claude-terminal")
yield self._terminal
def on_mount(self) -> None:
"""Start Claude Code when mounted."""
if self._auto_start:
self.call_later(self.start_claude)
def watch_workspace_visible(self, visible: bool) -> None:
"""Adjust height based on workspace visibility."""
if visible:
self.add_class("with-workspace")
else:
self.remove_class("with-workspace")
def start_claude(self) -> None:
"""Start the Claude Code CLI in the terminal."""
if self._terminal is None:
return
# Start Claude Code with the working directory
self._terminal.start(
self._claude_command,
str(self._workdir),
)
self.post_message(self.ClaudeStarted())
# Start monitoring for exit
if self._monitor_task:
self._monitor_task.cancel()
self._monitor_task = asyncio.create_task(self._monitor_claude())
async def _monitor_claude(self) -> None:
"""Monitor Claude process and restart if needed."""
if self._terminal is None:
return
# Wait for the terminal process to exit
while True:
await asyncio.sleep(1)
# Check if process has exited
if not self._terminal.is_running():
self.post_message(self.ClaudeExited(0))
if self._restart_on_exit:
# Wait a moment before restarting
await asyncio.sleep(0.5)
self.start_claude()
break
def stop_claude(self) -> None:
"""Stop the Claude Code CLI."""
if self._terminal:
self._terminal.stop()
if self._monitor_task:
self._monitor_task.cancel()
self._monitor_task = None
def send_input(self, text: str) -> None:
"""Send input to the Claude terminal."""
if self._terminal:
self._terminal.send(text)
def send_interrupt(self) -> None:
"""Send Ctrl+C interrupt to Claude."""
if self._terminal:
self._terminal.send("\x03") # Ctrl+C
def focus_terminal(self) -> None:
"""Focus the terminal."""
if self._terminal:
self._terminal.focus()
@property
def workdir(self) -> Path:
"""Get the working directory."""
return self._workdir
@workdir.setter
def workdir(self, path: Path) -> None:
"""Set the working directory (requires restart)."""
self._workdir = path
def set_restart_on_exit(self, restart: bool) -> None:
"""Set whether to restart Claude when it exits."""
self._restart_on_exit = restart
+212
View File
@@ -0,0 +1,212 @@
"""Context panel with Problems, TODOs, and Jira tabs."""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Static, TabbedContent, TabPane
from clide.models.problems import Problem
from clide.models.todos import TodoItem
from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.todos_view import TodosView
class ContextPanel(Vertical):
"""Right context panel with Problems, TODOs, and Jira integration."""
DEFAULT_CSS = """
ContextPanel {
width: 25%;
min-width: 30;
height: 100%;
background: $surface;
}
ContextPanel TabbedContent {
height: 1fr;
}
ContextPanel .context-tab-bar {
dock: bottom;
height: 1;
background: $panel;
padding: 0 1;
}
ContextPanel .tab-count {
margin: 0 1;
}
ContextPanel .error-count {
color: $error;
}
ContextPanel .warning-count {
color: $warning;
}
ContextPanel .success-count {
color: $success;
}
"""
class ProblemClicked(Message):
"""Emitted when a problem is clicked."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
class TodoClicked(Message):
"""Emitted when a TODO is clicked."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
class JiraRefreshRequested(Message):
"""Emitted when Jira refresh is requested."""
pass
# Reactive state with counts for tab badges
problem_count: reactive[int] = reactive(0)
todo_count: reactive[int] = reactive(0)
visible: reactive[bool] = reactive(True)
def __init__(
self,
jira_enabled: bool = True,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._jira_enabled = jira_enabled
self.id = "panel-context"
def compose(self) -> ComposeResult:
with TabbedContent(id="context-tabs"):
with TabPane("Problems", id="context-problems"):
yield ProblemsView(id="problems-view")
with TabPane("TODOs", id="context-todos"):
yield TodosView(id="todos-view")
with TabPane("Jira", id="context-jira"):
yield JiraView(enabled=self._jira_enabled, id="jira-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()
def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes."""
self.display = visible
def watch_problem_count(self, count: int) -> None:
"""Update problem count display."""
self._update_tab_counts()
def watch_todo_count(self, count: int) -> None:
"""Update todo count display."""
self._update_tab_counts()
def _update_tab_counts(self) -> None:
"""Update the tab counts display."""
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"
# Build count display
parts = []
if self.problem_count > 0:
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
else:
parts.append(f"[{problem_style}]✓ 0[/]")
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
counts.update(" │ ".join(parts))
except Exception:
pass
def update_problems(self, problems: list[Problem]) -> None:
"""Update problems view and count."""
self.problem_count = len(problems)
try:
view = self.query_one("#problems-view", ProblemsView)
view.update_problems(problems)
except Exception:
pass
def update_todos(self, items: list[TodoItem]) -> None:
"""Update TODOs view and count."""
self.todo_count = len(items)
try:
view = self.query_one("#todos-view", TodosView)
view.update_items(items)
except Exception:
pass
def update_jira(self, content: str) -> None:
"""Update Jira view content."""
try:
view = self.query_one("#jira-view", JiraView)
view.update_content(content)
except Exception:
pass
def set_jira_loading(self) -> None:
"""Set Jira view to loading state."""
try:
view = self.query_one("#jira-view", JiraView)
view.set_loading()
except Exception:
pass
def set_jira_error(self, error: str) -> None:
"""Set Jira view to error state."""
try:
view = self.query_one("#jira-view", JiraView)
view.set_error(error)
except Exception:
pass
def focus_tab(self, tab_id: str) -> None:
"""Focus a specific tab."""
tabs = self.query_one("#context-tabs", TabbedContent)
tabs.active = f"context-{tab_id}"
def focus_problems(self) -> None:
"""Focus problems tab."""
self.focus_tab("problems")
def focus_todos(self) -> None:
"""Focus TODOs tab."""
self.focus_tab("todos")
def focus_jira(self) -> None:
"""Focus Jira tab."""
self.focus_tab("jira")
# Event forwarding
def on_problems_view_problem_clicked(
self,
event: ProblemsView.ProblemClicked,
) -> None:
"""Forward problem click."""
self.post_message(self.ProblemClicked(event.problem))
def on_todos_view_todo_clicked(self, event: TodosView.TodoClicked) -> None:
"""Forward todo click."""
self.post_message(self.TodoClicked(event.item))
def on_jira_view_refresh_requested(
self,
event: JiraView.RefreshRequested,
) -> None:
"""Forward Jira refresh request."""
self.post_message(self.JiraRefreshRequested())
+157
View File
@@ -0,0 +1,157 @@
"""Sidebar panel with Files, Git, and Tree tabs."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import TabbedContent, TabPane
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
from clide.widgets.components.git_graph import GitGraphView
class SidebarPanel(Vertical):
"""Left sidebar panel with file browser, git changes, and git graph."""
DEFAULT_CSS = """
SidebarPanel {
width: 20%;
min-width: 25;
height: 100%;
background: $surface;
}
SidebarPanel TabbedContent {
height: 1fr;
}
SidebarPanel .sidebar-content {
height: 1fr;
}
SidebarPanel BranchStatus {
dock: bottom;
height: auto;
}
"""
class FileSelected(Message):
"""Emitted when a file is selected from the file browser."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class GitFileSelected(Message):
"""Emitted when a file is selected from git changes."""
def __init__(self, path: Path, staged: bool) -> None:
self.path = path
self.staged = staged
super().__init__()
class BranchChanged(Message):
"""Emitted when the branch is changed."""
def __init__(self, branch: str) -> None:
self.branch = branch
super().__init__()
# Reactive state
current_branch: reactive[str] = reactive("main")
visible: reactive[bool] = reactive(True)
def __init__(
self,
workdir: Path | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._workdir = workdir or Path.cwd()
self.id = "panel-sidebar"
def compose(self) -> ComposeResult:
with Container(classes="sidebar-content"), TabbedContent(id="sidebar-tabs"):
with TabPane("Files", id="sidebar-files"):
yield FilesView(path=self._workdir)
with TabPane("Git", id="sidebar-git"):
yield GitChangesView()
with TabPane("Tree", id="sidebar-tree"):
yield GitGraphView()
yield BranchStatus(current_branch=self.current_branch)
def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes."""
self.display = visible
def watch_current_branch(self, branch: str) -> None:
"""Update branch status when branch changes."""
try:
branch_status = self.query_one(BranchStatus)
branch_status.branch = branch
except Exception:
pass
def update_git_status(
self,
staged: list,
unstaged: list,
) -> None:
"""Update git changes view."""
try:
git_view = self.query_one(GitChangesView)
git_view.update_changes(staged, 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)
except Exception:
pass
def update_branches(self, branches: list[str]) -> None:
"""Update available branches."""
try:
branch_status = self.query_one(BranchStatus)
branch_status.update_branches(branches)
except Exception:
pass
def refresh_files(self) -> None:
"""Refresh file browser."""
try:
files_view = self.query_one(FilesView)
files_view.reload()
except Exception:
pass
def focus_tab(self, tab_id: str) -> None:
"""Focus a specific tab."""
tabs = self.query_one("#sidebar-tabs", TabbedContent)
tabs.active = tab_id
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
"""Forward file selection."""
self.post_message(self.FileSelected(event.path))
def on_git_changes_view_file_clicked(
self,
event: GitChangesView.FileClicked,
) -> None:
"""Forward git file selection."""
self.post_message(self.GitFileSelected(event.path, event.staged))
def on_branch_status_branch_changed(
self,
event: BranchStatus.BranchChanged,
) -> None:
"""Forward branch change."""
self.current_branch = event.branch
self.post_message(self.BranchChanged(event.branch))
+236
View File
@@ -0,0 +1,236 @@
"""Workspace panel with Editor, Diff, and Terminal tabs."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import TabbedContent, TabPane
from clide.models.diff import DiffContent
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.terminal_pane import TerminalPane
class WorkspacePanel(Vertical):
"""Center workspace panel with Editor, Diff, and Terminal tabs.
Hidden by default. Shows when:
- File is opened
- Diff is displayed
- Terminal is activated
"""
DEFAULT_CSS = """
WorkspacePanel {
height: 60%;
background: $background;
}
WorkspacePanel.hidden {
display: none;
}
WorkspacePanel TabbedContent {
height: 100%;
}
WorkspacePanel TabPane {
height: 100%;
padding: 0;
}
"""
class FileSaved(Message):
"""Emitted when a file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class DiffAccepted(Message):
"""Emitted when a diff is accepted."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
class DiffRejected(Message):
"""Emitted when a diff is rejected."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
class CommandSubmitted(Message):
"""Emitted when a terminal command is submitted."""
def __init__(self, command: str) -> None:
self.command = command
super().__init__()
class CloseRequested(Message):
"""Emitted when workspace should be hidden."""
pass
# Reactive state - persisted when hidden
visible: reactive[bool] = reactive(False)
active_tab: reactive[str] = reactive("editor")
def __init__(
self,
workdir: Path | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._workdir = workdir or Path.cwd()
self.id = "panel-workspace"
def compose(self) -> ComposeResult:
with TabbedContent(id="workspace-tabs"):
with TabPane("Editor", id="workspace-editor"):
yield EditorPane(id="editor-pane")
with TabPane("Diff", id="workspace-diff"):
yield DiffPane(id="diff-pane")
with TabPane("Terminal", id="workspace-terminal"):
yield TerminalPane(cwd=self._workdir, id="terminal-pane")
def on_mount(self) -> None:
"""Set initial visibility."""
self._update_visibility()
def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes - hide, don't destroy."""
self._update_visibility()
def _update_visibility(self) -> None:
"""Update display based on visibility state."""
if self.visible:
self.remove_class("hidden")
else:
self.add_class("hidden")
def show(self, tab: str | None = None) -> None:
"""Show workspace, optionally focusing a specific tab."""
self.visible = True
if tab:
self.focus_tab(tab)
def hide(self) -> None:
"""Hide workspace (state is preserved)."""
self.visible = False
def toggle(self) -> None:
"""Toggle workspace visibility."""
self.visible = not self.visible
def focus_tab(self, tab_id: str) -> None:
"""Focus a specific tab."""
tabs = self.query_one("#workspace-tabs", TabbedContent)
tabs.active = f"workspace-{tab_id}"
self.active_tab = tab_id
# Editor methods
def open_file(self, path: Path, line: int | None = None) -> None:
"""Open a file in the editor tab."""
self.show("editor")
try:
editor = self.query_one("#editor-pane", EditorPane)
editor.load_file(path, goto_line=line)
except Exception:
pass
def get_current_file(self) -> Path | None:
"""Get the currently open file."""
try:
editor = self.query_one("#editor-pane", EditorPane)
return editor.current_file
except Exception:
return None
def has_unsaved_changes(self) -> bool:
"""Check if editor has unsaved changes."""
try:
editor = self.query_one("#editor-pane", EditorPane)
return editor.modified
except Exception:
return False
# Diff methods
def show_diff(
self,
diff: DiffContent,
is_proposal: bool = False,
) -> None:
"""Show a diff in the diff tab."""
self.show("diff")
try:
diff_pane = self.query_one("#diff-pane", DiffPane)
diff_pane.load_diff(diff, is_proposal)
except Exception:
pass
def clear_diff(self) -> None:
"""Clear the diff view."""
try:
diff_pane = self.query_one("#diff-pane", DiffPane)
diff_pane.clear()
except Exception:
pass
# Terminal methods
def show_terminal(self) -> None:
"""Show and focus the terminal tab."""
self.show("terminal")
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal_input = terminal.query_one("#terminal-input")
terminal_input.focus()
except Exception:
pass
def write_terminal_output(self, text: str, style: str = "output") -> None:
"""Write output to terminal."""
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal.write_output(text, style)
except Exception:
pass
def write_terminal_error(self, error: str) -> None:
"""Write error to terminal."""
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal.write_error(error)
except Exception:
pass
def clear_terminal(self) -> None:
"""Clear terminal output."""
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal.clear()
except Exception:
pass
# Event forwarding
def on_editor_pane_file_saved(self, event: EditorPane.FileSaved) -> None:
"""Forward file save event."""
self.post_message(self.FileSaved(event.path))
def on_diff_pane_accept_clicked(self, event: DiffPane.AcceptClicked) -> None:
"""Forward diff accept event."""
self.post_message(self.DiffAccepted(event.file_path))
def on_diff_pane_reject_clicked(self, event: DiffPane.RejectClicked) -> None:
"""Forward diff reject event."""
self.post_message(self.DiffRejected(event.file_path))
def on_terminal_pane_command_submitted(
self,
event: TerminalPane.CommandSubmitted,
) -> None:
"""Forward terminal command event."""
self.post_message(self.CommandSubmitted(event.command))