move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual implementation moves wholesale into legacy/ rather than being deleted: its pane model, panel set, git skills, and panel communication design are real thought that should remain readable next to the new code while the rebuild finds its shape. Git's rename tracking preserves history, so `git log -- legacy/` still works. The Flutter rebuild lives at the repo root alongside a Go sidecar (the architecture claudian was heading toward, which folds into clide as a core component rather than a separate plugin project). Bootstrap of the new shape lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Clide - A TUI CLI IDE for Claude Code CLI."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide."""
|
||||
|
||||
from clide.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,863 @@
|
||||
"""Main Textual Application for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header
|
||||
from textual.worker import Worker, WorkerState
|
||||
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
from clide.services.claude_events import (
|
||||
ClaudeEvent,
|
||||
FileEditEvent,
|
||||
FileReadEvent,
|
||||
FileWriteEvent,
|
||||
setup_event_parsing,
|
||||
)
|
||||
from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching
|
||||
from clide.services.settings_service import get_settings_service
|
||||
from clide.services.syntax_service import register_languages
|
||||
from clide.themes.registry import get_all_themes, get_theme
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
|
||||
class ClideApp(App[None]):
|
||||
"""Clide TUI Application - Claude Code IDE.
|
||||
|
||||
Panel architecture:
|
||||
- Sidebar (left): Files, Git, Tree tabs + branch status
|
||||
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
|
||||
- Context (right): Problems, TODOs, Jira tabs
|
||||
|
||||
Workspace is hidden by default. Claude takes full height when
|
||||
workspace is hidden, 40% when visible.
|
||||
"""
|
||||
|
||||
TITLE = "Clide"
|
||||
SUB_TITLE = "Claude Code IDE"
|
||||
|
||||
CSS: ClassVar[str] = """
|
||||
/* Main layout */
|
||||
Screen {
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#center-column {
|
||||
width: 1fr;
|
||||
height: 100%;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
/* Panel styling */
|
||||
SidebarPanel {
|
||||
width: 20%;
|
||||
min-width: 25;
|
||||
max-width: 50;
|
||||
}
|
||||
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
max-width: 50;
|
||||
}
|
||||
|
||||
/* Workspace + Claude layout */
|
||||
WorkspacePanel {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
WorkspacePanel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ClaudePanel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ClaudePanel.with-workspace {
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
/* Compact mode - applied when .compact class is on #main-container */
|
||||
#main-container.compact SidebarPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#main-container.compact ContextPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fullscreen mode */
|
||||
.fullscreen {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layer: fullscreen;
|
||||
}
|
||||
|
||||
/* Global button styling - outlined look */
|
||||
Button {
|
||||
background: transparent;
|
||||
border: solid $secondary;
|
||||
color: $foreground;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background: $secondary 20%;
|
||||
border: solid $secondary;
|
||||
}
|
||||
|
||||
Button:focus {
|
||||
border: solid $primary;
|
||||
}
|
||||
|
||||
Button.-primary {
|
||||
border: solid $primary;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
Button.-primary:hover {
|
||||
background: $primary 20%;
|
||||
}
|
||||
"""
|
||||
|
||||
# Keybindings
|
||||
# OS-native shortcuts (Ctrl on Windows/Linux, Cmd on Mac mapped to ctrl in terminal)
|
||||
# Alt-based shortcuts for actions that shouldn't interfere with input fields
|
||||
# Note: priority=True ensures bindings work even when widgets have focus
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
# Global
|
||||
Binding("alt+q", "quit", "Quit", priority=True),
|
||||
Binding("alt+p", "command_palette", "Commands"),
|
||||
Binding("alt+o", "quick_open", "Quick Open"),
|
||||
Binding("alt+b", "toggle_sidebar", "Sidebar", priority=True),
|
||||
Binding("alt+shift+b", "toggle_context", "Context", priority=True),
|
||||
Binding("alt+`", "toggle_terminal", "Terminal", priority=True),
|
||||
Binding("alt+c", "toggle_compact", "Compact", priority=True),
|
||||
Binding("f11", "toggle_fullscreen", "Fullscreen"),
|
||||
Binding("escape", "escape", "Escape", show=False),
|
||||
# Navigation
|
||||
Binding("alt+1", "focus_claude", "Claude", show=False, priority=True),
|
||||
Binding("alt+2", "focus_editor", "Editor", show=False, priority=True),
|
||||
Binding("alt+3", "focus_terminal", "Terminal", show=False, priority=True),
|
||||
Binding("alt+0", "focus_sidebar", "Sidebar", show=False, priority=True),
|
||||
Binding("alt+w", "close_tab", "Close Tab", show=False),
|
||||
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
|
||||
# Git
|
||||
Binding("alt+g", "open_git", "Git", show=False, priority=True),
|
||||
# Problems
|
||||
Binding("alt+m", "open_problems", "Problems", show=False, priority=True),
|
||||
Binding("f8", "next_problem", "Next Problem", show=False),
|
||||
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
|
||||
# Editor - OS-native shortcuts with priority
|
||||
Binding("ctrl+s", "save_file", "Save", priority=True),
|
||||
Binding("alt+s", "save_file", "Save", show=False, priority=True),
|
||||
Binding("ctrl+z", "undo", "Undo", show=False, priority=True),
|
||||
# Theme
|
||||
Binding("alt+t", "select_theme", "Theme", priority=True),
|
||||
]
|
||||
|
||||
# Reactive state
|
||||
current_file: reactive[Path | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
fullscreen_panel: reactive[str | None] = reactive(None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
settings: ClideSettings | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.workdir = self._resolve_workdir(workdir)
|
||||
self.settings = settings or ClideSettings()
|
||||
self._test_mode = test_mode
|
||||
|
||||
# User settings persistence
|
||||
self._settings_service = get_settings_service()
|
||||
self._user_settings = self._settings_service.load()
|
||||
|
||||
# Extension manager
|
||||
self.extension_manager = ExtensionManager()
|
||||
|
||||
# Controllers
|
||||
self.git_controller = GitController(self.workdir)
|
||||
self.editor_controller = EditorController()
|
||||
self.diff_controller = DiffController(self.workdir)
|
||||
self.problems_controller = ProblemsController(self.workdir)
|
||||
self.todos_controller = TodosController(self.workdir)
|
||||
# Use settings parameter if jira_enabled explicitly set, otherwise user settings
|
||||
jira_enabled = self.settings.jira_enabled or self._user_settings.jira_enabled
|
||||
self.jira_controller = JiraController(
|
||||
enabled=jira_enabled,
|
||||
)
|
||||
|
||||
# Register themes
|
||||
self._register_themes()
|
||||
|
||||
# Register additional syntax highlighting languages
|
||||
register_languages()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_workdir(workdir: Path | None) -> Path:
|
||||
"""Resolve the working directory to the git root when possible."""
|
||||
import subprocess
|
||||
|
||||
target = workdir.resolve() if workdir and workdir.is_dir() else Path.cwd()
|
||||
|
||||
# Find git root -- anchors to the project root even if cwd is a subdirectory
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=str(target),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
git_root = Path(result.stdout.strip())
|
||||
if git_root.is_dir():
|
||||
return git_root
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
return target
|
||||
|
||||
def _register_themes(self) -> None:
|
||||
"""Register all themes with Textual."""
|
||||
for theme_meta in get_all_themes():
|
||||
theme_def = get_theme(theme_meta.name)
|
||||
if theme_def:
|
||||
self.register_theme(theme_def.to_textual_theme())
|
||||
|
||||
# Set initial theme: settings parameter takes precedence over user settings
|
||||
# Use settings.theme if different from default, otherwise user settings
|
||||
if self.settings.theme != "summer-night":
|
||||
self.theme = self.settings.theme
|
||||
else:
|
||||
self.theme = self._user_settings.theme
|
||||
|
||||
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
|
||||
"""Set the application theme.
|
||||
|
||||
Args:
|
||||
theme_name: Name of the theme to apply
|
||||
save: Whether to persist the setting (default: True)
|
||||
"""
|
||||
self.theme = theme_name
|
||||
if save:
|
||||
self._settings_service.set("theme", theme_name)
|
||||
self.notify(f"Theme set to: {theme_name}", severity="information")
|
||||
|
||||
def save_user_settings(self) -> None:
|
||||
"""Save current user settings to disk."""
|
||||
self._settings_service.update(
|
||||
theme=self.theme,
|
||||
compact_mode=self.compact_mode,
|
||||
sidebar_visible=self.query_one(SidebarPanel).display,
|
||||
context_visible=self.query_one(ContextPanel).display,
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the main layout."""
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left sidebar
|
||||
yield SidebarPanel(workdir=self.workdir)
|
||||
|
||||
# Center column with workspace and claude
|
||||
with Vertical(id="center-column"):
|
||||
yield WorkspacePanel(workdir=self.workdir)
|
||||
yield ClaudePanel(
|
||||
workdir=self.workdir,
|
||||
auto_start=not self._test_mode,
|
||||
)
|
||||
|
||||
# Right context panel
|
||||
yield ContextPanel(
|
||||
jira_enabled=self.settings.jira_enabled,
|
||||
project_path=self.workdir,
|
||||
)
|
||||
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Initialize application on mount."""
|
||||
# Apply saved user settings
|
||||
self._apply_user_settings()
|
||||
|
||||
# Load extensions
|
||||
self.extension_manager.load_extensions()
|
||||
await self.extension_manager.trigger_app_startup(self)
|
||||
|
||||
# Set up file watching for real-time sync
|
||||
self._setup_file_watching()
|
||||
|
||||
# Set up Claude event parsing for IDE integration
|
||||
self._setup_claude_events()
|
||||
|
||||
# Initial data refresh
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
await self._refresh_todos()
|
||||
if self._user_settings.jira_enabled:
|
||||
await self._refresh_jira()
|
||||
|
||||
# Focus Claude panel
|
||||
self.action_focus_claude()
|
||||
|
||||
def _setup_file_watching(self) -> None:
|
||||
"""Set up file system watching for real-time updates."""
|
||||
self._file_watcher = setup_file_watching(
|
||||
self.workdir,
|
||||
handlers=[self._on_file_changed],
|
||||
)
|
||||
|
||||
def _setup_claude_events(self) -> None:
|
||||
"""Set up Claude event parsing for IDE integration."""
|
||||
setup_event_parsing(self._on_claude_event)
|
||||
|
||||
def _on_file_changed(self, event: FileEvent) -> None:
|
||||
"""Handle file system changes by posting a FileEventMessage.
|
||||
|
||||
This bridges the watchdog callback to Textual's message system,
|
||||
allowing widgets to subscribe to file events via on_file_event_message.
|
||||
|
||||
Note: This is called from the watchdog thread, so we use call_from_thread
|
||||
to safely execute on the main thread. The FileEvent (Pydantic model) is
|
||||
thread-safe, but we create the Message on the main thread to avoid any
|
||||
potential Textual threading issues.
|
||||
"""
|
||||
|
||||
def post_file_event():
|
||||
self.post_message(FileEventMessage(event))
|
||||
|
||||
self.call_from_thread(post_file_event)
|
||||
|
||||
async def on_file_event_message(self, message: FileEventMessage) -> None:
|
||||
"""Handle file event messages from the file watcher.
|
||||
|
||||
This is the central handler for all file system events.
|
||||
The App coordinates updates to child widgets since Textual
|
||||
messages bubble up (not down to children).
|
||||
"""
|
||||
event = message.event
|
||||
|
||||
# Ignore files in .clide directory (settings, etc.)
|
||||
if ".clide" in str(event.path):
|
||||
return
|
||||
|
||||
# Trigger extension hooks
|
||||
self.extension_manager.trigger_file_changed(event)
|
||||
|
||||
# Debounce: skip if we refreshed recently (within 1 second)
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
if hasattr(self, "_last_file_refresh") and now - self._last_file_refresh < 1.0:
|
||||
return
|
||||
self._last_file_refresh = now
|
||||
|
||||
# Refresh file tree for created/deleted/moved files
|
||||
if event.event_type in ("created", "deleted", "moved"):
|
||||
try:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.refresh_files()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Use call_later to avoid blocking the event loop during refreshes
|
||||
# This ensures UI responsiveness isn't affected by heavy git operations
|
||||
if event.event_type in ("created", "modified", "deleted", "moved"):
|
||||
self.call_later(self._async_refresh_after_file_change, event)
|
||||
|
||||
async def _async_refresh_after_file_change(self, event: FileEvent) -> None:
|
||||
"""Perform async refreshes after a file change without blocking UI."""
|
||||
# Refresh git status for all file changes
|
||||
await self._refresh_git()
|
||||
|
||||
# Only refresh problems/todos for Python/text files
|
||||
if event.path.suffix in (".py", ".pyi", ".txt", ".md", ".rst"):
|
||||
if event.event_type in ("created", "modified"):
|
||||
await self._refresh_problems()
|
||||
await self._refresh_todos()
|
||||
|
||||
def _on_claude_event(self, event: ClaudeEvent) -> None:
|
||||
"""Handle Claude Code events for IDE integration.
|
||||
|
||||
When Claude reads/edits files, we can update the UI accordingly.
|
||||
"""
|
||||
# Schedule handling on the main thread
|
||||
self.call_later(self._handle_claude_event, event)
|
||||
|
||||
async def _handle_claude_event(self, event: ClaudeEvent) -> None:
|
||||
"""Async handler for Claude events."""
|
||||
if isinstance(event, FileReadEvent):
|
||||
# Claude read a file - highlight in sidebar file tree
|
||||
try:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.highlight_file(event.path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
|
||||
|
||||
elif isinstance(event, FileEditEvent):
|
||||
# Claude edited a file - notify user
|
||||
# Note: Git refresh is handled by FileEventMessage from the file watcher
|
||||
self.notify(f"Claude edited: {event.path.name}", severity="information")
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
|
||||
|
||||
elif isinstance(event, FileWriteEvent):
|
||||
# Claude created/wrote a file - notify user
|
||||
# Note: Git refresh and file tree refresh are handled by
|
||||
# FileEventMessage from the file watcher (event-driven)
|
||||
self.notify(f"Claude wrote: {event.path.name}", severity="information")
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
|
||||
|
||||
def _apply_user_settings(self) -> None:
|
||||
"""Apply saved user settings on startup."""
|
||||
# Panel visibility
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = self._user_settings.sidebar_visible
|
||||
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = self._user_settings.context_visible
|
||||
|
||||
# Compact mode
|
||||
self.compact_mode = self._user_settings.compact_mode
|
||||
|
||||
# Re-apply theme after mount (Textual needs this for proper initialization)
|
||||
if self._user_settings.theme:
|
||||
self.theme = self._user_settings.theme
|
||||
|
||||
# Reactive watchers
|
||||
def watch_workspace_visible(self, visible: bool) -> None:
|
||||
"""Update panels when workspace visibility changes."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
claude = self.query_one(ClaudePanel)
|
||||
|
||||
workspace.visible = visible
|
||||
claude.workspace_visible = visible
|
||||
|
||||
def watch_compact_mode(self, compact: bool) -> None:
|
||||
"""Toggle compact mode class."""
|
||||
container = self.query_one("#main-container")
|
||||
if compact:
|
||||
container.add_class("compact")
|
||||
else:
|
||||
container.remove_class("compact")
|
||||
|
||||
# Data refresh methods
|
||||
async def _refresh_git(self) -> None:
|
||||
"""Refresh git status."""
|
||||
status = await self.git_controller.get_status()
|
||||
if status:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_status(status.staged, status.unstaged)
|
||||
sidebar.current_branch = status.branch
|
||||
|
||||
branches = await self.git_controller.get_branches()
|
||||
if branches:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_branches([b.name for b in branches])
|
||||
|
||||
commits = await self.git_controller.get_log(limit=50)
|
||||
if commits:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_graph(commits)
|
||||
|
||||
async def _refresh_problems(self) -> None:
|
||||
"""Refresh linter problems."""
|
||||
problems = await self.problems_controller.run_all()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_problems(problems)
|
||||
|
||||
async def _refresh_todos(self) -> None:
|
||||
"""Refresh TODOs."""
|
||||
code_todos, project_todos = await self.todos_controller.scan()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_todos(code_todos, project_todos)
|
||||
|
||||
async def _refresh_jira(self) -> None:
|
||||
"""Refresh Jira content."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.set_jira_loading()
|
||||
content = await self.jira_controller.get_content()
|
||||
if content:
|
||||
context.update_jira(content)
|
||||
else:
|
||||
context.set_jira_error("Failed to load Jira content")
|
||||
|
||||
# Action methods
|
||||
def action_toggle_sidebar(self) -> None:
|
||||
"""Toggle sidebar visibility."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = not sidebar.visible
|
||||
self._settings_service.set("sidebar_visible", sidebar.visible)
|
||||
|
||||
def action_toggle_context(self) -> None:
|
||||
"""Toggle context panel visibility."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = not context.visible
|
||||
self._settings_service.set("context_visible", context.visible)
|
||||
|
||||
def action_toggle_terminal(self) -> None:
|
||||
"""Toggle terminal (shows workspace with terminal tab)."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if self.workspace_visible and workspace.get_active_tab_type() == "terminal":
|
||||
self.workspace_visible = False
|
||||
else:
|
||||
workspace.show_terminal()
|
||||
self.workspace_visible = True
|
||||
|
||||
def action_toggle_compact(self) -> None:
|
||||
"""Toggle compact mode."""
|
||||
self.compact_mode = not self.compact_mode
|
||||
self._settings_service.set("compact_mode", self.compact_mode)
|
||||
|
||||
def action_toggle_fullscreen(self) -> None:
|
||||
"""Toggle fullscreen for focused panel."""
|
||||
# TODO: Implement fullscreen toggle
|
||||
pass
|
||||
|
||||
def action_escape(self) -> None:
|
||||
"""Handle escape key."""
|
||||
if self.fullscreen_panel:
|
||||
self.fullscreen_panel = None
|
||||
elif self.workspace_visible:
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if not workspace.has_unsaved_changes():
|
||||
self.workspace_visible = False
|
||||
|
||||
def action_focus_claude(self) -> None:
|
||||
"""Focus Claude panel."""
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.focus_terminal()
|
||||
|
||||
def action_focus_editor(self) -> None:
|
||||
"""Focus editor."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.focus_last_editor()
|
||||
|
||||
def action_focus_terminal(self) -> None:
|
||||
"""Focus terminal."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.show_terminal()
|
||||
|
||||
def action_focus_sidebar(self) -> None:
|
||||
"""Focus sidebar."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = True
|
||||
sidebar.focus()
|
||||
|
||||
def action_close_tab(self) -> None:
|
||||
"""Close current tab in workspace."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.close_tab()
|
||||
|
||||
def action_open_git(self) -> None:
|
||||
"""Open git panel."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = True
|
||||
sidebar.focus_tab("sidebar-git")
|
||||
|
||||
def action_open_problems(self) -> None:
|
||||
"""Open problems panel."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = True
|
||||
context.focus_problems()
|
||||
|
||||
def action_next_problem(self) -> None:
|
||||
"""Go to next problem."""
|
||||
# TODO: Implement problem navigation
|
||||
pass
|
||||
|
||||
def action_prev_problem(self) -> None:
|
||||
"""Go to previous problem."""
|
||||
# TODO: Implement problem navigation
|
||||
pass
|
||||
|
||||
def action_save_file(self) -> None:
|
||||
"""Save current file in editor."""
|
||||
try:
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if workspace.has_unsaved_changes():
|
||||
workspace.save_active_editor()
|
||||
else:
|
||||
self.notify("No unsaved changes", severity="warning")
|
||||
except Exception as e:
|
||||
self.notify(f"Save failed: {e}", severity="error")
|
||||
|
||||
def action_undo(self) -> None:
|
||||
"""Undo last action in focused widget."""
|
||||
# Undo is handled by the focused widget (TextArea has built-in undo)
|
||||
# This action provides feedback if no undo is available
|
||||
focused = self.focused
|
||||
if focused and hasattr(focused, "undo"):
|
||||
focused.undo()
|
||||
else:
|
||||
self.notify("Undo not available", severity="warning")
|
||||
|
||||
def action_quick_open(self) -> None:
|
||||
"""Quick file open."""
|
||||
# TODO: Implement quick open
|
||||
pass
|
||||
|
||||
async def action_select_theme(self) -> None:
|
||||
"""Open theme selector."""
|
||||
from clide.themes.registry import get_all_themes
|
||||
|
||||
# Get all available themes
|
||||
themes = get_all_themes()
|
||||
theme_names = [t.name for t in themes]
|
||||
|
||||
# Use Textual's built-in selection if available, otherwise cycle
|
||||
# For now, simple cycle through themes
|
||||
current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0
|
||||
next_idx = (current_idx + 1) % len(theme_names)
|
||||
self.set_theme(theme_names[next_idx])
|
||||
|
||||
# Event handlers for panel messages
|
||||
async def on_sidebar_panel_file_selected(
|
||||
self,
|
||||
event: SidebarPanel.FileSelected,
|
||||
) -> None:
|
||||
"""Handle file selection from sidebar."""
|
||||
self.current_file = event.path
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(event.path)
|
||||
|
||||
async def on_sidebar_panel_git_file_selected(
|
||||
self,
|
||||
event: SidebarPanel.GitFileSelected,
|
||||
) -> None:
|
||||
"""Handle git file selection - show diff."""
|
||||
diff = await self.diff_controller.get_file_diff(
|
||||
str(event.path),
|
||||
staged=event.staged,
|
||||
)
|
||||
if diff:
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.show_diff(diff)
|
||||
|
||||
async def on_sidebar_panel_branch_changed(
|
||||
self,
|
||||
event: SidebarPanel.BranchChanged,
|
||||
) -> None:
|
||||
"""Handle branch change."""
|
||||
success = await self.git_controller.checkout_branch(event.branch)
|
||||
if success:
|
||||
await self._refresh_git()
|
||||
|
||||
async def on_context_panel_problem_clicked(
|
||||
self,
|
||||
event: ContextPanel.ProblemClicked,
|
||||
) -> None:
|
||||
"""Handle problem click - open file at line."""
|
||||
problem = event.problem
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(problem.file_path, line=problem.line)
|
||||
|
||||
async def on_context_panel_todo_clicked(
|
||||
self,
|
||||
event: ContextPanel.TodoClicked,
|
||||
) -> None:
|
||||
"""Handle TODO click - open file at line."""
|
||||
item = event.item
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(item.file_path, line=item.line)
|
||||
|
||||
async def on_context_panel_project_todo_clicked(
|
||||
self,
|
||||
event: ContextPanel.ProjectTodoClicked,
|
||||
) -> None:
|
||||
"""Handle project TODO click - open TODO.md at line."""
|
||||
item = event.item
|
||||
todo_path = self.workdir / "TODO.md"
|
||||
if todo_path.exists():
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(todo_path, line=item.line)
|
||||
|
||||
async def on_context_panel_todo_md_created(
|
||||
self,
|
||||
event: ContextPanel.TodoMdCreated,
|
||||
) -> None:
|
||||
"""Handle TODO.md creation - refresh TODOs and open file."""
|
||||
# Refresh TODOs to pick up the new file
|
||||
await self._refresh_todos()
|
||||
# Open the new file in editor
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(event.path)
|
||||
self.notify("Created TODO.md")
|
||||
|
||||
async def on_context_panel_jira_refresh_requested(
|
||||
self,
|
||||
_event: ContextPanel.JiraRefreshRequested,
|
||||
) -> None:
|
||||
"""Handle Jira refresh request."""
|
||||
await self._refresh_jira()
|
||||
|
||||
async def on_workspace_panel_file_saved(
|
||||
self,
|
||||
event: WorkspacePanel.FileSaved,
|
||||
) -> None:
|
||||
"""Handle file save - refresh problems and git."""
|
||||
self.notify(f"Saved: {event.path.name}", severity="information")
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
|
||||
async def on_workspace_panel_diff_accepted(
|
||||
self,
|
||||
event: WorkspacePanel.DiffAccepted,
|
||||
) -> None:
|
||||
"""Handle diff accept."""
|
||||
await self.diff_controller.accept_proposal(event.file_path)
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
|
||||
async def on_workspace_panel_diff_rejected(
|
||||
self,
|
||||
event: WorkspacePanel.DiffRejected,
|
||||
) -> None:
|
||||
"""Handle diff reject."""
|
||||
await self.diff_controller.reject_proposal(event.file_path)
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.clear_diff()
|
||||
|
||||
def on_claude_panel_claude_started(
|
||||
self,
|
||||
_event: ClaudePanel.ClaudeStarted,
|
||||
) -> None:
|
||||
"""Handle Claude Code started."""
|
||||
self.notify("Claude Code started", severity="information")
|
||||
|
||||
def on_claude_panel_claude_exited(
|
||||
self,
|
||||
event: ClaudePanel.ClaudeExited,
|
||||
) -> None:
|
||||
"""Handle Claude Code exited."""
|
||||
if event.return_code != 0:
|
||||
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
|
||||
|
||||
def on_workspace_panel_maximize_requested(
|
||||
self,
|
||||
_event: WorkspacePanel.MaximizeRequested,
|
||||
) -> None:
|
||||
"""Handle workspace maximize request - hide sidebars."""
|
||||
# Hide sidebars when workspace is maximized
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
context = self.query_one(ContextPanel)
|
||||
sidebar.display = False
|
||||
context.display = False
|
||||
|
||||
# Hide Claude panel
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.display = False
|
||||
|
||||
def on_workspace_panel_restore_requested(
|
||||
self,
|
||||
_event: WorkspacePanel.RestoreRequested,
|
||||
) -> None:
|
||||
"""Handle workspace restore request - show sidebars."""
|
||||
# Restore sidebars based on saved visibility settings
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
context = self.query_one(ContextPanel)
|
||||
sidebar.display = self._user_settings.sidebar_visible
|
||||
context.display = self._user_settings.context_visible
|
||||
|
||||
# Show Claude panel
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.display = True
|
||||
|
||||
def on_workspace_panel_close_requested(
|
||||
self,
|
||||
_event: WorkspacePanel.CloseRequested,
|
||||
) -> None:
|
||||
"""Handle workspace close request."""
|
||||
self.workspace_visible = False
|
||||
|
||||
def on_sidebar_panel_claude_command_requested(
|
||||
self,
|
||||
event: SidebarPanel.ClaudeCommandRequested,
|
||||
) -> None:
|
||||
"""Handle Claude command request from git panel.
|
||||
|
||||
Sends skill commands (e.g., /commit) to Claude terminal.
|
||||
Ensures the specific skill is installed before sending.
|
||||
"""
|
||||
from clide.services.skill_installer import get_skill_installer
|
||||
|
||||
# Extract skill name from command (e.g., "/commit" -> "commit")
|
||||
skill_name = event.command.lstrip("/").split()[0]
|
||||
|
||||
installer = get_skill_installer(project_dir=self.workdir)
|
||||
|
||||
# Quick check if already installed
|
||||
if installer.is_installed(skill_name):
|
||||
self._send_claude_command(event.command)
|
||||
return
|
||||
|
||||
# Need to install - show notification and do in background
|
||||
self.notify(f"Installing {skill_name} skill...", timeout=10)
|
||||
self._install_skill_and_run(skill_name, event.command)
|
||||
|
||||
@work(thread=True)
|
||||
def _install_skill_and_run(self, skill_name: str, command: str) -> tuple[str, str]:
|
||||
"""Install skill in background thread and return command to run."""
|
||||
self.git_controller._ensure_skill(skill_name)
|
||||
return (skill_name, command)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker completion."""
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Check if this was a skill installation worker
|
||||
if event.worker.name == "_install_skill_and_run":
|
||||
result = event.worker.result
|
||||
if result:
|
||||
skill_name, command = result
|
||||
self._send_claude_command(command)
|
||||
self.notify(f"{skill_name} skill installed!", severity="information", timeout=3)
|
||||
|
||||
def _send_claude_command(self, command: str) -> None:
|
||||
"""Send a command to Claude terminal."""
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.send_input(command)
|
||||
self.action_focus_claude()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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")
|
||||
|
||||
|
||||
@app.command()
|
||||
def update(
|
||||
check_only: Annotated[
|
||||
bool,
|
||||
typer.Option("--check", "-c", help="Only check for updates, don't install"),
|
||||
] = False,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option("--force", "-f", help="Force update even if already on latest"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Check for and install updates.
|
||||
|
||||
Updates are downloaded from git.schweitz.net releases.
|
||||
User settings in ~/.clide/ are preserved across updates.
|
||||
"""
|
||||
_ = force # TODO: Implement force update functionality
|
||||
from clide.services.update_service import check_for_updates, perform_update
|
||||
|
||||
typer.echo(f"Current version: {__version__}")
|
||||
typer.echo("Checking for updates...")
|
||||
|
||||
if check_only:
|
||||
result = check_for_updates()
|
||||
if result.error:
|
||||
typer.echo(f"Error: {result.error}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if result.update_available:
|
||||
typer.echo(f"Update available: {result.latest_version}")
|
||||
if result.release_info and result.release_info.release_notes:
|
||||
typer.echo("\nRelease notes:")
|
||||
typer.echo(result.release_info.release_notes[:500])
|
||||
else:
|
||||
typer.echo("Already running the latest version.")
|
||||
return
|
||||
|
||||
# Perform update with progress indication
|
||||
def progress_callback(downloaded: int, total: int) -> None:
|
||||
if total > 0:
|
||||
pct = (downloaded / total) * 100
|
||||
typer.echo(f"\rDownloading: {pct:.1f}%", nl=False)
|
||||
|
||||
success, message = perform_update(progress_callback)
|
||||
typer.echo("") # Newline after progress
|
||||
typer.echo(message)
|
||||
|
||||
if not success:
|
||||
raise typer.Exit(1)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,),
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path) if path else 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
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Git controller for managing git operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.git import GitBranch, GitCommit, GitStatus
|
||||
from clide.services.skill_installer import get_skill_installer
|
||||
|
||||
# Git operations that can be delegated to Claude via skills
|
||||
GitSkillCommand = Literal["commit", "stash", "pull", "push", "branch"]
|
||||
|
||||
|
||||
@controller
|
||||
class GitController:
|
||||
"""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)
|
||||
|
||||
# =========================================================================
|
||||
# Claude Skill Integration
|
||||
# =========================================================================
|
||||
|
||||
class ClaudeCommandRequested(Message):
|
||||
"""Emitted when a git command should be sent to Claude."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
def _ensure_skill(self, skill_name: str) -> bool:
|
||||
"""Ensure a specific skill is installed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name (e.g., "commit", "stash").
|
||||
|
||||
Returns:
|
||||
True if skill is available.
|
||||
"""
|
||||
installer = get_skill_installer()
|
||||
|
||||
# Check if already installed
|
||||
if installer.is_installed(skill_name):
|
||||
return True
|
||||
|
||||
# Try to install from template (project scope by default)
|
||||
try:
|
||||
installer.install(skill_name, scope="project")
|
||||
return True
|
||||
except ValueError:
|
||||
# Template not found
|
||||
return False
|
||||
except FileExistsError:
|
||||
# Already exists (race condition)
|
||||
return True
|
||||
|
||||
def _ensure_git_skill(self) -> bool:
|
||||
"""Ensure all git skills are installed (legacy compatibility).
|
||||
|
||||
Returns:
|
||||
True if commit skill is available.
|
||||
"""
|
||||
return self._ensure_skill("commit")
|
||||
|
||||
def request_claude_commit(self) -> bool:
|
||||
"""Request Claude to handle the commit workflow.
|
||||
|
||||
Emits ClaudeCommandRequested with /commit command.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
# The app will handle this message and send to Claude terminal
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_stash(self) -> bool:
|
||||
"""Request Claude to handle stashing changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_pull(self) -> bool:
|
||||
"""Request Claude to handle pulling changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_push(self) -> bool:
|
||||
"""Request Claude to handle pushing changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def get_claude_command(self, action: GitSkillCommand) -> str:
|
||||
"""Get the Claude command string for a git action.
|
||||
|
||||
Args:
|
||||
action: The git action to perform.
|
||||
|
||||
Returns:
|
||||
The command string to send to Claude (e.g., "/commit").
|
||||
"""
|
||||
self._ensure_git_skill()
|
||||
return f"/{action}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,163 @@
|
||||
"""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 (
|
||||
ProjectTodoItem,
|
||||
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],
|
||||
project_items: list[ProjectTodoItem],
|
||||
summary: TodosSummary,
|
||||
) -> None:
|
||||
self.items = items
|
||||
self.project_items = project_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 code TODO items."""
|
||||
return self._state.items
|
||||
|
||||
@property
|
||||
def project_items(self) -> list[ProjectTodoItem]:
|
||||
"""Get list of project TODO items from TODO.md."""
|
||||
return self._state.project_items
|
||||
|
||||
@property
|
||||
def summary(self) -> TodosSummary:
|
||||
"""Get TODOs summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def total_count(self) -> int:
|
||||
"""Get total code TODO count."""
|
||||
return self._state.summary.total
|
||||
|
||||
@property
|
||||
def project_count(self) -> int:
|
||||
"""Get total project TODO count."""
|
||||
return self._state.summary.project_total
|
||||
|
||||
async def refresh(
|
||||
self,
|
||||
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
|
||||
"""Refresh TODOs from project.
|
||||
|
||||
Returns:
|
||||
Tuple of (code items, project items, summary)
|
||||
"""
|
||||
items, project_items, summary = await self._scanner.scan()
|
||||
|
||||
self._state.items = items
|
||||
self._state.project_items = project_items
|
||||
self._state.summary = summary
|
||||
|
||||
return items, project_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) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
|
||||
"""Scan for TODOs and return items.
|
||||
|
||||
Returns:
|
||||
Tuple of (code items, project items)
|
||||
"""
|
||||
items, project_items, _ = await self.refresh()
|
||||
return items, project_items
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Built-in extensions for Clide."""
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Pluggy hook specifications for Clide extensions."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
from clide.services.file_watcher import FileEvent
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_changed(self, event: "FileEvent") -> None:
|
||||
"""Called when a file is created, modified, deleted, or moved.
|
||||
|
||||
Extensions can use this to:
|
||||
- Refresh TODO scanning
|
||||
- Re-run linters
|
||||
- Update Jira issue links
|
||||
- Trigger custom actions
|
||||
|
||||
Args:
|
||||
event: The file event with path, type, and timestamp
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_saved(self, path: Path) -> None:
|
||||
"""Called after a file is saved by the editor.
|
||||
|
||||
More specific than file_changed - only for user saves.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the saved file
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_claude_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Called when Claude Code performs an action.
|
||||
|
||||
Extensions can use this to react to Claude's actions,
|
||||
such as opening files, making edits, or running commands.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "file_read", "file_edit", "tool_use")
|
||||
data: Event-specific data (e.g., {"path": "/path/to/file"})
|
||||
"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Extension manager for loading and managing Clide extensions."""
|
||||
|
||||
from importlib.metadata import entry_points
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
from clide.services.file_watcher import FileEvent
|
||||
|
||||
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)
|
||||
|
||||
def trigger_file_changed(self, event: "FileEvent") -> None:
|
||||
"""Trigger file change hooks for all extensions.
|
||||
|
||||
Args:
|
||||
event: The file event with path, type, and timestamp
|
||||
"""
|
||||
self.hook.clide_on_file_changed(event=event)
|
||||
|
||||
def trigger_file_saved(self, path: Path) -> None:
|
||||
"""Trigger file saved hooks for all extensions.
|
||||
|
||||
Args:
|
||||
path: Path to the saved file
|
||||
"""
|
||||
self.hook.clide_on_file_saved(path=path)
|
||||
|
||||
def trigger_claude_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Trigger Claude event hooks for all extensions.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "file_read", "file_edit")
|
||||
data: Event-specific data
|
||||
"""
|
||||
self.hook.clide_on_claude_event(event_type=event_type, data=data)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared utility functions and helpers for Clide."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Pydantic models for Clide."""
|
||||
|
||||
from clide.models.config import ClideSettings, PanelConfig
|
||||
from clide.models.db import ConnectionLog, Project, Session, UserPreference
|
||||
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
|
||||
from clide.models.workspace import TAB_ICONS, TabInfo, TabType
|
||||
|
||||
__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",
|
||||
# Workspace
|
||||
"TabInfo",
|
||||
"TabType",
|
||||
"TAB_ICONS",
|
||||
# Database (SQLModel)
|
||||
"Project",
|
||||
"Session",
|
||||
"UserPreference",
|
||||
"ConnectionLog",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SQLModel table models for persistent storage.
|
||||
|
||||
These models serve both the standalone Clide TUI and the clide-web server.
|
||||
They ARE Pydantic models (SQLModel inherits from BaseModel).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Project(SQLModel, table=True):
|
||||
"""A project (git repo) that can be opened in Clide."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str = Field(unique=True, index=True)
|
||||
path: str
|
||||
theme: str = "summer-night"
|
||||
last_accessed: datetime | None = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class Session(SQLModel, table=True):
|
||||
"""A tmux session running a Clide instance (used by clide-web)."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_name: str = Field(index=True)
|
||||
tmux_session: str = Field(unique=True)
|
||||
pid: int | None = None
|
||||
status: str = "active"
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
last_activity: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class UserPreference(SQLModel, table=True):
|
||||
"""Key-value user preferences persisted across restarts."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
key: str = Field(unique=True, index=True)
|
||||
value: str
|
||||
|
||||
|
||||
class ConnectionLog(SQLModel, table=True):
|
||||
"""Audit log of browser connections (used by clide-web)."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_name: str
|
||||
client_ip: str
|
||||
connected_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
disconnected_at: datetime | None = None
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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, ...]
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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 ProjectTodoItem(BaseModel):
|
||||
"""A TODO item from TODO.md file."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
text: str
|
||||
section: str # Top-level section (## heading)
|
||||
subsection: str | None = None # Optional subsection (### heading)
|
||||
line: int # Line number in TODO.md
|
||||
checked: bool = False # Whether the checkbox is checked
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
"""Get full category path."""
|
||||
if self.subsection:
|
||||
return f"{self.section} › {self.subsection}"
|
||||
return self.section
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Icon for display."""
|
||||
return "☑" if self.checked else "☐"
|
||||
|
||||
|
||||
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
|
||||
project_todo_count: int = 0 # Count from TODO.md
|
||||
project_done_count: int = 0 # Checked items in TODO.md
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of code TODOs."""
|
||||
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
|
||||
|
||||
@property
|
||||
def project_total(self) -> int:
|
||||
"""Total number of project TODOs."""
|
||||
return self.project_todo_count + self.project_done_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] = []
|
||||
project_items: list[ProjectTodoItem] = [] # Items from TODO.md
|
||||
summary: TodosSummary = TodosSummary()
|
||||
filter_type: TodoType | None = None
|
||||
selected_index: int | None = None
|
||||
group_by_file: bool = True
|
||||
show_completed_project_todos: bool = False # Toggle for checked items
|
||||
|
||||
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]
|
||||
|
||||
def project_items_by_section(self, section: str) -> list[ProjectTodoItem]:
|
||||
"""Get project TODO items for a specific section."""
|
||||
return [item for item in self.project_items if item.section == section]
|
||||
|
||||
def get_project_sections(self) -> list[str]:
|
||||
"""Get unique sections from project TODOs."""
|
||||
sections: list[str] = []
|
||||
for item in self.project_items:
|
||||
if item.section not in sections:
|
||||
sections.append(item.section)
|
||||
return sections
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Workspace tab models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TabType(str, Enum):
|
||||
"""Types of workspace tabs."""
|
||||
|
||||
EDITOR = "editor"
|
||||
TERMINAL = "terminal"
|
||||
DIFF = "diff"
|
||||
|
||||
|
||||
# Nerd Font icons for each tab type
|
||||
TAB_ICONS: dict[TabType, str] = {
|
||||
TabType.EDITOR: "\uf15c", # nf-fa-file_text_o
|
||||
TabType.TERMINAL: "\uf120", # nf-fa-terminal
|
||||
TabType.DIFF: "\uf440", # nf-oct-diff
|
||||
}
|
||||
|
||||
|
||||
class TabInfo(BaseModel):
|
||||
"""Metadata for a workspace tab."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
tab_id: str
|
||||
tab_type: TabType
|
||||
label: str
|
||||
file_path: Path | None = None
|
||||
is_proposal: bool = False
|
||||
diff_file_path: str | None = None
|
||||
@@ -0,0 +1,20 @@
|
||||
"""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.skill_installer import SkillInstaller, get_skill_installer
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
__all__ = [
|
||||
"GitService",
|
||||
"LinterService",
|
||||
"ProcessService",
|
||||
"SettingsService",
|
||||
"SkillInstaller",
|
||||
"TodoScanner",
|
||||
"UserSettings",
|
||||
"get_settings_service",
|
||||
"get_skill_installer",
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Claude Code event detection and parsing.
|
||||
|
||||
This module provides event infrastructure for detecting Claude Code actions
|
||||
from terminal output, enabling tight IDE integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
# Event Types
|
||||
# -----------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaudeEvent:
|
||||
"""Base class for Claude Code events."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileReadEvent(ClaudeEvent):
|
||||
"""Emitted when Claude reads a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEditEvent(ClaudeEvent):
|
||||
"""Emitted when Claude edits a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileWriteEvent(ClaudeEvent):
|
||||
"""Emitted when Claude creates/writes a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobEvent(ClaudeEvent):
|
||||
"""Emitted when Claude searches for files."""
|
||||
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GrepEvent(ClaudeEvent):
|
||||
"""Emitted when Claude searches file contents."""
|
||||
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStartEvent(ClaudeEvent):
|
||||
"""Emitted when Claude starts using a tool."""
|
||||
|
||||
tool_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolEndEvent(ClaudeEvent):
|
||||
"""Emitted when Claude finishes using a tool."""
|
||||
|
||||
tool_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffProposedEvent(ClaudeEvent):
|
||||
"""Emitted when Claude proposes a diff."""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
# Textual Messages
|
||||
# ----------------
|
||||
|
||||
|
||||
class ClaudeEventMessage(Message):
|
||||
"""Textual message wrapper for Claude events."""
|
||||
|
||||
def __init__(self, event: ClaudeEvent) -> None:
|
||||
self.event = event
|
||||
super().__init__()
|
||||
|
||||
|
||||
# Pattern Matching
|
||||
# ----------------
|
||||
|
||||
# Patterns for detecting Claude Code output
|
||||
PATTERNS = {
|
||||
# Tool invocations - Claude Code shows these with bullet points
|
||||
"tool_read": re.compile(r"● Read\(([^)]+)\)"),
|
||||
"tool_edit": re.compile(r"● Edit\(([^)]+)\)"),
|
||||
"tool_write": re.compile(r"● Write\(([^)]+)\)"),
|
||||
"tool_glob": re.compile(r"● Glob\(([^)]+)\)"),
|
||||
"tool_grep": re.compile(r"● Grep\(([^)]+)\)"),
|
||||
# Generic tool pattern
|
||||
"tool_start": re.compile(r"● (\w+)\("),
|
||||
"tool_end": re.compile(r"└─"),
|
||||
# Diff headers
|
||||
"diff_header": re.compile(r"^@@\s*-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*@@", re.MULTILINE),
|
||||
"diff_file": re.compile(r"^(?:---|\+\+\+)\s+([^\s]+)", re.MULTILINE),
|
||||
}
|
||||
|
||||
|
||||
class ClaudeEventParser:
|
||||
"""Parses Claude Code terminal output to detect events.
|
||||
|
||||
This parser is designed to work with raw terminal data fed
|
||||
through the pyte event callback.
|
||||
"""
|
||||
|
||||
def __init__(self, callback: Callable[[ClaudeEvent], None] | None = None) -> None:
|
||||
"""Initialize the event parser.
|
||||
|
||||
Args:
|
||||
callback: Optional callback invoked for each detected event.
|
||||
"""
|
||||
self._callback = callback
|
||||
self._buffer = ""
|
||||
self._current_tool: str | None = None
|
||||
|
||||
def set_callback(self, callback: Callable[[ClaudeEvent], None] | None) -> None:
|
||||
"""Set the event callback."""
|
||||
self._callback = callback
|
||||
|
||||
def feed(self, data: str) -> list[ClaudeEvent]:
|
||||
"""Feed terminal data and return detected events.
|
||||
|
||||
Args:
|
||||
data: Raw terminal data from Claude Code.
|
||||
|
||||
Returns:
|
||||
List of detected events.
|
||||
"""
|
||||
events: list[ClaudeEvent] = []
|
||||
|
||||
# Add to buffer for multi-line matching
|
||||
self._buffer += data
|
||||
|
||||
# Limit buffer size to prevent memory issues
|
||||
if len(self._buffer) > 10000:
|
||||
self._buffer = self._buffer[-5000:]
|
||||
|
||||
# Check for tool invocations
|
||||
for match in PATTERNS["tool_read"].finditer(data):
|
||||
path = Path(match.group(1).strip())
|
||||
events.append(FileReadEvent(path=path))
|
||||
|
||||
for match in PATTERNS["tool_edit"].finditer(data):
|
||||
path = Path(match.group(1).strip())
|
||||
events.append(FileEditEvent(path=path))
|
||||
|
||||
for match in PATTERNS["tool_write"].finditer(data):
|
||||
path = Path(match.group(1).strip())
|
||||
events.append(FileWriteEvent(path=path))
|
||||
|
||||
for match in PATTERNS["tool_glob"].finditer(data):
|
||||
pattern = match.group(1).strip()
|
||||
events.append(GlobEvent(pattern=pattern))
|
||||
|
||||
for match in PATTERNS["tool_grep"].finditer(data):
|
||||
pattern = match.group(1).strip()
|
||||
events.append(GrepEvent(pattern=pattern))
|
||||
|
||||
# Check for generic tool start/end
|
||||
for match in PATTERNS["tool_start"].finditer(data):
|
||||
tool_name = match.group(1)
|
||||
# Don't emit for tools we handle specifically
|
||||
if tool_name not in ("Read", "Edit", "Write", "Glob", "Grep"):
|
||||
events.append(ToolStartEvent(tool_name=tool_name))
|
||||
self._current_tool = tool_name
|
||||
|
||||
if PATTERNS["tool_end"].search(data) and self._current_tool:
|
||||
events.append(ToolEndEvent(tool_name=self._current_tool))
|
||||
self._current_tool = None
|
||||
|
||||
# Check for diff content
|
||||
if PATTERNS["diff_header"].search(self._buffer):
|
||||
# Extract diff content (simplified - real impl would be more sophisticated)
|
||||
events.append(DiffProposedEvent(content=self._buffer))
|
||||
# Clear buffer after detecting diff
|
||||
self._buffer = ""
|
||||
|
||||
# Invoke callback for each event
|
||||
if self._callback:
|
||||
for evt in events:
|
||||
try:
|
||||
self._callback(evt)
|
||||
except Exception:
|
||||
pass # Don't let callback errors propagate
|
||||
|
||||
return events
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset parser state."""
|
||||
self._buffer = ""
|
||||
self._current_tool = None
|
||||
|
||||
|
||||
# Global parser instance for convenience
|
||||
_event_parser: ClaudeEventParser | None = None
|
||||
|
||||
|
||||
def get_event_parser() -> ClaudeEventParser:
|
||||
"""Get the global event parser instance."""
|
||||
global _event_parser
|
||||
if _event_parser is None:
|
||||
_event_parser = ClaudeEventParser()
|
||||
return _event_parser
|
||||
|
||||
|
||||
def setup_event_parsing(callback: Callable[[ClaudeEvent], None]) -> None:
|
||||
"""Set up event parsing with the given callback.
|
||||
|
||||
This should be called during app initialization to wire up
|
||||
the event parser with the terminal stream.
|
||||
"""
|
||||
from clide.vendor import pyte
|
||||
|
||||
parser = get_event_parser()
|
||||
parser.set_callback(callback)
|
||||
|
||||
# Wire up to pyte's event callback
|
||||
# The parser.feed returns events but pyte expects None return
|
||||
def _feed_wrapper(data: str) -> None:
|
||||
parser.feed(data)
|
||||
|
||||
pyte.set_event_callback(_feed_wrapper)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""SQLite database engine and session management.
|
||||
|
||||
Used by both standalone Clide and clide-web. The database file
|
||||
defaults to ~/.clide/clide.db but is configurable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import SQLModel, create_engine
|
||||
|
||||
_engine = None
|
||||
|
||||
DEFAULT_DB_PATH = Path.home() / ".clide" / "clide.db"
|
||||
|
||||
|
||||
def get_engine(db_path: Path | None = None):
|
||||
"""Create or return the SQLAlchemy engine."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
path = db_path or DEFAULT_DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{path}",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def init_db(db_path: Path | None = None) -> None:
|
||||
"""Create all tables if they don't exist."""
|
||||
# Import models so SQLModel registers them
|
||||
import clide.models.db # noqa: F401
|
||||
|
||||
engine = get_engine(db_path)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[DBSession, None, None]:
|
||||
"""Yield a database session. Usable as a FastAPI dependency or context manager."""
|
||||
if _engine is None:
|
||||
raise RuntimeError("Database not initialized — call init_db() first")
|
||||
with DBSession(_engine) as session:
|
||||
yield session
|
||||
@@ -0,0 +1,204 @@
|
||||
"""File operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Language extension mapping for syntax highlighting
|
||||
# Maps file extensions to tree-sitter language identifiers
|
||||
LANGUAGE_MAP: dict[str, str] = {
|
||||
# Python
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".pyw": "python",
|
||||
# JavaScript/TypeScript
|
||||
".js": "javascript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
# Web
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "css",
|
||||
".sass": "css",
|
||||
".less": "css",
|
||||
# Dart/Flutter
|
||||
".dart": "dart",
|
||||
# Data formats
|
||||
".json": "json",
|
||||
".jsonc": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".xml": "xml",
|
||||
# Markdown
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
# Shell
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".fish": "bash",
|
||||
# SQL
|
||||
".sql": "sql",
|
||||
# Other languages
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".hpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".vue": "vue",
|
||||
".svelte": "svelte",
|
||||
".lua": "lua",
|
||||
".r": "r",
|
||||
".R": "r",
|
||||
".swift": "swift",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".scala": "scala",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
}
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for file I/O operations."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
|
||||
# Static methods for simple sync operations (used by EditorPane)
|
||||
@staticmethod
|
||||
def read_file(path: Path) -> str:
|
||||
"""Read file contents synchronously.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
|
||||
Returns:
|
||||
File contents as string
|
||||
"""
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def write_file(path: Path, content: str) -> bool:
|
||||
"""Write content to file synchronously.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def detect_language(path: Path) -> str | None:
|
||||
"""Detect language from file extension.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
Language identifier for tree-sitter or None
|
||||
"""
|
||||
return LANGUAGE_MAP.get(path.suffix.lower())
|
||||
|
||||
# Instance methods for async operations
|
||||
async def read_file_async(self, path: Path) -> str:
|
||||
"""Read file contents asynchronously.
|
||||
|
||||
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_async(self, path: Path, content: str) -> None:
|
||||
"""Write content to file asynchronously.
|
||||
|
||||
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
|
||||
"""
|
||||
return LANGUAGE_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
|
||||
@@ -0,0 +1,287 @@
|
||||
"""File system watching service for real-time sync.
|
||||
|
||||
This module provides file system monitoring capabilities for the Clide IDE,
|
||||
enabling reactive updates when files change on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from textual.message import Message
|
||||
|
||||
try:
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirDeletedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileDeletedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
)
|
||||
from watchdog.events import (
|
||||
FileSystemEventHandler as WatchdogHandler,
|
||||
)
|
||||
from watchdog.observers import Observer as WatchdogObserver
|
||||
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
WatchdogObserver = None # type: ignore[misc, assignment]
|
||||
WatchdogHandler = object # type: ignore[misc, assignment]
|
||||
FileCreatedEvent = None # type: ignore[misc, assignment]
|
||||
FileModifiedEvent = None # type: ignore[misc, assignment]
|
||||
FileDeletedEvent = None # type: ignore[misc, assignment]
|
||||
FileMovedEvent = None # type: ignore[misc, assignment]
|
||||
DirCreatedEvent = None # type: ignore[misc, assignment]
|
||||
DirModifiedEvent = None # type: ignore[misc, assignment]
|
||||
DirDeletedEvent = None # type: ignore[misc, assignment]
|
||||
DirMovedEvent = None # type: ignore[misc, assignment]
|
||||
|
||||
|
||||
class FileEvent(BaseModel):
|
||||
"""A file system event.
|
||||
|
||||
Attributes:
|
||||
path: The path to the file/directory that changed.
|
||||
event_type: The type of change that occurred.
|
||||
timestamp: When the event occurred.
|
||||
is_directory: Whether this is a directory event.
|
||||
old_path: For move events, the original path.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
path: Path
|
||||
event_type: Literal["created", "modified", "deleted", "moved"]
|
||||
timestamp: datetime
|
||||
is_directory: bool = False
|
||||
old_path: Path | None = None
|
||||
|
||||
|
||||
class FileEventMessage(Message):
|
||||
"""Textual message for file events."""
|
||||
|
||||
def __init__(self, event: FileEvent) -> None:
|
||||
self.event = event
|
||||
super().__init__()
|
||||
|
||||
|
||||
class FileWatcher:
|
||||
"""Watches a directory for file system changes.
|
||||
|
||||
Uses watchdog for efficient cross-platform file monitoring.
|
||||
Emits FileEvent objects to registered handlers.
|
||||
|
||||
Example:
|
||||
watcher = FileWatcher(Path.cwd())
|
||||
watcher.register_handler(my_handler)
|
||||
watcher.start()
|
||||
# ... later ...
|
||||
watcher.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, ignore_patterns: list[str] | None = None) -> None:
|
||||
"""Initialize the file watcher.
|
||||
|
||||
Args:
|
||||
root: The root directory to watch.
|
||||
ignore_patterns: Glob patterns to ignore (e.g., ["*.pyc", "__pycache__"]).
|
||||
"""
|
||||
self._root = root.resolve()
|
||||
self._ignore_patterns = ignore_patterns or [
|
||||
"*.pyc",
|
||||
"__pycache__",
|
||||
".git",
|
||||
".venv",
|
||||
"venv",
|
||||
"node_modules",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".pytest_cache",
|
||||
"*.egg-info",
|
||||
".clide",
|
||||
".claude",
|
||||
]
|
||||
self._handlers: list[Callable[[FileEvent], None]] = []
|
||||
self._observer: WatchdogObserver | None = None # type: ignore[valid-type]
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if watchdog is available."""
|
||||
return WATCHDOG_AVAILABLE
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the watcher is currently running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
"""Get the root directory being watched."""
|
||||
return self._root
|
||||
|
||||
def register_handler(self, handler: Callable[[FileEvent], None]) -> None:
|
||||
"""Register a handler for file events.
|
||||
|
||||
Args:
|
||||
handler: A callable that accepts a FileEvent.
|
||||
"""
|
||||
if handler not in self._handlers:
|
||||
self._handlers.append(handler)
|
||||
|
||||
def unregister_handler(self, handler: Callable[[FileEvent], None]) -> None:
|
||||
"""Unregister a handler.
|
||||
|
||||
Args:
|
||||
handler: The handler to remove.
|
||||
"""
|
||||
if handler in self._handlers:
|
||||
self._handlers.remove(handler)
|
||||
|
||||
def _should_ignore(self, path: Path) -> bool:
|
||||
"""Check if a path should be ignored based on patterns."""
|
||||
path_str = str(path)
|
||||
for pattern in self._ignore_patterns:
|
||||
# Simple pattern matching - could be enhanced with fnmatch
|
||||
if pattern.startswith("*"):
|
||||
if path_str.endswith(pattern[1:]):
|
||||
return True
|
||||
elif pattern in path_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _emit_event(self, event: FileEvent) -> None:
|
||||
"""Emit an event to all handlers."""
|
||||
if self._should_ignore(event.path):
|
||||
return
|
||||
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(event)
|
||||
except Exception:
|
||||
pass # Don't let handler errors affect other handlers
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start watching for file changes.
|
||||
|
||||
Returns:
|
||||
True if started successfully, False if watchdog is not available.
|
||||
"""
|
||||
if not WATCHDOG_AVAILABLE:
|
||||
return False
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
event_handler = _WatchdogHandler(self)
|
||||
self._observer = WatchdogObserver()
|
||||
self._observer.schedule(event_handler, str(self._root), recursive=True)
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop watching for file changes."""
|
||||
if self._observer is not None:
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=5)
|
||||
self._observer = None
|
||||
self._running = False
|
||||
|
||||
|
||||
class _WatchdogHandler(WatchdogHandler): # type: ignore[misc, valid-type]
|
||||
"""Internal handler for watchdog events."""
|
||||
|
||||
def __init__(self, watcher: FileWatcher) -> None:
|
||||
super().__init__()
|
||||
self._watcher = watcher
|
||||
|
||||
def _create_event(
|
||||
self,
|
||||
src_path: str | bytes,
|
||||
event_type: Literal["created", "modified", "deleted", "moved"],
|
||||
is_directory: bool,
|
||||
dest_path: str | bytes | None = None,
|
||||
) -> FileEvent:
|
||||
"""Create a FileEvent from watchdog event data."""
|
||||
# Watchdog can return bytes or str depending on platform
|
||||
src = src_path.decode() if isinstance(src_path, bytes) else src_path
|
||||
dest = dest_path.decode() if isinstance(dest_path, bytes) else dest_path
|
||||
return FileEvent(
|
||||
path=Path(dest if dest else src),
|
||||
event_type=event_type,
|
||||
timestamp=datetime.now(),
|
||||
is_directory=is_directory,
|
||||
old_path=Path(src) if dest else None,
|
||||
)
|
||||
|
||||
def on_created(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(event.src_path, "created", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_modified(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(event.src_path, "modified", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_deleted(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(event.src_path, "deleted", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_moved(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(
|
||||
event.src_path, "moved", event.is_directory, event.dest_path
|
||||
)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
|
||||
# Global watcher instance
|
||||
_file_watcher: FileWatcher | None = None
|
||||
|
||||
|
||||
def get_file_watcher(root: Path | None = None) -> FileWatcher:
|
||||
"""Get or create the global file watcher.
|
||||
|
||||
Args:
|
||||
root: The root directory to watch. Only used on first call.
|
||||
|
||||
Returns:
|
||||
The FileWatcher instance.
|
||||
"""
|
||||
global _file_watcher
|
||||
if _file_watcher is None:
|
||||
_file_watcher = FileWatcher(root or Path.cwd())
|
||||
return _file_watcher
|
||||
|
||||
|
||||
def setup_file_watching(
|
||||
root: Path,
|
||||
handlers: list[Callable[[FileEvent], None]] | None = None,
|
||||
) -> FileWatcher:
|
||||
"""Set up file watching with optional initial handlers.
|
||||
|
||||
Args:
|
||||
root: The root directory to watch.
|
||||
handlers: Optional list of handlers to register.
|
||||
|
||||
Returns:
|
||||
The configured FileWatcher.
|
||||
"""
|
||||
global _file_watcher
|
||||
_file_watcher = FileWatcher(root)
|
||||
|
||||
if handlers:
|
||||
for handler in handlers:
|
||||
_file_watcher.register_handler(handler)
|
||||
|
||||
_file_watcher.start()
|
||||
return _file_watcher
|
||||
@@ -0,0 +1,273 @@
|
||||
"""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 files go in both untracked list and unstaged
|
||||
untracked.append(path)
|
||||
unstaged.append(
|
||||
GitChange(
|
||||
path=path,
|
||||
status=ChangeStatus.UNTRACKED,
|
||||
staged=False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if index_status != " ":
|
||||
staged.append(
|
||||
GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(index_status),
|
||||
staged=True,
|
||||
)
|
||||
)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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
|
||||
|
||||
# Debug
|
||||
terminal_debug: bool = False # Verbose terminal/pyte logging to ~/.clide/terminal_debug.log
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Skill installer service for Claude Code skills.
|
||||
|
||||
This module provides functionality to install skill templates
|
||||
into the user's Claude Code configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
# Path to bundled skill templates within Clide package
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "skills"
|
||||
|
||||
# Default installation locations
|
||||
USER_SKILLS_DIR = Path.home() / ".claude" / "skills"
|
||||
|
||||
|
||||
class SkillInstaller:
|
||||
"""Installs Claude Code skills from templates.
|
||||
|
||||
Skills can be installed to:
|
||||
- User level: ~/.claude/skills/ (available globally)
|
||||
- Project level: .claude/skills/ (available in project only)
|
||||
|
||||
Example:
|
||||
installer = SkillInstaller()
|
||||
|
||||
# Check if skill exists
|
||||
if not installer.is_installed("git-workflow"):
|
||||
installer.install("git-workflow")
|
||||
|
||||
# Install to project instead of user
|
||||
installer.install("git-workflow", scope="project")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
templates_dir: Path | None = None,
|
||||
project_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the skill installer.
|
||||
|
||||
Args:
|
||||
templates_dir: Override the templates directory.
|
||||
project_dir: Project directory for project-scoped skills.
|
||||
"""
|
||||
self._templates_dir = templates_dir or TEMPLATES_DIR
|
||||
self._project_dir = project_dir or Path.cwd()
|
||||
|
||||
@property
|
||||
def templates_dir(self) -> Path:
|
||||
"""Get the templates directory."""
|
||||
return self._templates_dir
|
||||
|
||||
@property
|
||||
def user_skills_dir(self) -> Path:
|
||||
"""Get the user skills directory."""
|
||||
return USER_SKILLS_DIR
|
||||
|
||||
@property
|
||||
def project_skills_dir(self) -> Path:
|
||||
"""Get the project skills directory."""
|
||||
return self._project_dir / ".claude" / "skills"
|
||||
|
||||
def list_available_templates(self) -> list[str]:
|
||||
"""List all available skill templates.
|
||||
|
||||
Returns:
|
||||
List of skill names that can be installed.
|
||||
"""
|
||||
if not self._templates_dir.exists():
|
||||
return []
|
||||
|
||||
return [
|
||||
d.name
|
||||
for d in self._templates_dir.iterdir()
|
||||
if d.is_dir() and (d / "SKILL.md").exists()
|
||||
]
|
||||
|
||||
def list_installed_skills(
|
||||
self,
|
||||
scope: Literal["user", "project", "all"] = "all",
|
||||
) -> list[dict[str, str]]:
|
||||
"""List installed skills.
|
||||
|
||||
Args:
|
||||
scope: Which skills to list - user, project, or all.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'name', 'scope', and 'path' keys.
|
||||
"""
|
||||
skills = []
|
||||
|
||||
if scope in ("user", "all"):
|
||||
if self.user_skills_dir.exists():
|
||||
for d in self.user_skills_dir.iterdir():
|
||||
if d.is_dir() and (d / "SKILL.md").exists():
|
||||
skills.append(
|
||||
{
|
||||
"name": d.name,
|
||||
"scope": "user",
|
||||
"path": str(d),
|
||||
}
|
||||
)
|
||||
|
||||
if scope in ("project", "all"):
|
||||
if self.project_skills_dir.exists():
|
||||
for d in self.project_skills_dir.iterdir():
|
||||
if d.is_dir() and (d / "SKILL.md").exists():
|
||||
skills.append(
|
||||
{
|
||||
"name": d.name,
|
||||
"scope": "project",
|
||||
"path": str(d),
|
||||
}
|
||||
)
|
||||
|
||||
return skills
|
||||
|
||||
def is_installed(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project", "any"] = "any",
|
||||
) -> bool:
|
||||
"""Check if a skill is installed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name to check.
|
||||
scope: Where to check - user, project, or any.
|
||||
|
||||
Returns:
|
||||
True if the skill is installed.
|
||||
"""
|
||||
if scope in ("user", "any"):
|
||||
user_skill = self.user_skills_dir / skill_name / "SKILL.md"
|
||||
if user_skill.exists():
|
||||
return True
|
||||
|
||||
if scope in ("project", "any"):
|
||||
project_skill = self.project_skills_dir / skill_name / "SKILL.md"
|
||||
if project_skill.exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_skill_path(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project", "any"] = "any",
|
||||
) -> Path | None:
|
||||
"""Get the path to an installed skill.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name.
|
||||
scope: Where to look - user, project, or any (project takes priority).
|
||||
|
||||
Returns:
|
||||
Path to the skill directory, or None if not found.
|
||||
"""
|
||||
# Project scope takes priority when scope is "any"
|
||||
if scope in ("project", "any"):
|
||||
project_skill = self.project_skills_dir / skill_name
|
||||
if (project_skill / "SKILL.md").exists():
|
||||
return project_skill
|
||||
|
||||
if scope in ("user", "any"):
|
||||
user_skill = self.user_skills_dir / skill_name
|
||||
if (user_skill / "SKILL.md").exists():
|
||||
return user_skill
|
||||
|
||||
return None
|
||||
|
||||
def install(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project"] = "user",
|
||||
overwrite: bool = False,
|
||||
) -> Path:
|
||||
"""Install a skill from templates.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name to install.
|
||||
scope: Where to install - user or project level.
|
||||
overwrite: Whether to overwrite existing installation.
|
||||
|
||||
Returns:
|
||||
Path to the installed skill.
|
||||
|
||||
Raises:
|
||||
ValueError: If skill template doesn't exist.
|
||||
FileExistsError: If skill exists and overwrite is False.
|
||||
"""
|
||||
# Check template exists
|
||||
template_dir = self._templates_dir / skill_name
|
||||
if not template_dir.exists() or not (template_dir / "SKILL.md").exists():
|
||||
raise ValueError(f"Skill template '{skill_name}' not found")
|
||||
|
||||
# Determine target directory
|
||||
if scope == "user":
|
||||
target_dir = self.user_skills_dir / skill_name
|
||||
else:
|
||||
target_dir = self.project_skills_dir / skill_name
|
||||
|
||||
# Check if already exists
|
||||
if target_dir.exists():
|
||||
if not overwrite:
|
||||
raise FileExistsError(f"Skill '{skill_name}' already installed at {target_dir}")
|
||||
shutil.rmtree(target_dir)
|
||||
|
||||
# Create parent directory
|
||||
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy template
|
||||
shutil.copytree(template_dir, target_dir)
|
||||
|
||||
return target_dir
|
||||
|
||||
def uninstall(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project"] = "user",
|
||||
) -> bool:
|
||||
"""Uninstall a skill.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name to uninstall.
|
||||
scope: Where to uninstall from - user or project level.
|
||||
|
||||
Returns:
|
||||
True if skill was uninstalled, False if it wasn't installed.
|
||||
"""
|
||||
if scope == "user":
|
||||
skill_dir = self.user_skills_dir / skill_name
|
||||
else:
|
||||
skill_dir = self.project_skills_dir / skill_name
|
||||
|
||||
if skill_dir.exists():
|
||||
shutil.rmtree(skill_dir)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def ensure_installed(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project"] = "project",
|
||||
) -> Path:
|
||||
"""Ensure a skill is installed, installing if needed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name.
|
||||
scope: Where to install if not present (default: project).
|
||||
|
||||
Returns:
|
||||
Path to the skill directory.
|
||||
|
||||
Raises:
|
||||
ValueError: If skill template doesn't exist.
|
||||
"""
|
||||
existing = self.get_skill_path(skill_name)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
return self.install(skill_name, scope=scope)
|
||||
|
||||
|
||||
# Global instance
|
||||
_skill_installer: SkillInstaller | None = None
|
||||
|
||||
|
||||
def get_skill_installer(project_dir: Path | None = None) -> SkillInstaller:
|
||||
"""Get or create the global skill installer.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory (only used on first call).
|
||||
|
||||
Returns:
|
||||
The SkillInstaller instance.
|
||||
"""
|
||||
global _skill_installer
|
||||
if _skill_installer is None:
|
||||
_skill_installer = SkillInstaller(project_dir=project_dir)
|
||||
return _skill_installer
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Syntax highlighting service for additional language support.
|
||||
|
||||
Textual 7.x includes built-in support for many languages when tree-sitter
|
||||
packages are installed. This module provides utilities for checking and
|
||||
registering additional languages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Languages supported by Textual's TextArea with tree-sitter packages
|
||||
SUPPORTED_LANGUAGES = {
|
||||
# Core web languages
|
||||
"python",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
# Markup/config
|
||||
"markdown",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
# Shell
|
||||
"bash",
|
||||
# SQL
|
||||
"sql",
|
||||
# Systems languages
|
||||
"rust",
|
||||
"go",
|
||||
"java",
|
||||
# Regex
|
||||
"regex",
|
||||
}
|
||||
|
||||
# Additional languages that may be registered if packages are available
|
||||
OPTIONAL_LANGUAGES = [
|
||||
"dart",
|
||||
"kotlin",
|
||||
"swift",
|
||||
"scala",
|
||||
"ruby",
|
||||
"php",
|
||||
"lua",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"elixir",
|
||||
"haskell",
|
||||
"ocaml",
|
||||
"zig",
|
||||
"nim",
|
||||
"vue",
|
||||
"svelte",
|
||||
]
|
||||
|
||||
|
||||
def register_languages() -> list[str]:
|
||||
"""Register additional languages with Textual's TextArea.
|
||||
|
||||
In Textual 7.x, languages are automatically registered when tree-sitter
|
||||
packages are installed. This function registers additional languages
|
||||
that need special handling (like TypeScript which has separate functions).
|
||||
|
||||
Returns:
|
||||
List of successfully registered language names
|
||||
"""
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
except ImportError:
|
||||
logger.warning("Textual not available")
|
||||
return []
|
||||
|
||||
registered = []
|
||||
|
||||
# Register TypeScript and TSX (they have special language function names)
|
||||
try:
|
||||
import tree_sitter_typescript as tst
|
||||
|
||||
# Register TypeScript
|
||||
try:
|
||||
TextArea.register_language(tst.language_typescript(), "typescript")
|
||||
registered.append("typescript")
|
||||
logger.debug("Registered language: typescript")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not register typescript: {e}")
|
||||
|
||||
# Register TSX
|
||||
try:
|
||||
TextArea.register_language(tst.language_tsx(), "tsx")
|
||||
registered.append("tsx")
|
||||
logger.debug("Registered language: tsx")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not register tsx: {e}")
|
||||
|
||||
except ImportError:
|
||||
logger.debug("tree-sitter-typescript not installed")
|
||||
|
||||
# Register other optional languages with standard API
|
||||
for lang_name in OPTIONAL_LANGUAGES:
|
||||
try:
|
||||
# Try to import the tree-sitter package for this language
|
||||
module_name = f"tree_sitter_{lang_name}"
|
||||
module = __import__(module_name)
|
||||
|
||||
# Get the language function
|
||||
if hasattr(module, "language"):
|
||||
language = module.language()
|
||||
|
||||
# Try to get a highlight query if available
|
||||
highlight_query = None
|
||||
if hasattr(module, "HIGHLIGHTS_QUERY"):
|
||||
highlight_query = module.HIGHLIGHTS_QUERY
|
||||
|
||||
# Register with Textual
|
||||
try:
|
||||
TextArea.register_language(language, lang_name, highlight_query)
|
||||
registered.append(lang_name)
|
||||
logger.debug(f"Registered language: {lang_name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not register language '{lang_name}': {e}")
|
||||
|
||||
except ImportError:
|
||||
# Package not installed, skip
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Error processing language '{lang_name}': {e}")
|
||||
|
||||
return registered
|
||||
|
||||
|
||||
def get_available_languages() -> list[str]:
|
||||
"""Get list of all available languages for syntax highlighting.
|
||||
|
||||
Returns:
|
||||
List of language names that can be used with TextArea
|
||||
"""
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
|
||||
# Create a temporary instance to check available languages
|
||||
ta = TextArea()
|
||||
return sorted(ta.available_languages)
|
||||
except ImportError:
|
||||
return sorted(SUPPORTED_LANGUAGES)
|
||||
except Exception:
|
||||
return sorted(SUPPORTED_LANGUAGES)
|
||||
|
||||
|
||||
def is_syntax_highlighting_available() -> bool:
|
||||
"""Check if syntax highlighting is available.
|
||||
|
||||
Returns:
|
||||
True if tree-sitter is installed and syntax highlighting works
|
||||
"""
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
|
||||
ta = TextArea("test", language="python")
|
||||
return ta.is_syntax_aware
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,244 @@
|
||||
"""TODO comment scanner service."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.todos import ProjectTodoItem, 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,
|
||||
)
|
||||
|
||||
# Pattern to match markdown checkboxes: - [ ] or - [x]
|
||||
CHECKBOX_PATTERN = re.compile(r"^(\s*)-\s*\[([ xX])\]\s*(.+)$")
|
||||
|
||||
# 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], list[ProjectTodoItem], TodosSummary]:
|
||||
"""Scan project for TODO comments and TODO.md items.
|
||||
|
||||
Returns:
|
||||
Tuple of (code todo items, project 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()
|
||||
|
||||
# Parse TODO.md if it exists
|
||||
project_items = self._parse_todo_md()
|
||||
|
||||
# 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
|
||||
project_todo_count = sum(1 for i in project_items if not i.checked)
|
||||
project_done_count = sum(1 for i in project_items if i.checked)
|
||||
|
||||
summary = TodosSummary(
|
||||
todo_count=todo_count,
|
||||
fixme_count=fixme_count,
|
||||
hack_count=hack_count,
|
||||
other_count=other_count,
|
||||
project_todo_count=project_todo_count,
|
||||
project_done_count=project_done_count,
|
||||
)
|
||||
|
||||
return items, project_items, summary
|
||||
|
||||
def _parse_todo_md(self) -> list[ProjectTodoItem]:
|
||||
"""Parse TODO.md file for checkbox items.
|
||||
|
||||
Returns:
|
||||
List of project TODO items
|
||||
"""
|
||||
todo_md_path = self.project_path / "TODO.md"
|
||||
if not todo_md_path.exists():
|
||||
return []
|
||||
|
||||
items: list[ProjectTodoItem] = []
|
||||
current_section = "General"
|
||||
current_subsection: str | None = None
|
||||
|
||||
try:
|
||||
content = todo_md_path.read_text(encoding="utf-8")
|
||||
for line_num, line in enumerate(content.split("\n"), 1):
|
||||
# Check for section headers (## Section)
|
||||
if line.startswith("## "):
|
||||
current_section = line[3:].strip()
|
||||
current_subsection = None
|
||||
continue
|
||||
|
||||
# Check for subsection headers (### Subsection)
|
||||
if line.startswith("### "):
|
||||
current_subsection = line[4:].strip()
|
||||
continue
|
||||
|
||||
# Check for checkbox items
|
||||
match = self.CHECKBOX_PATTERN.match(line)
|
||||
if match:
|
||||
checkbox_state = match.group(2)
|
||||
text = match.group(3).strip()
|
||||
checked = checkbox_state.lower() == "x"
|
||||
|
||||
items.append(
|
||||
ProjectTodoItem(
|
||||
text=text,
|
||||
section=current_section,
|
||||
subsection=current_subsection,
|
||||
line=line_num,
|
||||
checked=checked,
|
||||
)
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return items
|
||||
|
||||
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
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Auto-update service for Clide.
|
||||
|
||||
Checks for updates from the release server and handles self-updating.
|
||||
User settings in ~/.clide/ are preserved across updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Update server configuration
|
||||
UPDATE_SERVER = "https://git.schweitz.net"
|
||||
REPO_OWNER = "jeroen" # TODO: Update with actual owner
|
||||
REPO_NAME = "clide" # TODO: Update with actual repo name
|
||||
|
||||
# Current version (injected at build time or read from package)
|
||||
try:
|
||||
from clide import __version__ as CURRENT_VERSION
|
||||
except ImportError:
|
||||
CURRENT_VERSION = "0.0.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
"""Information about a release."""
|
||||
|
||||
version: str
|
||||
tag_name: str
|
||||
download_url: str
|
||||
release_notes: str
|
||||
published_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCheckResult:
|
||||
"""Result of checking for updates."""
|
||||
|
||||
update_available: bool
|
||||
current_version: str
|
||||
latest_version: str | None
|
||||
release_info: ReleaseInfo | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def get_platform_asset_name() -> str:
|
||||
"""Get the expected asset name for the current platform."""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "darwin":
|
||||
return "macos.dmg"
|
||||
elif system == "linux":
|
||||
# Normalize architecture names
|
||||
if machine in ("x86_64", "amd64"):
|
||||
arch = "x86_64"
|
||||
elif machine in ("aarch64", "arm64"):
|
||||
arch = "aarch64"
|
||||
else:
|
||||
arch = machine
|
||||
return f"linux-{arch}.AppImage"
|
||||
elif system == "windows":
|
||||
return "windows-setup.exe"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
def parse_version(version: str) -> tuple[int, ...]:
|
||||
"""Parse a version string into a tuple for comparison."""
|
||||
# Remove 'v' prefix if present
|
||||
version = version.lstrip("v")
|
||||
# Split and convert to integers
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
# Handle versions like "1.0.0-beta"
|
||||
num_part = ""
|
||||
for char in part:
|
||||
if char.isdigit():
|
||||
num_part += char
|
||||
else:
|
||||
break
|
||||
parts.append(int(num_part) if num_part else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def is_newer_version(current: str, latest: str) -> bool:
|
||||
"""Check if latest version is newer than current."""
|
||||
return parse_version(latest) > parse_version(current)
|
||||
|
||||
|
||||
def check_for_updates() -> UpdateCheckResult:
|
||||
"""Check the release server for available updates.
|
||||
|
||||
Returns:
|
||||
UpdateCheckResult with update status and release info.
|
||||
"""
|
||||
try:
|
||||
# Gitea API endpoint for releases
|
||||
api_url = f"{UPDATE_SERVER}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
|
||||
|
||||
request = Request(api_url)
|
||||
request.add_header("Accept", "application/json")
|
||||
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
|
||||
|
||||
with urlopen(request, timeout=10) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
tag_name = data.get("tag_name", "")
|
||||
latest_version = tag_name.lstrip("v")
|
||||
|
||||
# Find the download URL for current platform
|
||||
platform_asset = get_platform_asset_name()
|
||||
download_url = None
|
||||
|
||||
for asset in data.get("assets", []):
|
||||
if platform_asset in asset.get("name", ""):
|
||||
download_url = asset.get("browser_download_url")
|
||||
break
|
||||
|
||||
if not download_url:
|
||||
# Try constructing URL from release
|
||||
download_url = f"{UPDATE_SERVER}/{REPO_OWNER}/{REPO_NAME}/releases/download/{tag_name}/Clide-{tag_name}-{platform_asset}"
|
||||
|
||||
release_info = ReleaseInfo(
|
||||
version=latest_version,
|
||||
tag_name=tag_name,
|
||||
download_url=download_url,
|
||||
release_notes=data.get("body", ""),
|
||||
published_at=data.get("published_at", ""),
|
||||
)
|
||||
|
||||
update_available = is_newer_version(CURRENT_VERSION, latest_version)
|
||||
|
||||
return UpdateCheckResult(
|
||||
update_available=update_available,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=latest_version,
|
||||
release_info=release_info,
|
||||
)
|
||||
|
||||
except URLError as e:
|
||||
logger.error(f"Failed to check for updates: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error=f"Network error: {e.reason}",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse update response: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error="Invalid response from update server",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error checking for updates: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
def download_update(release_info: ReleaseInfo, progress_callback=None) -> Path:
|
||||
"""Download the update to a temporary location.
|
||||
|
||||
Args:
|
||||
release_info: Release information with download URL.
|
||||
progress_callback: Optional callback(bytes_downloaded, total_bytes).
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If download fails.
|
||||
"""
|
||||
try:
|
||||
request = Request(release_info.download_url)
|
||||
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
|
||||
|
||||
# Create temp file with appropriate extension
|
||||
suffix = Path(release_info.download_url).suffix or ""
|
||||
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix="clide_update_")
|
||||
os.close(fd)
|
||||
|
||||
with urlopen(request, timeout=300) as response:
|
||||
total_size = int(response.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 8192
|
||||
|
||||
with open(temp_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, total_size)
|
||||
|
||||
return Path(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download update: {e}")
|
||||
raise RuntimeError(f"Download failed: {e}") from e
|
||||
|
||||
|
||||
def get_executable_path() -> Path:
|
||||
"""Get the path to the current executable."""
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as compiled executable
|
||||
return Path(sys.executable)
|
||||
else:
|
||||
# Running as Python script
|
||||
return Path(sys.argv[0]).resolve()
|
||||
|
||||
|
||||
def apply_update(downloaded_file: Path) -> bool:
|
||||
"""Apply the downloaded update.
|
||||
|
||||
This replaces the current executable with the new version.
|
||||
On macOS/Linux, this can happen while the app is running.
|
||||
On Windows, we need to use a helper script.
|
||||
|
||||
Args:
|
||||
downloaded_file: Path to the downloaded update file.
|
||||
|
||||
Returns:
|
||||
True if update was applied successfully.
|
||||
"""
|
||||
system = platform.system().lower()
|
||||
current_exe = get_executable_path()
|
||||
|
||||
try:
|
||||
if system == "darwin":
|
||||
# macOS: For DMG, just inform user to install manually
|
||||
# For direct binary updates, we can replace in-place
|
||||
if downloaded_file.suffix == ".dmg":
|
||||
logger.info(f"DMG downloaded to: {downloaded_file}")
|
||||
return True # User needs to install manually
|
||||
|
||||
# Direct binary replacement
|
||||
backup_path = current_exe.with_suffix(".backup")
|
||||
shutil.copy2(current_exe, backup_path)
|
||||
shutil.copy2(downloaded_file, current_exe)
|
||||
current_exe.chmod(
|
||||
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
||||
)
|
||||
backup_path.unlink()
|
||||
return True
|
||||
|
||||
elif system == "linux":
|
||||
# Linux: AppImage can be replaced directly
|
||||
if downloaded_file.suffix == ".AppImage":
|
||||
backup_path = current_exe.with_suffix(".backup")
|
||||
shutil.copy2(current_exe, backup_path)
|
||||
shutil.copy2(downloaded_file, current_exe)
|
||||
current_exe.chmod(
|
||||
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
||||
)
|
||||
backup_path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
elif system == "windows":
|
||||
# Windows: Can't replace running executable directly
|
||||
# Create a batch script to replace after exit
|
||||
batch_script = current_exe.parent / "update_clide.bat"
|
||||
new_exe = downloaded_file
|
||||
|
||||
script_content = f"""@echo off
|
||||
echo Updating Clide...
|
||||
timeout /t 2 /nobreak >nul
|
||||
copy /y "{new_exe}" "{current_exe}"
|
||||
del "{new_exe}"
|
||||
del "%~f0"
|
||||
echo Update complete!
|
||||
"""
|
||||
batch_script.write_text(script_content)
|
||||
logger.info(f"Update script created: {batch_script}")
|
||||
logger.info("Please restart Clide to complete the update.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply update: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Clean up downloaded file if it still exists and wasn't moved
|
||||
if downloaded_file.exists() and system != "windows":
|
||||
try:
|
||||
downloaded_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def perform_update(progress_callback=None) -> tuple[bool, str]:
|
||||
"""Check for and perform an update.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for download progress.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
# Check for updates
|
||||
result = check_for_updates()
|
||||
|
||||
if result.error:
|
||||
return False, f"Failed to check for updates: {result.error}"
|
||||
|
||||
if not result.update_available:
|
||||
return True, f"Already running the latest version ({result.current_version})"
|
||||
|
||||
if not result.release_info:
|
||||
return False, "No release information available"
|
||||
|
||||
# Download update
|
||||
try:
|
||||
downloaded_file = download_update(result.release_info, progress_callback)
|
||||
except RuntimeError as e:
|
||||
return False, str(e)
|
||||
|
||||
# Apply update
|
||||
if apply_update(downloaded_file):
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
return True, f"Update to {result.latest_version} downloaded. Restart Clide to complete."
|
||||
elif system == "darwin" and downloaded_file.suffix == ".dmg":
|
||||
return (
|
||||
True,
|
||||
f"Update {result.latest_version} downloaded to {downloaded_file}. Please install manually.",
|
||||
)
|
||||
else:
|
||||
return (
|
||||
True,
|
||||
f"Updated to {result.latest_version}. Restart Clide to use the new version.",
|
||||
)
|
||||
else:
|
||||
return False, "Failed to apply update"
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: branch
|
||||
description: Create, switch, or manage git branches
|
||||
---
|
||||
|
||||
# Git Branch
|
||||
|
||||
Create, switch, or manage branches.
|
||||
|
||||
## Steps
|
||||
|
||||
1. If no argument, list branches with `git branch -a`
|
||||
2. If branch name provided:
|
||||
- Check if it exists
|
||||
- If exists: `git checkout <branch>`
|
||||
- If not: `git checkout -b <branch>`
|
||||
3. Show current branch status after switch
|
||||
|
||||
## Common Operations
|
||||
|
||||
- List all branches: `git branch -a`
|
||||
- Create and switch: `git checkout -b <name>`
|
||||
- Switch to existing: `git checkout <name>`
|
||||
- Delete local branch: `git branch -d <name>`
|
||||
- Delete remote branch: `git push origin --delete <name>`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive branch names (feature/*, fix/*, etc.)
|
||||
- Keep branches short-lived
|
||||
- Delete merged branches to keep repo clean
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: commit
|
||||
description: Create a well-formatted git commit with staged changes
|
||||
---
|
||||
|
||||
# Git Commit
|
||||
|
||||
Create a well-formatted commit with staged changes following best practices.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status --porcelain` to check for changes
|
||||
2. If no staged changes, show unstaged files and ask what to stage
|
||||
3. Run `git diff --cached` to review staged changes
|
||||
4. Generate a commit message following Conventional Commits format:
|
||||
- `feat:` new feature
|
||||
- `fix:` bug fix
|
||||
- `docs:` documentation
|
||||
- `refactor:` code restructuring
|
||||
- `test:` adding tests
|
||||
- `chore:` maintenance
|
||||
5. Create commit with the message, adding Co-Authored-By trailer
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
|
||||
<body - what and why, not how>
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Warn about large commits (>500 lines changed)
|
||||
- Suggest splitting large changes into smaller commits
|
||||
- Never skip pre-commit hooks unless explicitly requested
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: pull
|
||||
description: Pull changes from remote with rebase
|
||||
---
|
||||
|
||||
# Git Pull
|
||||
|
||||
Pull changes from remote with rebase to keep history clean.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Check for uncommitted changes - stash if needed
|
||||
2. Run `git pull --rebase origin <current-branch>`
|
||||
3. If conflicts occur:
|
||||
- Show conflicting files
|
||||
- Help resolve conflicts one by one
|
||||
- Continue rebase after resolution
|
||||
4. Pop stash if we stashed earlier
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When conflicts are found:
|
||||
1. Show the conflicting files with `git status`
|
||||
2. For each file, show the conflict markers
|
||||
3. Help user decide how to resolve
|
||||
4. Stage resolved files with `git add`
|
||||
5. Continue with `git rebase --continue`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use rebase for pulls to keep history clean
|
||||
- Stash local changes before pulling
|
||||
- Never force push after rebase on shared branches
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: push
|
||||
description: Push current branch to remote
|
||||
---
|
||||
|
||||
# Git Push
|
||||
|
||||
Push current branch to remote repository.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Check if branch has upstream: `git rev-parse --abbrev-ref @{u}`
|
||||
2. If no upstream, set it: `git push -u origin <branch>`
|
||||
3. Otherwise: `git push`
|
||||
4. If push is rejected (non-fast-forward):
|
||||
- Suggest pull --rebase first
|
||||
- Never force push to main/master without explicit request
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Never force push to protected branches (main, master, develop)
|
||||
- Always set upstream on first push with `-u` flag
|
||||
- If rejected, pull with rebase first rather than force pushing
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: stash
|
||||
description: Stash current working directory changes
|
||||
---
|
||||
|
||||
# Git Stash
|
||||
|
||||
Stash current working directory changes for later use.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status` to show what will be stashed
|
||||
2. Ask for optional stash message
|
||||
3. Run `git stash push -m "<message>"` or `git stash push` if no message
|
||||
4. Confirm stash was created with `git stash list`
|
||||
|
||||
## Options
|
||||
|
||||
- Include untracked files: `git stash push -u`
|
||||
- Stash specific files: `git stash push -- <files>`
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `git stash list` - List all stashes
|
||||
- `git stash pop` - Apply and remove most recent stash
|
||||
- `git stash apply` - Apply but keep stash
|
||||
- `git stash drop` - Remove a stash
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
"""Vendored third-party libraries for Clide."""
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
pyte - LGPL License
|
||||
====================
|
||||
|
||||
This is a vendored copy of pyte (https://github.com/selectel/pyte)
|
||||
with modifications for Clide diagnostic logging.
|
||||
|
||||
Original copyright:
|
||||
(c) 2011-2012 by Selectel.
|
||||
(c) 2012-2017 by pyte authors and contributors.
|
||||
|
||||
This code is licensed under the GNU Lesser General Public License (LGPL).
|
||||
Modifications made by the Clide project are also licensed under LGPL.
|
||||
|
||||
For the full LGPL license text, see:
|
||||
https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
Modifications:
|
||||
- Added diagnostic logging hooks for debugging terminal rendering issues
|
||||
- Added event callback support for Claude Code integration
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
pyte
|
||||
~~~~
|
||||
|
||||
`pyte` implements a mix of VT100, VT220 and VT520 specification,
|
||||
and aims to support most of the `TERM=linux` functionality.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"Screen",
|
||||
"DiffScreen",
|
||||
"HistoryScreen",
|
||||
"DebugScreen",
|
||||
"Stream",
|
||||
"ByteStream",
|
||||
# Clide additions
|
||||
"set_debug_logger",
|
||||
"get_debug_logger",
|
||||
"set_event_callback",
|
||||
)
|
||||
|
||||
import io
|
||||
|
||||
# Re-export submodules for compatibility
|
||||
from . import screens
|
||||
from .screens import DebugScreen, DiffScreen, HistoryScreen, Screen
|
||||
from .screens import set_debug_logger as _set_screen_logger
|
||||
from .streams import ByteStream, Stream, set_event_callback
|
||||
from .streams import set_debug_logger as _set_stream_logger
|
||||
|
||||
|
||||
def set_debug_logger(logger):
|
||||
"""Set debug logger for both streams and screens.
|
||||
|
||||
Args:
|
||||
logger: A callable that accepts a string message, or None to disable.
|
||||
"""
|
||||
_set_stream_logger(logger)
|
||||
_set_screen_logger(logger)
|
||||
|
||||
|
||||
def get_debug_logger():
|
||||
"""Get the current debug logger (if set).
|
||||
|
||||
Returns:
|
||||
The current debug logger callable, or None if not set.
|
||||
"""
|
||||
return screens._debug_logger
|
||||
|
||||
|
||||
if __debug__:
|
||||
|
||||
def dis(chars: bytes | str) -> None:
|
||||
"""A :func:`dis.dis` for terminals."""
|
||||
if isinstance(chars, str):
|
||||
chars = chars.encode("utf-8")
|
||||
|
||||
with io.StringIO() as buf:
|
||||
ByteStream(DebugScreen(to=buf)).feed(chars)
|
||||
print(buf.getvalue())
|
||||
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
pyte.charsets
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
This module defines ``G0`` and ``G1`` charset mappings the same way
|
||||
they are defined for linux terminal, see
|
||||
``linux/drivers/tty/consolemap.c`` @ http://git.kernel.org
|
||||
|
||||
.. note:: ``VT100_MAP`` and ``IBMPC_MAP`` were taken unchanged
|
||||
from linux kernel source and therefore are licensed
|
||||
under **GPL**.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: Latin1.
|
||||
LAT1_MAP = "".join(map(chr, range(256)))
|
||||
|
||||
#: VT100 graphic character set.
|
||||
VT100_MAP = "".join(chr(c) for c in [
|
||||
0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
|
||||
0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x000e, 0x000f,
|
||||
0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
|
||||
0x0018, 0x0019, 0x001a, 0x001b, 0x001c, 0x001d, 0x001e, 0x001f,
|
||||
0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
|
||||
0x0028, 0x0029, 0x002a, 0x2192, 0x2190, 0x2191, 0x2193, 0x002f,
|
||||
0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
|
||||
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
|
||||
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
|
||||
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
|
||||
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
|
||||
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x00a0,
|
||||
0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1,
|
||||
0x2591, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba,
|
||||
0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c,
|
||||
0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x007f,
|
||||
0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
|
||||
0x0088, 0x0089, 0x008a, 0x008b, 0x008c, 0x008d, 0x008e, 0x008f,
|
||||
0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
|
||||
0x0098, 0x0099, 0x009a, 0x009b, 0x009c, 0x009d, 0x009e, 0x009f,
|
||||
0x00a0, 0x00a1, 0x00a2, 0x00a3, 0x00a4, 0x00a5, 0x00a6, 0x00a7,
|
||||
0x00a8, 0x00a9, 0x00aa, 0x00ab, 0x00ac, 0x00ad, 0x00ae, 0x00af,
|
||||
0x00b0, 0x00b1, 0x00b2, 0x00b3, 0x00b4, 0x00b5, 0x00b6, 0x00b7,
|
||||
0x00b8, 0x00b9, 0x00ba, 0x00bb, 0x00bc, 0x00bd, 0x00be, 0x00bf,
|
||||
0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c6, 0x00c7,
|
||||
0x00c8, 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf,
|
||||
0x00d0, 0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d7,
|
||||
0x00d8, 0x00d9, 0x00da, 0x00db, 0x00dc, 0x00dd, 0x00de, 0x00df,
|
||||
0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, 0x00e5, 0x00e6, 0x00e7,
|
||||
0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00ed, 0x00ee, 0x00ef,
|
||||
0x00f0, 0x00f1, 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00f7,
|
||||
0x00f8, 0x00f9, 0x00fa, 0x00fb, 0x00fc, 0x00fd, 0x00fe, 0x00ff
|
||||
])
|
||||
|
||||
#: IBM Codepage 437.
|
||||
IBMPC_MAP = "".join(chr(c) for c in [
|
||||
0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
|
||||
0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
|
||||
0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
|
||||
0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
|
||||
0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
|
||||
0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
|
||||
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
|
||||
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
|
||||
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
|
||||
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
|
||||
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
|
||||
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
|
||||
0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
|
||||
0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x006f,
|
||||
0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
|
||||
0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
|
||||
0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
|
||||
0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
|
||||
0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
|
||||
0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
|
||||
0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
|
||||
0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
|
||||
0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
|
||||
0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
|
||||
0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
|
||||
0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
|
||||
0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
|
||||
0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
|
||||
0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
|
||||
0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
|
||||
0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
|
||||
0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
|
||||
])
|
||||
|
||||
|
||||
#: VAX42 character set.
|
||||
VAX42_MAP = "".join(chr(c) for c in [
|
||||
0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
|
||||
0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
|
||||
0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
|
||||
0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
|
||||
0x0020, 0x043b, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
|
||||
0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
|
||||
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
|
||||
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x0435,
|
||||
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
|
||||
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
|
||||
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
|
||||
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
|
||||
0x0060, 0x0441, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
|
||||
0x0435, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x043a,
|
||||
0x0070, 0x0071, 0x0442, 0x0073, 0x043b, 0x0435, 0x0076, 0x0077,
|
||||
0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
|
||||
0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
|
||||
0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
|
||||
0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
|
||||
0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
|
||||
0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
|
||||
0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
|
||||
0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
|
||||
0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
|
||||
0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
|
||||
0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
|
||||
0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
|
||||
0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
|
||||
0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
|
||||
0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
|
||||
0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
|
||||
0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
|
||||
])
|
||||
|
||||
|
||||
MAPS = {
|
||||
"B": LAT1_MAP,
|
||||
"0": VT100_MAP,
|
||||
"U": IBMPC_MAP,
|
||||
"V": VAX42_MAP
|
||||
}
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
pyte.control
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module defines simple control sequences, recognized by
|
||||
:class:`~pyte.streams.Stream`, the set of codes here is for
|
||||
``TERM=linux`` which is a superset of VT102.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: *Space*: Not surprisingly -- ``" "``.
|
||||
SP = " "
|
||||
|
||||
#: *Null*: Does nothing.
|
||||
NUL = "\x00"
|
||||
|
||||
#: *Bell*: Beeps.
|
||||
BEL = "\x07"
|
||||
|
||||
#: *Backspace*: Backspace one column, but not past the beginning of the
|
||||
#: line.
|
||||
BS = "\x08"
|
||||
|
||||
#: *Horizontal tab*: Move cursor to the next tab stop, or to the end
|
||||
#: of the line if there is no earlier tab stop.
|
||||
HT = "\x09"
|
||||
|
||||
#: *Linefeed*: Give a line feed, and, if :data:`pyte.modes.LNM` (new
|
||||
#: line mode) is set also a carriage return.
|
||||
LF = "\n"
|
||||
#: *Vertical tab*: Same as :data:`LF`.
|
||||
VT = "\x0b"
|
||||
#: *Form feed*: Same as :data:`LF`.
|
||||
FF = "\x0c"
|
||||
|
||||
#: *Carriage return*: Move cursor to left margin on current line.
|
||||
CR = "\r"
|
||||
|
||||
#: *Shift out*: Activate G1 character set.
|
||||
SO = "\x0e"
|
||||
|
||||
#: *Shift in*: Activate G0 character set.
|
||||
SI = "\x0f"
|
||||
|
||||
#: *Cancel*: Interrupt escape sequence. If received during an escape or
|
||||
#: control sequence, cancels the sequence and displays substitution
|
||||
#: character.
|
||||
CAN = "\x18"
|
||||
#: *Substitute*: Same as :data:`CAN`.
|
||||
SUB = "\x1a"
|
||||
|
||||
#: *Escape*: Starts an escape sequence.
|
||||
ESC = "\x1b"
|
||||
|
||||
#: *Delete*: Is ignored.
|
||||
DEL = "\x7f"
|
||||
|
||||
#: *Control sequence introducer*.
|
||||
CSI_C0 = ESC + "["
|
||||
CSI_C1 = "\x9b"
|
||||
CSI = CSI_C0
|
||||
|
||||
#: *String terminator*.
|
||||
ST_C0 = ESC + "\\"
|
||||
ST_C1 = "\x9c"
|
||||
ST = ST_C0
|
||||
|
||||
#: *Operating system command*.
|
||||
OSC_C0 = ESC + "]"
|
||||
OSC_C1 = "\x9d"
|
||||
OSC = OSC_C0
|
||||
Vendored
+154
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
pyte.escape
|
||||
~~~~~~~~~~~
|
||||
|
||||
This module defines both CSI and non-CSI escape sequences, recognized
|
||||
by :class:`~pyte.streams.Stream` and subclasses.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: *Reset*.
|
||||
RIS = "c"
|
||||
|
||||
#: *Index*: Move cursor down one line in same column. If the cursor is
|
||||
#: at the bottom margin, the screen performs a scroll-up.
|
||||
IND = "D"
|
||||
|
||||
#: *Next line*: Same as :data:`pyte.control.LF`.
|
||||
NEL = "E"
|
||||
|
||||
#: Tabulation set: Set a horizontal tab stop at cursor position.
|
||||
HTS = "H"
|
||||
|
||||
#: *Reverse index*: Move cursor up one line in same column. If the
|
||||
#: cursor is at the top margin, the screen performs a scroll-down.
|
||||
RI = "M"
|
||||
|
||||
#: Save cursor: Save cursor position, character attribute (graphic
|
||||
#: rendition), character set, and origin mode selection (see
|
||||
#: :data:`DECRC`).
|
||||
DECSC = "7"
|
||||
|
||||
#: *Restore cursor*: Restore previously saved cursor position, character
|
||||
#: attribute (graphic rendition), character set, and origin mode
|
||||
#: selection. If none were saved, move cursor to home position.
|
||||
DECRC = "8"
|
||||
|
||||
# "Sharp" escape sequences.
|
||||
# -------------------------
|
||||
|
||||
#: *Alignment display*: Fill screen with uppercase E's for testing
|
||||
#: screen focus and alignment.
|
||||
DECALN = "8"
|
||||
|
||||
|
||||
# ECMA-48 CSI sequences.
|
||||
# ---------------------
|
||||
|
||||
#: *Insert character*: Insert the indicated # of blank characters.
|
||||
ICH = "@"
|
||||
|
||||
#: *Cursor up*: Move cursor up the indicated # of lines in same column.
|
||||
#: Cursor stops at top margin.
|
||||
CUU = "A"
|
||||
|
||||
#: *Cursor down*: Move cursor down the indicated # of lines in same
|
||||
#: column. Cursor stops at bottom margin.
|
||||
CUD = "B"
|
||||
|
||||
#: *Cursor forward*: Move cursor right the indicated # of columns.
|
||||
#: Cursor stops at right margin.
|
||||
CUF = "C"
|
||||
|
||||
#: *Cursor back*: Move cursor left the indicated # of columns. Cursor
|
||||
#: stops at left margin.
|
||||
CUB = "D"
|
||||
|
||||
#: *Cursor next line*: Move cursor down the indicated # of lines to
|
||||
#: column 1.
|
||||
CNL = "E"
|
||||
|
||||
#: *Cursor previous line*: Move cursor up the indicated # of lines to
|
||||
#: column 1.
|
||||
CPL = "F"
|
||||
|
||||
#: *Cursor horizontal align*: Move cursor to the indicated column in
|
||||
#: current line.
|
||||
CHA = "G"
|
||||
|
||||
#: *Cursor position*: Move cursor to the indicated line, column (origin
|
||||
#: at ``1, 1``).
|
||||
CUP = "H"
|
||||
|
||||
#: *Erase data* (default: from cursor to end of line).
|
||||
ED = "J"
|
||||
|
||||
#: *Erase in line* (default: from cursor to end of line).
|
||||
EL = "K"
|
||||
|
||||
#: *Insert line*: Insert the indicated # of blank lines, starting from
|
||||
#: the current line. Lines displayed below cursor move down. Lines moved
|
||||
#: past the bottom margin are lost.
|
||||
IL = "L"
|
||||
|
||||
#: *Delete line*: Delete the indicated # of lines, starting from the
|
||||
#: current line. As lines are deleted, lines displayed below cursor
|
||||
#: move up. Lines added to bottom of screen have spaces with same
|
||||
#: character attributes as last line move up.
|
||||
DL = "M"
|
||||
|
||||
#: *Delete character*: Delete the indicated # of characters on the
|
||||
#: current line. When character is deleted, all characters to the right
|
||||
#: of cursor move left.
|
||||
DCH = "P"
|
||||
|
||||
#: *Erase character*: Erase the indicated # of characters on the
|
||||
#: current line.
|
||||
ECH = "X"
|
||||
|
||||
#: *Horizontal position relative*: Same as :data:`CUF`.
|
||||
HPR = "a"
|
||||
|
||||
#: *Device Attributes*.
|
||||
DA = "c"
|
||||
|
||||
#: *Vertical position adjust*: Move cursor to the indicated line,
|
||||
#: current column.
|
||||
VPA = "d"
|
||||
|
||||
#: *Vertical position relative*: Same as :data:`CUD`.
|
||||
VPR = "e"
|
||||
|
||||
#: *Horizontal / Vertical position*: Same as :data:`CUP`.
|
||||
HVP = "f"
|
||||
|
||||
#: *Tabulation clear*: Clears a horizontal tab stop at cursor position.
|
||||
TBC = "g"
|
||||
|
||||
#: *Set mode*.
|
||||
SM = "h"
|
||||
|
||||
#: *Reset mode*.
|
||||
RM = "l"
|
||||
|
||||
#: *Select graphics rendition*: The terminal can display the following
|
||||
#: character attributes that change the character display without
|
||||
#: changing the character (see :mod:`pyte.graphics`).
|
||||
SGR = "m"
|
||||
|
||||
#: *Device status report*.
|
||||
DSR = "n"
|
||||
|
||||
#: *Select top and bottom margins*: Selects margins, defining the
|
||||
#: scrolling region; parameters are top and bottom line. If called
|
||||
#: without any arguments, whole screen is used.
|
||||
DECSTBM = "r"
|
||||
|
||||
#: *Horizontal position adjust*: Same as :data:`CHA`.
|
||||
HPA = "'"
|
||||
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
pyte.graphics
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
This module defines graphic-related constants, mostly taken from
|
||||
:manpage:`console_codes(4)` and
|
||||
http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: A mapping of ANSI text style codes to style names, "+" means the:
|
||||
#: attribute is set, "-" -- reset; example:
|
||||
#:
|
||||
#: >>> text[1]
|
||||
#: '+bold'
|
||||
#: >>> text[9]
|
||||
#: '+strikethrough'
|
||||
TEXT = {
|
||||
1: "+bold",
|
||||
3: "+italics",
|
||||
4: "+underscore",
|
||||
5: "+blink",
|
||||
7: "+reverse",
|
||||
9: "+strikethrough",
|
||||
22: "-bold",
|
||||
23: "-italics",
|
||||
24: "-underscore",
|
||||
25: "-blink",
|
||||
27: "-reverse",
|
||||
29: "-strikethrough",
|
||||
}
|
||||
|
||||
#: A mapping of ANSI foreground color codes to color names.
|
||||
#:
|
||||
#: >>> FG_ANSI[30]
|
||||
#: 'black'
|
||||
#: >>> FG_ANSI[38]
|
||||
#: 'default'
|
||||
FG_ANSI = {
|
||||
30: "black",
|
||||
31: "red",
|
||||
32: "green",
|
||||
33: "brown",
|
||||
34: "blue",
|
||||
35: "magenta",
|
||||
36: "cyan",
|
||||
37: "white",
|
||||
39: "default", # white.
|
||||
}
|
||||
|
||||
#: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility.
|
||||
FG = FG_ANSI
|
||||
|
||||
#: A mapping of non-standard ``aixterm`` foreground color codes to
|
||||
#: color names. These are high intensity colors.
|
||||
FG_AIXTERM = {
|
||||
90: "brightblack",
|
||||
91: "brightred",
|
||||
92: "brightgreen",
|
||||
93: "brightbrown",
|
||||
94: "brightblue",
|
||||
95: "brightmagenta",
|
||||
96: "brightcyan",
|
||||
97: "brightwhite",
|
||||
}
|
||||
|
||||
#: A mapping of ANSI background color codes to color names.
|
||||
#:
|
||||
#: >>> BG_ANSI[40]
|
||||
#: 'black'
|
||||
#: >>> BG_ANSI[48]
|
||||
#: 'default'
|
||||
BG_ANSI = {
|
||||
40: "black",
|
||||
41: "red",
|
||||
42: "green",
|
||||
43: "brown",
|
||||
44: "blue",
|
||||
45: "magenta",
|
||||
46: "cyan",
|
||||
47: "white",
|
||||
49: "default", # black.
|
||||
}
|
||||
|
||||
#: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility.
|
||||
BG = BG_ANSI
|
||||
|
||||
#: A mapping of non-standard ``aixterm`` background color codes to
|
||||
#: color names. These are high intensity colors.
|
||||
BG_AIXTERM = {
|
||||
100: "brightblack",
|
||||
101: "brightred",
|
||||
102: "brightgreen",
|
||||
103: "brightbrown",
|
||||
104: "brightblue",
|
||||
105: "brightmagenta",
|
||||
106: "brightcyan",
|
||||
107: "brightwhite",
|
||||
}
|
||||
|
||||
#: SGR code for foreground in 256 or True color mode.
|
||||
FG_256 = 38
|
||||
|
||||
#: SGR code for background in 256 or True color mode.
|
||||
BG_256 = 48
|
||||
|
||||
#: A table of 256 foreground or background colors.
|
||||
#: First 16 entries softened from xterm defaults to match modern terminal themes.
|
||||
_FG_BG_256 = [
|
||||
(0x28, 0x2C, 0x34), # 0 black
|
||||
(0xE0, 0x6C, 0x75), # 1 red
|
||||
(0x98, 0xC3, 0x79), # 2 green
|
||||
(0xE5, 0xC0, 0x7B), # 3 yellow
|
||||
(0x61, 0xAF, 0xEF), # 4 blue
|
||||
(0xC6, 0x78, 0xDD), # 5 magenta
|
||||
(0x56, 0xB6, 0xC2), # 6 cyan
|
||||
(0xAB, 0xB2, 0xBF), # 7 white
|
||||
(0x5C, 0x63, 0x70), # 8 bright black
|
||||
(0xE0, 0x6C, 0x75), # 9 bright red
|
||||
(0x98, 0xC3, 0x79), # 10 bright green
|
||||
(0xE5, 0xC0, 0x7B), # 11 bright yellow
|
||||
(0x61, 0xAF, 0xEF), # 12 bright blue
|
||||
(0xC6, 0x78, 0xDD), # 13 bright magenta
|
||||
(0x56, 0xB6, 0xC2), # 14 bright cyan
|
||||
(0xFF, 0xFF, 0xFF), # 15 bright white
|
||||
]
|
||||
|
||||
# colors 16..231: the 6x6x6 color cube
|
||||
valuerange = (0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF)
|
||||
|
||||
for i in range(216):
|
||||
r = valuerange[(i // 36) % 6]
|
||||
g = valuerange[(i // 6) % 6]
|
||||
b = valuerange[i % 6]
|
||||
_FG_BG_256.append((r, g, b))
|
||||
|
||||
# colors 232..255: grayscale
|
||||
for i in range(24):
|
||||
v = 8 + i * 10
|
||||
_FG_BG_256.append((v, v, v))
|
||||
|
||||
FG_BG_256 = [f"{r:02x}{g:02x}{b:02x}" for r, g, b in _FG_BG_256]
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
pyte.modes
|
||||
~~~~~~~~~~
|
||||
|
||||
This module defines terminal mode switches, used by
|
||||
:class:`~pyte.screens.Screen`. There're two types of terminal modes:
|
||||
|
||||
* `non-private` which should be set with ``ESC [ N h``, where ``N``
|
||||
is an integer, representing mode being set; and
|
||||
* `private` which should be set with ``ESC [ ? N h``.
|
||||
|
||||
The latter are shifted 5 times to the right, to be easily
|
||||
distinguishable from the former ones; for example `Origin Mode`
|
||||
-- :data:`DECOM` is ``192`` not ``6``.
|
||||
|
||||
>>> DECOM
|
||||
192
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: *Line Feed/New Line Mode*: When enabled, causes a received
|
||||
#: :data:`~pyte.control.LF`, :data:`pyte.control.FF`, or
|
||||
#: :data:`~pyte.control.VT` to move the cursor to the first column of
|
||||
#: the next line.
|
||||
LNM = 20
|
||||
|
||||
#: *Insert/Replace Mode*: When enabled, new display characters move
|
||||
#: old display characters to the right. Characters moved past the
|
||||
#: right margin are lost. Otherwise, new display characters replace
|
||||
#: old display characters at the cursor position.
|
||||
IRM = 4
|
||||
|
||||
|
||||
# Private modes.
|
||||
# ..............
|
||||
|
||||
#: *Text Cursor Enable Mode*: determines if the text cursor is
|
||||
#: visible.
|
||||
DECTCEM = 25 << 5
|
||||
|
||||
#: *Screen Mode*: toggles screen-wide reverse-video mode.
|
||||
DECSCNM = 5 << 5
|
||||
|
||||
#: *Origin Mode*: allows cursor addressing relative to a user-defined
|
||||
#: origin. This mode resets when the terminal is powered up or reset.
|
||||
#: It does not affect the erase in display (ED) function.
|
||||
DECOM = 6 << 5
|
||||
|
||||
#: *Auto Wrap Mode*: selects where received graphic characters appear
|
||||
#: when the cursor is at the right margin.
|
||||
DECAWM = 7 << 5
|
||||
|
||||
#: *Column Mode*: selects the number of columns per line (80 or 132)
|
||||
#: on the screen.
|
||||
DECCOLM = 3 << 5
|
||||
Vendored
+878
@@ -0,0 +1,878 @@
|
||||
"""
|
||||
pyte.screens
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module provides classes for terminal screens.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
import warnings
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable, Generator, Sequence
|
||||
from functools import lru_cache
|
||||
from typing import (
|
||||
Any,
|
||||
NamedTuple,
|
||||
TextIO,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from wcwidth import wcwidth as _wcwidth # type: ignore[import]
|
||||
|
||||
from . import charsets as cs
|
||||
from . import control as ctrl
|
||||
from . import graphics as g
|
||||
from . import modes as mo
|
||||
from .streams import Stream
|
||||
|
||||
wcwidth: Callable[[str], int] = lru_cache(maxsize=4096)(_wcwidth)
|
||||
|
||||
KT = TypeVar("KT")
|
||||
VT = TypeVar("VT")
|
||||
|
||||
# Clide diagnostic logging support
|
||||
_debug_logger: Callable[[str], None] | None = None
|
||||
|
||||
|
||||
def set_debug_logger(logger: Callable[[str], None] | None) -> None:
|
||||
"""Set a debug logger function for diagnostic output."""
|
||||
global _debug_logger
|
||||
_debug_logger = logger
|
||||
|
||||
|
||||
def _log_debug(message: str) -> None:
|
||||
"""Log a debug message if debug logging is enabled."""
|
||||
if _debug_logger is not None:
|
||||
_debug_logger(message)
|
||||
|
||||
|
||||
class Margins(NamedTuple):
|
||||
"""A container for screen's scroll margins."""
|
||||
|
||||
top: int
|
||||
bottom: int
|
||||
|
||||
|
||||
class Savepoint(NamedTuple):
|
||||
"""A container for savepoint, created on :data:`~pyte.escape.DECSC`."""
|
||||
|
||||
cursor: Cursor
|
||||
g0_charset: str
|
||||
g1_charset: str
|
||||
charset: int
|
||||
origin: bool
|
||||
wrap: bool
|
||||
|
||||
|
||||
class Char(NamedTuple):
|
||||
"""A single styled on-screen character."""
|
||||
|
||||
data: str
|
||||
fg: str = "default"
|
||||
bg: str = "default"
|
||||
bold: bool = False
|
||||
italics: bool = False
|
||||
underscore: bool = False
|
||||
strikethrough: bool = False
|
||||
reverse: bool = False
|
||||
blink: bool = False
|
||||
|
||||
|
||||
class Cursor:
|
||||
"""Screen cursor."""
|
||||
|
||||
__slots__ = ("x", "y", "attrs", "hidden")
|
||||
|
||||
def __init__(self, x: int, y: int, attrs: Char = Char(" ")) -> None:
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.attrs = attrs
|
||||
self.hidden = False
|
||||
|
||||
|
||||
class StaticDefaultDict(dict[KT, VT]):
|
||||
"""A dict with a static default value."""
|
||||
|
||||
def __init__(self, default: VT) -> None:
|
||||
self.default = default
|
||||
|
||||
def __missing__(self, key: KT) -> VT:
|
||||
return self.default
|
||||
|
||||
|
||||
_DEFAULT_MODE = set([mo.DECAWM, mo.DECTCEM])
|
||||
|
||||
|
||||
class Screen:
|
||||
"""A screen is an in-memory matrix of characters."""
|
||||
|
||||
@property
|
||||
def default_char(self) -> Char:
|
||||
"""An empty character with default foreground and background colors."""
|
||||
reverse = mo.DECSCNM in self.mode
|
||||
return Char(data=" ", fg="default", bg="default", reverse=reverse)
|
||||
|
||||
def __init__(self, columns: int, lines: int) -> None:
|
||||
self.savepoints: list[Savepoint] = []
|
||||
self.columns = columns
|
||||
self.lines = lines
|
||||
self.buffer: dict[int, StaticDefaultDict[int, Char]] = defaultdict(
|
||||
lambda: StaticDefaultDict[int, Char](self.default_char)
|
||||
)
|
||||
self.dirty: set[int] = set()
|
||||
self.reset()
|
||||
self.mode = _DEFAULT_MODE.copy()
|
||||
self.margins: Margins | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.columns}, {self.lines})"
|
||||
|
||||
@property
|
||||
def display(self) -> list[str]:
|
||||
"""A list of screen lines as unicode strings."""
|
||||
|
||||
def render(line: StaticDefaultDict[int, Char]) -> Generator[str, None, None]:
|
||||
is_wide_char = False
|
||||
for x in range(self.columns):
|
||||
if is_wide_char:
|
||||
is_wide_char = False
|
||||
continue
|
||||
char = line[x].data
|
||||
assert sum(map(wcwidth, char[1:])) == 0
|
||||
is_wide_char = wcwidth(char[0]) == 2
|
||||
yield char
|
||||
|
||||
return ["".join(render(self.buffer[y])) for y in range(self.lines)]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the terminal to its initial state."""
|
||||
_log_debug("[SCREEN] reset()")
|
||||
self.dirty.update(range(self.lines))
|
||||
self.buffer.clear()
|
||||
self.margins = None
|
||||
|
||||
self.mode = _DEFAULT_MODE.copy()
|
||||
|
||||
self.title = ""
|
||||
self.icon_name = ""
|
||||
|
||||
self.charset = 0
|
||||
self.g0_charset = cs.LAT1_MAP
|
||||
self.g1_charset = cs.VT100_MAP
|
||||
|
||||
self.tabstops = set(range(8, self.columns, 8))
|
||||
|
||||
self.cursor = Cursor(0, 0)
|
||||
self.cursor_position()
|
||||
|
||||
self.saved_columns: int | None = None
|
||||
|
||||
def resize(self, lines: int | None = None, columns: int | None = None) -> None:
|
||||
"""Resize the screen to the given size."""
|
||||
lines = lines or self.lines
|
||||
columns = columns or self.columns
|
||||
|
||||
if lines == self.lines and columns == self.columns:
|
||||
return
|
||||
|
||||
_log_debug(f"[SCREEN] resize({lines}, {columns})")
|
||||
|
||||
self.dirty.update(range(lines))
|
||||
|
||||
if lines < self.lines:
|
||||
self.save_cursor()
|
||||
self.cursor_position(0, 0)
|
||||
self.delete_lines(self.lines - lines)
|
||||
self.restore_cursor()
|
||||
|
||||
if columns < self.columns:
|
||||
for line in self.buffer.values():
|
||||
for x in range(columns, self.columns):
|
||||
line.pop(x, None)
|
||||
|
||||
self.lines, self.columns = lines, columns
|
||||
self.set_margins()
|
||||
|
||||
def set_margins(self, top: int | None = None, bottom: int | None = None) -> None:
|
||||
"""Select top and bottom margins for the scrolling region."""
|
||||
if (top is None or top == 0) and bottom is None:
|
||||
self.margins = None
|
||||
return
|
||||
|
||||
margins = self.margins or Margins(0, self.lines - 1)
|
||||
|
||||
if top is None:
|
||||
top = margins.top
|
||||
else:
|
||||
top = max(0, min(top - 1, self.lines - 1))
|
||||
if bottom is None:
|
||||
bottom = margins.bottom
|
||||
else:
|
||||
bottom = max(0, min(bottom - 1, self.lines - 1))
|
||||
|
||||
if bottom - top >= 1:
|
||||
self.margins = Margins(top, bottom)
|
||||
self.cursor_position()
|
||||
|
||||
def set_mode(self, *modes: int, **kwargs: Any) -> None:
|
||||
"""Set (enable) a given list of modes."""
|
||||
mode_list = list(modes)
|
||||
if kwargs.get("private"):
|
||||
mode_list = [mode << 5 for mode in modes]
|
||||
if mo.DECSCNM in mode_list:
|
||||
self.dirty.update(range(self.lines))
|
||||
|
||||
self.mode.update(mode_list)
|
||||
|
||||
if mo.DECCOLM in mode_list:
|
||||
self.saved_columns = self.columns
|
||||
self.resize(columns=132)
|
||||
self.erase_in_display(2)
|
||||
self.cursor_position()
|
||||
|
||||
if mo.DECOM in mode_list:
|
||||
self.cursor_position()
|
||||
|
||||
if mo.DECSCNM in mode_list:
|
||||
for line in self.buffer.values():
|
||||
line.default = self.default_char
|
||||
for x in line:
|
||||
line[x] = line[x]._replace(reverse=True)
|
||||
self.select_graphic_rendition(7)
|
||||
|
||||
if mo.DECTCEM in mode_list:
|
||||
self.cursor.hidden = False
|
||||
|
||||
def reset_mode(self, *modes: int, **kwargs: Any) -> None:
|
||||
"""Reset (disable) a given list of modes."""
|
||||
mode_list = list(modes)
|
||||
if kwargs.get("private"):
|
||||
mode_list = [mode << 5 for mode in modes]
|
||||
if mo.DECSCNM in mode_list:
|
||||
self.dirty.update(range(self.lines))
|
||||
|
||||
self.mode.difference_update(mode_list)
|
||||
|
||||
if mo.DECCOLM in mode_list:
|
||||
if self.columns == 132 and self.saved_columns is not None:
|
||||
self.resize(columns=self.saved_columns)
|
||||
self.saved_columns = None
|
||||
self.erase_in_display(2)
|
||||
self.cursor_position()
|
||||
|
||||
if mo.DECOM in mode_list:
|
||||
self.cursor_position()
|
||||
|
||||
if mo.DECSCNM in mode_list:
|
||||
for line in self.buffer.values():
|
||||
line.default = self.default_char
|
||||
for x in line:
|
||||
line[x] = line[x]._replace(reverse=False)
|
||||
self.select_graphic_rendition(27)
|
||||
|
||||
if mo.DECTCEM in mode_list:
|
||||
self.cursor.hidden = True
|
||||
|
||||
def define_charset(self, code: str, mode: str) -> None:
|
||||
"""Define G0 or G1 charset."""
|
||||
if code in cs.MAPS:
|
||||
if mode == "(":
|
||||
self.g0_charset = cs.MAPS[code]
|
||||
elif mode == ")":
|
||||
self.g1_charset = cs.MAPS[code]
|
||||
|
||||
def shift_in(self) -> None:
|
||||
"""Select G0 character set."""
|
||||
self.charset = 0
|
||||
|
||||
def shift_out(self) -> None:
|
||||
"""Select G1 character set."""
|
||||
self.charset = 1
|
||||
|
||||
def draw(self, data: str) -> None:
|
||||
"""Display decoded characters at the current cursor position."""
|
||||
data = data.translate(self.g1_charset if self.charset else self.g0_charset)
|
||||
|
||||
for char in data:
|
||||
char_width = wcwidth(char)
|
||||
|
||||
# Treat Private Use Area and other nerd font glyphs as width 1.
|
||||
# wcwidth returns -1 for PUA chars (U+E000-U+F8FF, U+F0000-U+FFFFF)
|
||||
# which are used by Nerd Fonts for icons in tmux status bars, etc.
|
||||
if char_width < 0:
|
||||
code = ord(char)
|
||||
if (0xE000 <= code <= 0xF8FF or # BMP Private Use Area
|
||||
0xF0000 <= code <= 0xFFFFF or # Supplementary PUA-A
|
||||
0x100000 <= code <= 0x10FFFF or # Supplementary PUA-B
|
||||
0x2580 <= code <= 0x259F or # Block Elements
|
||||
0x1F000 <= code <= 0x1FFFF): # Symbols/Emoji
|
||||
char_width = 1
|
||||
else:
|
||||
continue # Skip truly unprintable chars instead of breaking
|
||||
|
||||
# Clide: Log character drawing for debugging
|
||||
if _debug_logger is not None and char_width > 0:
|
||||
code = ord(char)
|
||||
if code > 127 or code < 32:
|
||||
_log_debug(
|
||||
f"[DRAW] char={char!r} code=U+{code:04X} width={char_width} pos=({self.cursor.x},{self.cursor.y})"
|
||||
)
|
||||
|
||||
if self.cursor.x == self.columns:
|
||||
if mo.DECAWM in self.mode:
|
||||
self.dirty.add(self.cursor.y)
|
||||
self.carriage_return()
|
||||
self.linefeed()
|
||||
elif char_width > 0:
|
||||
self.cursor.x -= char_width
|
||||
|
||||
if mo.IRM in self.mode and char_width > 0:
|
||||
self.insert_characters(char_width)
|
||||
|
||||
line = self.buffer[self.cursor.y]
|
||||
if char_width == 1:
|
||||
line[self.cursor.x] = self.cursor.attrs._replace(data=char)
|
||||
elif char_width == 2:
|
||||
line[self.cursor.x] = self.cursor.attrs._replace(data=char)
|
||||
if self.cursor.x + 1 < self.columns:
|
||||
line[self.cursor.x + 1] = self.cursor.attrs._replace(data="")
|
||||
elif char_width == 0 and unicodedata.combining(char):
|
||||
if self.cursor.x:
|
||||
last = line[self.cursor.x - 1]
|
||||
normalized = unicodedata.normalize("NFC", last.data + char)
|
||||
line[self.cursor.x - 1] = last._replace(data=normalized)
|
||||
elif self.cursor.y:
|
||||
last = self.buffer[self.cursor.y - 1][self.columns - 1]
|
||||
normalized = unicodedata.normalize("NFC", last.data + char)
|
||||
self.buffer[self.cursor.y - 1][self.columns - 1] = last._replace(
|
||||
data=normalized
|
||||
)
|
||||
elif char_width == 0:
|
||||
continue # Skip zero-width non-combining chars
|
||||
else:
|
||||
continue # Skip any remaining unhandled chars
|
||||
|
||||
if char_width > 0:
|
||||
self.cursor.x = min(self.cursor.x + char_width, self.columns)
|
||||
|
||||
self.dirty.add(self.cursor.y)
|
||||
|
||||
def set_title(self, param: str) -> None:
|
||||
"""Set terminal title."""
|
||||
self.title = param
|
||||
|
||||
def set_icon_name(self, param: str) -> None:
|
||||
"""Set icon name."""
|
||||
self.icon_name = param
|
||||
|
||||
def carriage_return(self) -> None:
|
||||
"""Move the cursor to the beginning of the current line."""
|
||||
self.cursor.x = 0
|
||||
|
||||
def index(self) -> None:
|
||||
"""Move the cursor down one line in the same column."""
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
if self.cursor.y == bottom:
|
||||
self.dirty.update(range(self.lines))
|
||||
for y in range(top, bottom):
|
||||
self.buffer[y] = self.buffer[y + 1]
|
||||
self.buffer.pop(bottom, None)
|
||||
else:
|
||||
self.cursor_down()
|
||||
|
||||
def reverse_index(self) -> None:
|
||||
"""Move the cursor up one line in the same column."""
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
if self.cursor.y == top:
|
||||
self.dirty.update(range(self.lines))
|
||||
for y in range(bottom, top, -1):
|
||||
self.buffer[y] = self.buffer[y - 1]
|
||||
self.buffer.pop(top, None)
|
||||
else:
|
||||
self.cursor_up()
|
||||
|
||||
def linefeed(self) -> None:
|
||||
"""Perform an index and, if LNM is set, a carriage return."""
|
||||
self.index()
|
||||
if mo.LNM in self.mode:
|
||||
self.carriage_return()
|
||||
|
||||
def tab(self) -> None:
|
||||
"""Move to the next tab space."""
|
||||
for stop in sorted(self.tabstops):
|
||||
if self.cursor.x < stop:
|
||||
column = stop
|
||||
break
|
||||
else:
|
||||
column = self.columns - 1
|
||||
self.cursor.x = column
|
||||
|
||||
def backspace(self) -> None:
|
||||
"""Move cursor to the left one."""
|
||||
self.cursor_back()
|
||||
|
||||
def save_cursor(self) -> None:
|
||||
"""Push the current cursor position onto the stack."""
|
||||
self.savepoints.append(
|
||||
Savepoint(
|
||||
copy.copy(self.cursor),
|
||||
self.g0_charset,
|
||||
self.g1_charset,
|
||||
self.charset,
|
||||
mo.DECOM in self.mode,
|
||||
mo.DECAWM in self.mode,
|
||||
)
|
||||
)
|
||||
|
||||
def restore_cursor(self) -> None:
|
||||
"""Set the current cursor position to whatever cursor is on top of the stack."""
|
||||
if self.savepoints:
|
||||
savepoint = self.savepoints.pop()
|
||||
self.g0_charset = savepoint.g0_charset
|
||||
self.g1_charset = savepoint.g1_charset
|
||||
self.charset = savepoint.charset
|
||||
if savepoint.origin:
|
||||
self.set_mode(mo.DECOM)
|
||||
if savepoint.wrap:
|
||||
self.set_mode(mo.DECAWM)
|
||||
self.cursor = savepoint.cursor
|
||||
self.ensure_hbounds()
|
||||
self.ensure_vbounds(use_margins=True)
|
||||
else:
|
||||
self.reset_mode(mo.DECOM)
|
||||
self.cursor_position()
|
||||
|
||||
def insert_lines(self, count: int | None = None) -> None:
|
||||
"""Insert the indicated # of lines at line with cursor."""
|
||||
count = count or 1
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
if top <= self.cursor.y <= bottom:
|
||||
self.dirty.update(range(self.cursor.y, self.lines))
|
||||
for y in range(bottom, self.cursor.y - 1, -1):
|
||||
if y + count <= bottom and y in self.buffer:
|
||||
self.buffer[y + count] = self.buffer[y]
|
||||
self.buffer.pop(y, None)
|
||||
self.carriage_return()
|
||||
|
||||
def delete_lines(self, count: int | None = None) -> None:
|
||||
"""Delete the indicated # of lines."""
|
||||
count = count or 1
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
if top <= self.cursor.y <= bottom:
|
||||
self.dirty.update(range(self.cursor.y, self.lines))
|
||||
for y in range(self.cursor.y, bottom + 1):
|
||||
if y + count <= bottom:
|
||||
if y + count in self.buffer:
|
||||
self.buffer[y] = self.buffer.pop(y + count)
|
||||
else:
|
||||
self.buffer.pop(y, None)
|
||||
self.carriage_return()
|
||||
|
||||
def insert_characters(self, count: int | None = None) -> None:
|
||||
"""Insert the indicated # of blank characters at the cursor position."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
count = count or 1
|
||||
line = self.buffer[self.cursor.y]
|
||||
for x in range(self.columns, self.cursor.x - 1, -1):
|
||||
if x + count <= self.columns:
|
||||
line[x + count] = line[x]
|
||||
line.pop(x, None)
|
||||
|
||||
def delete_characters(self, count: int | None = None) -> None:
|
||||
"""Delete the indicated # of characters."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
count = count or 1
|
||||
line = self.buffer[self.cursor.y]
|
||||
for x in range(self.cursor.x, self.columns):
|
||||
if x + count <= self.columns:
|
||||
line[x] = line.pop(x + count, self.default_char)
|
||||
else:
|
||||
line.pop(x, None)
|
||||
|
||||
def erase_characters(self, count: int | None = None) -> None:
|
||||
"""Erase the indicated # of characters."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
count = count or 1
|
||||
line = self.buffer[self.cursor.y]
|
||||
for x in range(self.cursor.x, min(self.cursor.x + count, self.columns)):
|
||||
line[x] = self.cursor.attrs
|
||||
|
||||
def erase_in_line(self, how: int = 0, private: bool = False) -> None:
|
||||
"""Erase a line in a specific way."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
if how == 0:
|
||||
interval = range(self.cursor.x, self.columns)
|
||||
elif how == 1:
|
||||
interval = range(self.cursor.x + 1)
|
||||
elif how == 2:
|
||||
interval = range(self.columns)
|
||||
|
||||
line = self.buffer[self.cursor.y]
|
||||
for x in interval:
|
||||
line[x] = self.cursor.attrs
|
||||
|
||||
def erase_in_display(self, how: int = 0, *args: Any, **kwargs: Any) -> None:
|
||||
"""Erases display in a specific way."""
|
||||
_log_debug(f"[SCREEN] erase_in_display(how={how})")
|
||||
|
||||
if how == 0:
|
||||
interval = range(self.cursor.y + 1, self.lines)
|
||||
elif how == 1:
|
||||
interval = range(self.cursor.y)
|
||||
elif how == 2 or how == 3:
|
||||
interval = range(self.lines)
|
||||
|
||||
self.dirty.update(interval)
|
||||
for y in interval:
|
||||
line = self.buffer[y]
|
||||
for x in line:
|
||||
line[x] = self.cursor.attrs
|
||||
|
||||
if how == 0 or how == 1:
|
||||
self.erase_in_line(how)
|
||||
|
||||
def set_tab_stop(self) -> None:
|
||||
"""Set a horizontal tab stop at cursor position."""
|
||||
self.tabstops.add(self.cursor.x)
|
||||
|
||||
def clear_tab_stop(self, how: int = 0) -> None:
|
||||
"""Clear a horizontal tab stop."""
|
||||
if how == 0:
|
||||
self.tabstops.discard(self.cursor.x)
|
||||
elif how == 3:
|
||||
self.tabstops = set()
|
||||
|
||||
def ensure_hbounds(self) -> None:
|
||||
"""Ensure the cursor is within horizontal screen bounds."""
|
||||
self.cursor.x = min(max(0, self.cursor.x), self.columns - 1)
|
||||
|
||||
def ensure_vbounds(self, use_margins: bool | None = None) -> None:
|
||||
"""Ensure the cursor is within vertical screen bounds."""
|
||||
if (use_margins or mo.DECOM in self.mode) and self.margins is not None:
|
||||
top, bottom = self.margins
|
||||
else:
|
||||
top, bottom = 0, self.lines - 1
|
||||
self.cursor.y = min(max(top, self.cursor.y), bottom)
|
||||
|
||||
def cursor_up(self, count: int | None = None) -> None:
|
||||
"""Move cursor up the indicated # of lines."""
|
||||
top, _bottom = self.margins or Margins(0, self.lines - 1)
|
||||
self.cursor.y = max(self.cursor.y - (count or 1), top)
|
||||
|
||||
def cursor_up1(self, count: int | None = None) -> None:
|
||||
"""Move cursor up the indicated # of lines to column 1."""
|
||||
self.cursor_up(count)
|
||||
self.carriage_return()
|
||||
|
||||
def cursor_down(self, count: int | None = None) -> None:
|
||||
"""Move cursor down the indicated # of lines."""
|
||||
_top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
self.cursor.y = min(self.cursor.y + (count or 1), bottom)
|
||||
|
||||
def cursor_down1(self, count: int | None = None) -> None:
|
||||
"""Move cursor down the indicated # of lines to column 1."""
|
||||
self.cursor_down(count)
|
||||
self.carriage_return()
|
||||
|
||||
def cursor_back(self, count: int | None = None) -> None:
|
||||
"""Move cursor left the indicated # of columns."""
|
||||
if self.cursor.x == self.columns:
|
||||
self.cursor.x -= 1
|
||||
self.cursor.x -= count or 1
|
||||
self.ensure_hbounds()
|
||||
|
||||
def cursor_forward(self, count: int | None = None) -> None:
|
||||
"""Move cursor right the indicated # of columns."""
|
||||
self.cursor.x += count or 1
|
||||
self.ensure_hbounds()
|
||||
|
||||
def cursor_position(self, line: int | None = None, column: int | None = None) -> None:
|
||||
"""Set the cursor to a specific line and column."""
|
||||
column = (column or 1) - 1
|
||||
line = (line or 1) - 1
|
||||
|
||||
if self.margins is not None and mo.DECOM in self.mode:
|
||||
line += self.margins.top
|
||||
if not self.margins.top <= line <= self.margins.bottom:
|
||||
return
|
||||
|
||||
self.cursor.x = column
|
||||
self.cursor.y = line
|
||||
self.ensure_hbounds()
|
||||
self.ensure_vbounds()
|
||||
|
||||
def cursor_to_column(self, column: int | None = None) -> None:
|
||||
"""Move cursor to a specific column in the current line."""
|
||||
self.cursor.x = (column or 1) - 1
|
||||
self.ensure_hbounds()
|
||||
|
||||
def cursor_to_line(self, line: int | None = None) -> None:
|
||||
"""Move cursor to a specific line in the current column."""
|
||||
self.cursor.y = (line or 1) - 1
|
||||
if mo.DECOM in self.mode:
|
||||
assert self.margins is not None
|
||||
self.cursor.y += self.margins.top
|
||||
self.ensure_vbounds()
|
||||
|
||||
def bell(self, *args: Any) -> None:
|
||||
"""Bell stub."""
|
||||
pass
|
||||
|
||||
def alignment_display(self) -> None:
|
||||
"""Fills screen with uppercase E's for screen focus and alignment."""
|
||||
self.dirty.update(range(self.lines))
|
||||
for y in range(self.lines):
|
||||
for x in range(self.columns):
|
||||
self.buffer[y][x] = self.buffer[y][x]._replace(data="E")
|
||||
|
||||
def select_graphic_rendition(self, *attrs: int) -> None:
|
||||
"""Set display attributes."""
|
||||
replace = {}
|
||||
|
||||
if not attrs or attrs == (0,):
|
||||
self.cursor.attrs = self.default_char
|
||||
return
|
||||
|
||||
attrs_list = list(reversed(attrs))
|
||||
|
||||
while attrs_list:
|
||||
attr = attrs_list.pop()
|
||||
if attr == 0:
|
||||
replace.update(self.default_char._asdict())
|
||||
elif attr in g.FG_ANSI:
|
||||
replace["fg"] = g.FG_ANSI[attr]
|
||||
elif attr in g.BG:
|
||||
replace["bg"] = g.BG_ANSI[attr]
|
||||
elif attr in g.TEXT:
|
||||
attr_str = g.TEXT[attr]
|
||||
replace[attr_str[1:]] = attr_str.startswith("+")
|
||||
elif attr in g.FG_AIXTERM:
|
||||
replace.update(fg=g.FG_AIXTERM[attr])
|
||||
elif attr in g.BG_AIXTERM:
|
||||
replace.update(bg=g.BG_AIXTERM[attr])
|
||||
elif attr in (g.FG_256, g.BG_256):
|
||||
key = "fg" if attr == g.FG_256 else "bg"
|
||||
try:
|
||||
n = attrs_list.pop()
|
||||
if n == 5:
|
||||
m = attrs_list.pop()
|
||||
replace[key] = g.FG_BG_256[m]
|
||||
elif n == 2:
|
||||
replace[key] = (
|
||||
f"{attrs_list.pop():02x}{attrs_list.pop():02x}{attrs_list.pop():02x}"
|
||||
)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
self.cursor.attrs = self.cursor.attrs._replace(**replace)
|
||||
|
||||
def report_device_attributes(self, mode: int = 0, **kwargs: bool) -> None:
|
||||
"""Report terminal identity."""
|
||||
if mode == 0 and not kwargs.get("private"):
|
||||
self.write_process_input(ctrl.CSI + "?6c")
|
||||
|
||||
def report_device_status(self, mode: int) -> None:
|
||||
"""Report terminal status or cursor position."""
|
||||
if mode == 5:
|
||||
self.write_process_input(ctrl.CSI + "0n")
|
||||
elif mode == 6:
|
||||
x = self.cursor.x + 1
|
||||
y = self.cursor.y + 1
|
||||
if mo.DECOM in self.mode:
|
||||
assert self.margins is not None
|
||||
y -= self.margins.top
|
||||
self.write_process_input(ctrl.CSI + f"{y};{x}R")
|
||||
|
||||
def write_process_input(self, data: str) -> None:
|
||||
"""Write data to the process running inside the terminal."""
|
||||
pass
|
||||
|
||||
def debug(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Endpoint for unrecognized escape sequences."""
|
||||
if _debug_logger is not None:
|
||||
_log_debug(f"[DEBUG] unrecognized: args={args} kwargs={kwargs}")
|
||||
|
||||
|
||||
class DiffScreen(Screen):
|
||||
"""A screen subclass, which maintains a set of dirty lines. Deprecated."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
warnings.warn(
|
||||
"The functionality of ``DiffScreen` has been merged into "
|
||||
"``Screen`` and will be removed in 0.8.0.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
super(DiffScreen, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class History(NamedTuple):
|
||||
top: deque[StaticDefaultDict[int, Char]]
|
||||
bottom: deque[StaticDefaultDict[int, Char]]
|
||||
ratio: float
|
||||
size: int
|
||||
position: int
|
||||
|
||||
|
||||
class HistoryScreen(Screen):
|
||||
"""A Screen subclass, which keeps track of screen history."""
|
||||
|
||||
_wrapped = set(Stream.events)
|
||||
_wrapped.update(["next_page", "prev_page"])
|
||||
|
||||
def __init__(self, columns: int, lines: int, history: int = 100, ratio: float = 0.5) -> None:
|
||||
self.history = History(
|
||||
deque(maxlen=history), deque(maxlen=history), float(ratio), history, history
|
||||
)
|
||||
super(HistoryScreen, self).__init__(columns, lines)
|
||||
|
||||
def _make_wrapper(self, event: str, handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def inner(*args: Any, **kwargs: Any) -> Any:
|
||||
self.before_event(event)
|
||||
result = handler(*args, **kwargs)
|
||||
self.after_event(event)
|
||||
return result
|
||||
|
||||
return inner
|
||||
|
||||
def __getattribute__(self, attr: str) -> Callable[..., Any]:
|
||||
value = super(HistoryScreen, self).__getattribute__(attr)
|
||||
if attr in HistoryScreen._wrapped:
|
||||
return HistoryScreen._make_wrapper(self, attr, value)
|
||||
else:
|
||||
return value # type: ignore[no-any-return]
|
||||
|
||||
def before_event(self, event: str) -> None:
|
||||
"""Ensure a screen is at the bottom of the history buffer."""
|
||||
if event not in ["prev_page", "next_page"]:
|
||||
while self.history.position < self.history.size:
|
||||
self.next_page()
|
||||
|
||||
def after_event(self, event: str) -> None:
|
||||
"""Ensure all lines on a screen have proper width."""
|
||||
if event in ["prev_page", "next_page"]:
|
||||
for line in self.buffer.values():
|
||||
for x in line:
|
||||
if x > self.columns:
|
||||
line.pop(x)
|
||||
|
||||
self.cursor.hidden = not (
|
||||
self.history.position == self.history.size and mo.DECTCEM in self.mode
|
||||
)
|
||||
|
||||
def _reset_history(self) -> None:
|
||||
self.history.top.clear()
|
||||
self.history.bottom.clear()
|
||||
self.history = self.history._replace(position=self.history.size)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Overloaded to reset screen history state."""
|
||||
super(HistoryScreen, self).reset()
|
||||
self._reset_history()
|
||||
|
||||
def erase_in_display(self, how: int = 0, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overloaded to reset history state."""
|
||||
super(HistoryScreen, self).erase_in_display(how, *args, **kwargs)
|
||||
if how == 3:
|
||||
self._reset_history()
|
||||
|
||||
def index(self) -> None:
|
||||
"""Overloaded to update top history with the removed lines."""
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
if self.cursor.y == bottom:
|
||||
self.history.top.append(self.buffer[top])
|
||||
super(HistoryScreen, self).index()
|
||||
|
||||
def reverse_index(self) -> None:
|
||||
"""Overloaded to update bottom history with the removed lines."""
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
if self.cursor.y == top:
|
||||
self.history.bottom.append(self.buffer[bottom])
|
||||
super(HistoryScreen, self).reverse_index()
|
||||
|
||||
def prev_page(self) -> None:
|
||||
"""Move the screen page up through the history buffer."""
|
||||
if self.history.position > self.lines and self.history.top:
|
||||
mid = min(len(self.history.top), int(math.ceil(self.lines * self.history.ratio)))
|
||||
|
||||
self.history.bottom.extendleft(
|
||||
self.buffer[y] for y in range(self.lines - 1, self.lines - mid - 1, -1)
|
||||
)
|
||||
self.history = self.history._replace(position=self.history.position - mid)
|
||||
|
||||
for y in range(self.lines - 1, mid - 1, -1):
|
||||
self.buffer[y] = self.buffer[y - mid]
|
||||
for y in range(mid - 1, -1, -1):
|
||||
self.buffer[y] = self.history.top.pop()
|
||||
|
||||
self.dirty = set(range(self.lines))
|
||||
|
||||
def next_page(self) -> None:
|
||||
"""Move the screen page down through the history buffer."""
|
||||
if self.history.position < self.history.size and self.history.bottom:
|
||||
mid = min(len(self.history.bottom), int(math.ceil(self.lines * self.history.ratio)))
|
||||
|
||||
self.history.top.extend(self.buffer[y] for y in range(mid))
|
||||
self.history = self.history._replace(position=self.history.position + mid)
|
||||
|
||||
for y in range(self.lines - mid):
|
||||
self.buffer[y] = self.buffer[y + mid]
|
||||
for y in range(self.lines - mid, self.lines):
|
||||
self.buffer[y] = self.history.bottom.popleft()
|
||||
|
||||
self.dirty = set(range(self.lines))
|
||||
|
||||
|
||||
class DebugEvent(NamedTuple):
|
||||
"""Event dispatched to DebugScreen."""
|
||||
|
||||
name: str
|
||||
args: Any
|
||||
kwargs: Any
|
||||
|
||||
@staticmethod
|
||||
def from_string(line: str) -> DebugEvent:
|
||||
return DebugEvent(*json.loads(line))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return json.dumps(self)
|
||||
|
||||
def __call__(self, screen: Screen) -> Any:
|
||||
"""Execute this event on a given screen."""
|
||||
return getattr(screen, self.name)(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
class DebugScreen:
|
||||
"""A screen which dumps a subset of the received events to a file."""
|
||||
|
||||
def __init__(self, to: TextIO = sys.stderr, only: Sequence[str] = ()) -> None:
|
||||
self.to = to
|
||||
self.only = only
|
||||
|
||||
def only_wrapper(self, attr: str) -> Callable[..., None]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
self.to.write(str(DebugEvent(attr, args, kwargs)))
|
||||
self.to.write(str(os.linesep))
|
||||
|
||||
return wrapper
|
||||
|
||||
def __getattribute__(self, attr: str) -> Callable[..., None]:
|
||||
if attr not in Stream.events:
|
||||
return super(DebugScreen, self).__getattribute__(attr) # type: ignore[no-any-return]
|
||||
elif not self.only or attr in self.only:
|
||||
return self.only_wrapper(attr)
|
||||
else:
|
||||
return lambda *args, **kwargs: None
|
||||
Vendored
+519
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
pyte.streams
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module provides three stream implementations with different
|
||||
features; for starters, here's a quick example of how streams are
|
||||
typically used:
|
||||
|
||||
>>> import pyte
|
||||
>>> screen = pyte.Screen(80, 24)
|
||||
>>> stream = pyte.Stream(screen)
|
||||
>>> stream.feed("\x1b[5B") # Move the cursor down 5 rows.
|
||||
>>> screen.cursor.y
|
||||
5
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import itertools
|
||||
import re
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from . import control as ctrl
|
||||
from . import escape as esc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .screens import Screen
|
||||
|
||||
|
||||
# Clide diagnostic logging support
|
||||
_debug_logger: Callable[[str], None] | None = None
|
||||
_event_callback: Callable[[str], None] | None = None
|
||||
|
||||
|
||||
def set_debug_logger(logger: Callable[[str], None] | None) -> None:
|
||||
"""Set a debug logger function for diagnostic output.
|
||||
|
||||
Args:
|
||||
logger: A callable that accepts a string message, or None to disable.
|
||||
"""
|
||||
global _debug_logger
|
||||
_debug_logger = logger
|
||||
|
||||
|
||||
def set_event_callback(callback: Callable[[str], None] | None) -> None:
|
||||
"""Set an event callback for raw terminal data.
|
||||
|
||||
This callback is invoked with the raw data before parsing.
|
||||
Useful for Claude Code event detection.
|
||||
|
||||
Args:
|
||||
callback: A callable that accepts raw terminal data, or None to disable.
|
||||
"""
|
||||
global _event_callback
|
||||
_event_callback = callback
|
||||
|
||||
|
||||
def _log_debug(message: str) -> None:
|
||||
"""Log a debug message if debug logging is enabled."""
|
||||
if _debug_logger is not None:
|
||||
_debug_logger(message)
|
||||
|
||||
|
||||
ParserGenerator = Generator[bool | None, str, None]
|
||||
|
||||
|
||||
class Stream:
|
||||
"""A stream is a state machine that parses a stream of bytes and
|
||||
dispatches events based on what it sees.
|
||||
|
||||
:param pyte.screens.Screen screen: a screen to dispatch events to.
|
||||
:param bool strict: check if a given screen implements all required
|
||||
events.
|
||||
|
||||
.. note::
|
||||
|
||||
Stream only accepts text as input, but if for some reason
|
||||
you need to feed it with bytes, consider using
|
||||
:class:`~pyte.streams.ByteStream` instead.
|
||||
|
||||
.. versionchanged 0.6.0::
|
||||
|
||||
For performance reasons the binding between stream events and
|
||||
screen methods was made static. As a result, the stream **will
|
||||
not** dispatch events to methods added to screen **after** the
|
||||
stream was created.
|
||||
|
||||
.. seealso::
|
||||
|
||||
`man console_codes <http://linux.die.net/man/4/console_codes>`_
|
||||
For details on console codes listed bellow in :attr:`basic`,
|
||||
:attr:`escape`, :attr:`csi`, :attr:`sharp`.
|
||||
"""
|
||||
|
||||
#: Control sequences, which don't require any arguments.
|
||||
basic = {
|
||||
ctrl.BEL: "bell",
|
||||
ctrl.BS: "backspace",
|
||||
ctrl.HT: "tab",
|
||||
ctrl.LF: "linefeed",
|
||||
ctrl.VT: "linefeed",
|
||||
ctrl.FF: "linefeed",
|
||||
ctrl.CR: "carriage_return",
|
||||
ctrl.SO: "shift_out",
|
||||
ctrl.SI: "shift_in",
|
||||
}
|
||||
|
||||
#: non-CSI escape sequences.
|
||||
escape = {
|
||||
esc.RIS: "reset",
|
||||
esc.IND: "index",
|
||||
esc.NEL: "linefeed",
|
||||
esc.RI: "reverse_index",
|
||||
esc.HTS: "set_tab_stop",
|
||||
esc.DECSC: "save_cursor",
|
||||
esc.DECRC: "restore_cursor",
|
||||
}
|
||||
|
||||
#: "sharp" escape sequences -- ``ESC # <N>``.
|
||||
sharp = {
|
||||
esc.DECALN: "alignment_display",
|
||||
}
|
||||
|
||||
#: CSI escape sequences -- ``CSI P1;P2;...;Pn <fn>``.
|
||||
csi = {
|
||||
esc.ICH: "insert_characters",
|
||||
esc.CUU: "cursor_up",
|
||||
esc.CUD: "cursor_down",
|
||||
esc.CUF: "cursor_forward",
|
||||
esc.CUB: "cursor_back",
|
||||
esc.CNL: "cursor_down1",
|
||||
esc.CPL: "cursor_up1",
|
||||
esc.CHA: "cursor_to_column",
|
||||
esc.CUP: "cursor_position",
|
||||
esc.ED: "erase_in_display",
|
||||
esc.EL: "erase_in_line",
|
||||
esc.IL: "insert_lines",
|
||||
esc.DL: "delete_lines",
|
||||
esc.DCH: "delete_characters",
|
||||
esc.ECH: "erase_characters",
|
||||
esc.HPR: "cursor_forward",
|
||||
esc.DA: "report_device_attributes",
|
||||
esc.VPA: "cursor_to_line",
|
||||
esc.VPR: "cursor_down",
|
||||
esc.HVP: "cursor_position",
|
||||
esc.TBC: "clear_tab_stop",
|
||||
esc.SM: "set_mode",
|
||||
esc.RM: "reset_mode",
|
||||
esc.SGR: "select_graphic_rendition",
|
||||
esc.DSR: "report_device_status",
|
||||
esc.DECSTBM: "set_margins",
|
||||
esc.HPA: "cursor_to_column",
|
||||
}
|
||||
|
||||
#: A set of all events dispatched by the stream.
|
||||
events = frozenset(
|
||||
itertools.chain(
|
||||
basic.values(),
|
||||
escape.values(),
|
||||
sharp.values(),
|
||||
csi.values(),
|
||||
["define_charset"],
|
||||
["set_icon_name", "set_title"], # OSC.
|
||||
["draw", "debug"],
|
||||
)
|
||||
)
|
||||
|
||||
#: A regular expression pattern matching everything what can be
|
||||
#: considered plain text.
|
||||
_special = set([ctrl.ESC, ctrl.CSI_C1, ctrl.NUL, ctrl.DEL, ctrl.OSC_C1])
|
||||
_special.update(basic)
|
||||
_text_pattern = re.compile("[^" + "".join(map(re.escape, _special)) + "]+")
|
||||
del _special
|
||||
|
||||
def __init__(self, screen: Screen | None = None, strict: bool = True) -> None:
|
||||
self.listener: Screen | None = None
|
||||
self.strict = strict
|
||||
self.use_utf8: bool = True
|
||||
|
||||
self._taking_plain_text: bool | None = None
|
||||
|
||||
if screen is not None:
|
||||
self.attach(screen)
|
||||
|
||||
def attach(self, screen: Screen) -> None:
|
||||
"""Adds a given screen to the listener queue.
|
||||
|
||||
:param pyte.screens.Screen screen: a screen to attach to.
|
||||
"""
|
||||
if self.listener is not None:
|
||||
warnings.warn(
|
||||
"As of version 0.6.0 the listener queue is "
|
||||
"restricted to a single element. Existing "
|
||||
f"listener {self.listener} will be replaced.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
if self.strict:
|
||||
for event in self.events:
|
||||
if not hasattr(screen, event):
|
||||
raise TypeError(f"{screen} is missing {event}")
|
||||
|
||||
self.listener = screen
|
||||
self._parser: ParserGenerator | None = None
|
||||
self._initialize_parser()
|
||||
|
||||
def detach(self, screen: Screen) -> None:
|
||||
"""Remove a given screen from the listener queue and fails
|
||||
silently if it's not attached.
|
||||
|
||||
:param pyte.screens.Screen screen: a screen to detach.
|
||||
"""
|
||||
if screen is self.listener:
|
||||
self.listener = None
|
||||
|
||||
def feed(self, data: str) -> None:
|
||||
"""Consume some data and advances the state as necessary.
|
||||
|
||||
:param str data: a blob of data to feed from.
|
||||
"""
|
||||
# Clide: Invoke event callback for raw data
|
||||
if _event_callback is not None:
|
||||
try:
|
||||
_event_callback(data)
|
||||
except Exception:
|
||||
pass # Don't let callback errors affect parsing
|
||||
|
||||
# Clide: Debug log incoming data
|
||||
if _debug_logger is not None:
|
||||
# Log escape sequences in a readable format
|
||||
escaped = data.encode("unicode_escape").decode("ascii")
|
||||
if len(escaped) > 200:
|
||||
escaped = escaped[:200] + "..."
|
||||
_log_debug(f"[STREAM] feed: {escaped}")
|
||||
|
||||
send = self._send_to_parser
|
||||
if self.listener is None:
|
||||
raise RuntimeError("Listener is not set")
|
||||
|
||||
draw = self.listener.draw
|
||||
match_text = self._text_pattern.match
|
||||
taking_plain_text = self._taking_plain_text
|
||||
|
||||
length = len(data)
|
||||
offset = 0
|
||||
while offset < length:
|
||||
if taking_plain_text:
|
||||
match = match_text(data, offset)
|
||||
if match:
|
||||
start, offset = match.span()
|
||||
draw(data[start:offset])
|
||||
else:
|
||||
taking_plain_text = False
|
||||
else:
|
||||
taking_plain_text = send(data[offset : offset + 1])
|
||||
offset += 1
|
||||
|
||||
self._taking_plain_text = taking_plain_text
|
||||
|
||||
def _send_to_parser(self, data: str) -> bool | None:
|
||||
try:
|
||||
assert self._parser is not None
|
||||
return self._parser.send(data)
|
||||
except Exception:
|
||||
# Reset the parser state to make sure it is usable even
|
||||
# after receiving an exception. See PR #101 for details.
|
||||
self._initialize_parser()
|
||||
raise
|
||||
|
||||
def _initialize_parser(self) -> None:
|
||||
self._parser = self._parser_fsm()
|
||||
self._taking_plain_text = next(self._parser)
|
||||
|
||||
def _parser_fsm(self) -> ParserGenerator:
|
||||
"""An FSM implemented as a coroutine.
|
||||
|
||||
This generator is not the most beautiful, but it is as performant
|
||||
as possible. When a process generates a lot of output, then this
|
||||
will be the bottleneck, because it processes just one character
|
||||
at a time.
|
||||
|
||||
Don't change anything without profiling first.
|
||||
"""
|
||||
basic = self.basic
|
||||
assert self.listener is not None
|
||||
listener = self.listener
|
||||
draw = listener.draw
|
||||
debug = listener.debug
|
||||
|
||||
ESC, CSI_C1 = ctrl.ESC, ctrl.CSI_C1
|
||||
OSC_C1 = ctrl.OSC_C1
|
||||
SP_OR_GT = ctrl.SP + ">"
|
||||
NUL_OR_DEL = ctrl.NUL + ctrl.DEL
|
||||
CAN_OR_SUB = ctrl.CAN + ctrl.SUB
|
||||
ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, ctrl.VT, ctrl.FF, ctrl.CR])
|
||||
OSC_TERMINATORS = set([ctrl.ST_C0, ctrl.ST_C1, ctrl.BEL])
|
||||
|
||||
def create_dispatcher(mapping: Mapping[str, str]) -> dict[str, Callable[..., None]]:
|
||||
d = defaultdict(
|
||||
lambda: debug,
|
||||
dict((event, getattr(listener, attr)) for event, attr in mapping.items()),
|
||||
)
|
||||
|
||||
# Clide: Wrap dispatchers with debug logging
|
||||
if _debug_logger is not None:
|
||||
original_d = dict(d)
|
||||
for event, attr in mapping.items():
|
||||
original_handler = original_d.get(event, debug)
|
||||
|
||||
def make_wrapper(e: str, a: str, h: Callable[..., None]) -> Callable[..., None]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
_log_debug(f"[DISPATCH] {a}({args}, {kwargs})")
|
||||
return h(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
d[event] = make_wrapper(event, attr, original_handler)
|
||||
|
||||
return d
|
||||
|
||||
basic_dispatch = create_dispatcher(basic)
|
||||
sharp_dispatch = create_dispatcher(self.sharp)
|
||||
escape_dispatch = create_dispatcher(self.escape)
|
||||
csi_dispatch = create_dispatcher(self.csi)
|
||||
|
||||
while True:
|
||||
# ``True`` tells ``Screen.feed`` that it is allowed to send
|
||||
# chunks of plain text directly to the listener, instead
|
||||
# of this generator.
|
||||
char = yield True
|
||||
|
||||
if char == ESC:
|
||||
# Most non-VT52 commands start with a left-bracket after the
|
||||
# escape and then a stream of parameters and a command; with
|
||||
# a single notable exception -- :data:`escape.DECOM` sequence,
|
||||
# which starts with a sharp.
|
||||
#
|
||||
# .. versionchanged:: 0.4.10
|
||||
#
|
||||
# For compatibility with Linux terminal stream also
|
||||
# recognizes ``ESC % C`` sequences for selecting control
|
||||
# character set. However, in the current version these
|
||||
# are noop.
|
||||
char = yield None
|
||||
if char == "[":
|
||||
char = CSI_C1 # Go to CSI.
|
||||
elif char == "]":
|
||||
char = OSC_C1 # Go to OSC.
|
||||
else:
|
||||
if char == "#":
|
||||
sharp_dispatch[(yield None)]()
|
||||
elif char == "%":
|
||||
self.select_other_charset((yield None))
|
||||
elif char in "()":
|
||||
code = yield None
|
||||
if self.use_utf8:
|
||||
continue
|
||||
|
||||
# See http://www.cl.cam.ac.uk/~mgk25/unicode.html#term
|
||||
# for the why on the UTF-8 restriction.
|
||||
listener.define_charset(code, mode=char)
|
||||
else:
|
||||
escape_dispatch[char]()
|
||||
continue # Don't go to CSI.
|
||||
|
||||
if char in basic:
|
||||
# Ignore shifts in UTF-8 mode. See
|
||||
# http://www.cl.cam.ac.uk/~mgk25/unicode.html#term for
|
||||
# the why on UTF-8 restriction.
|
||||
if (char == ctrl.SI or char == ctrl.SO) and self.use_utf8:
|
||||
continue
|
||||
|
||||
basic_dispatch[char]()
|
||||
elif char == CSI_C1:
|
||||
# All parameters are unsigned, positive decimal integers, with
|
||||
# the most significant digit sent first. Any parameter greater
|
||||
# than 9999 is set to 9999. If you do not specify a value, a 0
|
||||
# value is assumed.
|
||||
#
|
||||
# .. seealso::
|
||||
#
|
||||
# `VT102 User Guide <http://vt100.net/docs/vt102-ug/>`_
|
||||
# For details on the formatting of escape arguments.
|
||||
#
|
||||
# `VT220 Programmer Ref. <http://vt100.net/docs/vt220-rm/>`_
|
||||
# For details on the characters valid for use as
|
||||
# arguments.
|
||||
params = []
|
||||
current = ""
|
||||
private = False
|
||||
while True:
|
||||
char = yield None
|
||||
if char == "?":
|
||||
private = True
|
||||
elif char in ALLOWED_IN_CSI:
|
||||
basic_dispatch[char]()
|
||||
elif char in SP_OR_GT:
|
||||
pass # Secondary DA is not supported atm.
|
||||
elif char in CAN_OR_SUB:
|
||||
# If CAN or SUB is received during a sequence, the
|
||||
# current sequence is aborted; terminal displays
|
||||
# the substitute character, followed by characters
|
||||
# in the sequence received after CAN or SUB.
|
||||
draw(char)
|
||||
break
|
||||
elif char.isdigit():
|
||||
current += char
|
||||
elif char == "$":
|
||||
# XTerm-specific ESC]...$[a-z] sequences are not
|
||||
# currently supported.
|
||||
yield None
|
||||
break
|
||||
else:
|
||||
params.append(min(int(current or 0), 9999))
|
||||
|
||||
if char == ";":
|
||||
current = ""
|
||||
else:
|
||||
# Clide: Log CSI sequence
|
||||
if _debug_logger is not None:
|
||||
_log_debug(f"[CSI] char={char!r} params={params} private={private}")
|
||||
|
||||
if private:
|
||||
csi_dispatch[char](*params, private=True)
|
||||
else:
|
||||
csi_dispatch[char](*params)
|
||||
break # CSI is finished.
|
||||
elif char == OSC_C1:
|
||||
code = yield None
|
||||
if code == "R":
|
||||
continue # Reset palette. Not implemented.
|
||||
elif code == "P":
|
||||
continue # Set palette. Not implemented.
|
||||
|
||||
param = ""
|
||||
while True:
|
||||
char = yield None
|
||||
if char == ESC:
|
||||
char += yield None
|
||||
if char in OSC_TERMINATORS:
|
||||
break
|
||||
else:
|
||||
param += char
|
||||
|
||||
param = param[1:] # Drop the ;.
|
||||
|
||||
# Clide: Log OSC sequence
|
||||
if _debug_logger is not None:
|
||||
_log_debug(f"[OSC] code={code!r} param={param!r}")
|
||||
|
||||
if code in "01":
|
||||
listener.set_icon_name(param)
|
||||
if code in "02":
|
||||
listener.set_title(param)
|
||||
elif char not in NUL_OR_DEL:
|
||||
draw(char)
|
||||
|
||||
def select_other_charset(self, code: str) -> None:
|
||||
"""Select other (non G0 or G1) charset.
|
||||
|
||||
:param str code: character set code, should be a character from
|
||||
``"@G8"``, otherwise ignored.
|
||||
|
||||
.. note:: We currently follow ``"linux"`` and only use this
|
||||
command to switch from ISO-8859-1 to UTF-8 and back.
|
||||
|
||||
.. versionadded:: 0.6.0
|
||||
|
||||
.. seealso::
|
||||
|
||||
`Standard ECMA-35, Section 15.4 \
|
||||
<http://ecma-international.org/publications/standards/Ecma-035.htm>`_
|
||||
for a description of VTXXX character set machinery.
|
||||
"""
|
||||
# A noop since all input is Unicode-only.
|
||||
|
||||
|
||||
class ByteStream(Stream):
|
||||
"""A stream which takes bytes as input.
|
||||
|
||||
Bytes are decoded to text using either UTF-8 (default) or the encoding
|
||||
selected via :meth:`~pyte.Stream.select_other_charset`.
|
||||
|
||||
.. attribute:: use_utf8
|
||||
|
||||
Assume the input to :meth:`~pyte.streams.ByteStream.feed` is encoded
|
||||
using UTF-8. Defaults to ``True``.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
super(ByteStream, self).__init__(*args, **kwargs)
|
||||
|
||||
self.utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
|
||||
def feed(self, data: bytes) -> None: # type: ignore[override]
|
||||
if self.use_utf8:
|
||||
data_str = self.utf8_decoder.decode(data)
|
||||
else:
|
||||
data_str = "".join(map(chr, data))
|
||||
|
||||
super(ByteStream, self).feed(data_str)
|
||||
|
||||
def select_other_charset(self, code: str) -> None:
|
||||
if code == "@":
|
||||
self.use_utf8 = False
|
||||
self.utf8_decoder.reset()
|
||||
elif code in "G8":
|
||||
self.use_utf8 = True
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""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.tile_list import TileItem, TileListView
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
__all__ = [
|
||||
"BranchStatus",
|
||||
"DiffPane",
|
||||
"EditorPane",
|
||||
"FilesView",
|
||||
"GitChangesView",
|
||||
"GitGraphView",
|
||||
"JiraView",
|
||||
"ProblemsView",
|
||||
"TerminalPane",
|
||||
"TileItem",
|
||||
"TileListView",
|
||||
"TodosView",
|
||||
]
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Action bar widget for contextual toolbar buttons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionButton:
|
||||
"""Definition for an action button.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the button
|
||||
icon: Unicode icon to display
|
||||
tooltip: Hover text / description
|
||||
callback: Function to call when clicked (or None for message-based)
|
||||
visible: Whether button is currently visible
|
||||
enabled: Whether button is currently enabled
|
||||
"""
|
||||
|
||||
id: str
|
||||
icon: str
|
||||
tooltip: str
|
||||
callback: Callable[[], None] | None = None
|
||||
visible: bool = True
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ActionBarButton(Static):
|
||||
"""A compact action bar button using Static for cleaner rendering."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ActionBarButton {
|
||||
width: auto;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
ActionBarButton:hover {
|
||||
background: $surface-lighten-1;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
ActionBarButton.-active {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
ActionBarButton.-disabled {
|
||||
color: $text-disabled;
|
||||
}
|
||||
"""
|
||||
|
||||
can_focus = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
icon: str,
|
||||
tooltip: str,
|
||||
action_id: str,
|
||||
disabled: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(icon, **kwargs)
|
||||
self.tooltip = tooltip
|
||||
self.action_id = action_id
|
||||
self._disabled = disabled
|
||||
if disabled:
|
||||
self.add_class("-disabled")
|
||||
|
||||
@property
|
||||
def disabled(self) -> bool:
|
||||
return self._disabled
|
||||
|
||||
@disabled.setter
|
||||
def disabled(self, value: bool) -> None:
|
||||
self._disabled = value
|
||||
if value:
|
||||
self.add_class("-disabled")
|
||||
else:
|
||||
self.remove_class("-disabled")
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
"""Handle click events."""
|
||||
if not self._disabled:
|
||||
# Post a button pressed message
|
||||
self.post_message(Button.Pressed(self))
|
||||
|
||||
|
||||
class ActionBar(Horizontal):
|
||||
"""Contextual action bar for workspace panels.
|
||||
|
||||
Displays action buttons that can be dynamically added/removed
|
||||
based on the active context (editor, diff, terminal, etc.).
|
||||
|
||||
Example:
|
||||
action_bar = ActionBar()
|
||||
action_bar.register_button(ActionButton(
|
||||
id="save",
|
||||
icon="💾",
|
||||
tooltip="Save file",
|
||||
callback=self.save_file,
|
||||
))
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ActionBar {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ActionBar .action-separator {
|
||||
width: 1;
|
||||
height: 1;
|
||||
margin: 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
class ButtonPressed(Message):
|
||||
"""Emitted when an action button is pressed."""
|
||||
|
||||
def __init__(self, button_id: str) -> None:
|
||||
self.button_id = button_id
|
||||
super().__init__()
|
||||
|
||||
# Track maximized state
|
||||
maximized: reactive[bool] = reactive(False)
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._buttons: dict[str, ActionButton] = {}
|
||||
self._button_order: list[str] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the action bar - buttons added dynamically."""
|
||||
# Initially empty, buttons added via register_button
|
||||
yield from []
|
||||
|
||||
def register_button(
|
||||
self,
|
||||
button: ActionButton,
|
||||
*,
|
||||
position: int | None = None,
|
||||
) -> None:
|
||||
"""Register an action button.
|
||||
|
||||
Args:
|
||||
button: The button definition
|
||||
position: Optional position in the bar (default: end)
|
||||
"""
|
||||
self._buttons[button.id] = button
|
||||
|
||||
if position is not None:
|
||||
self._button_order.insert(position, button.id)
|
||||
else:
|
||||
self._button_order.append(button.id)
|
||||
|
||||
# Create and mount the button widget
|
||||
btn_widget = ActionBarButton(
|
||||
icon=button.icon,
|
||||
tooltip=button.tooltip,
|
||||
action_id=button.id,
|
||||
id=f"action-{button.id}",
|
||||
disabled=not button.enabled,
|
||||
)
|
||||
|
||||
if not button.visible:
|
||||
btn_widget.display = False
|
||||
|
||||
self.mount(btn_widget)
|
||||
|
||||
def unregister_button(self, button_id: str) -> None:
|
||||
"""Remove an action button.
|
||||
|
||||
Args:
|
||||
button_id: ID of the button to remove
|
||||
"""
|
||||
if button_id in self._buttons:
|
||||
del self._buttons[button_id]
|
||||
self._button_order.remove(button_id)
|
||||
|
||||
try:
|
||||
btn = self.query_one(f"#action-{button_id}", ActionBarButton)
|
||||
btn.remove()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_button_visible(self, button_id: str, visible: bool) -> None:
|
||||
"""Show or hide a button.
|
||||
|
||||
Args:
|
||||
button_id: ID of the button
|
||||
visible: Whether to show the button
|
||||
"""
|
||||
if button_id in self._buttons:
|
||||
self._buttons[button_id].visible = visible
|
||||
try:
|
||||
btn = self.query_one(f"#action-{button_id}", ActionBarButton)
|
||||
btn.display = visible
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_button_enabled(self, button_id: str, enabled: bool) -> None:
|
||||
"""Enable or disable a button.
|
||||
|
||||
Args:
|
||||
button_id: ID of the button
|
||||
enabled: Whether to enable the button
|
||||
"""
|
||||
if button_id in self._buttons:
|
||||
self._buttons[button_id].enabled = enabled
|
||||
try:
|
||||
btn = self.query_one(f"#action-{button_id}", ActionBarButton)
|
||||
btn.disabled = not enabled
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_button_active(self, button_id: str, active: bool) -> None:
|
||||
"""Set a button's active state (visual highlight).
|
||||
|
||||
Args:
|
||||
button_id: ID of the button
|
||||
active: Whether button should appear active
|
||||
"""
|
||||
try:
|
||||
btn = self.query_one(f"#action-{button_id}", ActionBarButton)
|
||||
if active:
|
||||
btn.add_class("-active")
|
||||
else:
|
||||
btn.remove_class("-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_button_icon(self, button_id: str, icon: str) -> None:
|
||||
"""Update a button's icon.
|
||||
|
||||
Args:
|
||||
button_id: ID of the button
|
||||
icon: New icon to display
|
||||
"""
|
||||
if button_id in self._buttons:
|
||||
self._buttons[button_id].icon = icon
|
||||
try:
|
||||
btn = self.query_one(f"#action-{button_id}", ActionBarButton)
|
||||
btn.label = icon
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_separator(self) -> None:
|
||||
"""Add a visual separator."""
|
||||
sep = Static("│", classes="action-separator")
|
||||
self.mount(sep)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press."""
|
||||
if isinstance(event.button, ActionBarButton):
|
||||
button_id = event.button.action_id
|
||||
|
||||
# Call the callback if defined
|
||||
if button_id in self._buttons:
|
||||
button = self._buttons[button_id]
|
||||
if button.callback:
|
||||
button.callback()
|
||||
|
||||
# Also emit a message for flexible handling
|
||||
self.post_message(self.ButtonPressed(button_id))
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all buttons."""
|
||||
for btn_id in list(self._buttons.keys()):
|
||||
self.unregister_button(btn_id)
|
||||
|
||||
# Also remove any separators
|
||||
for sep in self.query(".action-separator"):
|
||||
sep.remove()
|
||||
|
||||
|
||||
# Standard action button definitions for common operations
|
||||
STANDARD_BUTTONS = {
|
||||
"save": ActionButton(
|
||||
id="save",
|
||||
icon="💾",
|
||||
tooltip="Save (Alt+S)",
|
||||
),
|
||||
"close": ActionButton(
|
||||
id="close",
|
||||
icon="✕",
|
||||
tooltip="Close",
|
||||
),
|
||||
"minimize": ActionButton(
|
||||
id="minimize",
|
||||
icon="▽",
|
||||
tooltip="Minimize",
|
||||
),
|
||||
"maximize": ActionButton(
|
||||
id="maximize",
|
||||
icon="□",
|
||||
tooltip="Maximize",
|
||||
),
|
||||
"restore": ActionButton(
|
||||
id="restore",
|
||||
icon="❐",
|
||||
tooltip="Restore",
|
||||
visible=False, # Hidden by default, shown when maximized
|
||||
),
|
||||
"split": ActionButton(
|
||||
id="split",
|
||||
icon="⊞",
|
||||
tooltip="Split terminal",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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: auto;
|
||||
min-width: 12;
|
||||
}
|
||||
|
||||
BranchStatus .toggle-icon {
|
||||
width: auto;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
BranchStatus .git-stats {
|
||||
width: 1fr;
|
||||
text-align: right;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
BranchStatus .staged-count {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
BranchStatus .unstaged-count {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
BranchStatus .popout {
|
||||
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 #branch-list {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
}
|
||||
|
||||
BranchStatus .popout-actions {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
BranchStatus .popout-actions Button {
|
||||
width: 1fr;
|
||||
min-width: 10;
|
||||
height: 3;
|
||||
margin: 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,
|
||||
staged: int = 0,
|
||||
unstaged: int = 0,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._current = current_branch
|
||||
self._branches = branches or []
|
||||
self._popout_visible = False
|
||||
self._staged = staged
|
||||
self._unstaged = unstaged
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="status-bar"):
|
||||
yield Static("⎇", classes="branch-icon")
|
||||
yield Static(self._current, classes="branch-name", id="branch-name")
|
||||
yield Static("▾", classes="toggle-icon")
|
||||
yield Static(self._format_stats(), classes="git-stats", id="git-stats")
|
||||
|
||||
with Vertical(classes="popout", id="branch-popout"):
|
||||
yield Label("Recent branches", classes="popout-header")
|
||||
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 _format_stats(self) -> str:
|
||||
"""Format git stats display."""
|
||||
parts = []
|
||||
if self._staged > 0:
|
||||
parts.append(f"[staged-count]staged: {self._staged}[/]")
|
||||
if self._unstaged > 0:
|
||||
parts.append(f"[unstaged-count]unstaged: {self._unstaged}[/]")
|
||||
return " · ".join(parts) if parts else ""
|
||||
|
||||
def update_stats(self, staged: int, unstaged: int) -> None:
|
||||
"""Update staged/unstaged counts."""
|
||||
self._staged = staged
|
||||
self._unstaged = unstaged
|
||||
try:
|
||||
stats = self.query_one("#git-stats", Static)
|
||||
stats.update(self._format_stats())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
|
||||
"""Update branches list."""
|
||||
self._branches = branches # type: ignore
|
||||
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 = str(label.renderable).lstrip("●").lstrip("○").lstrip()
|
||||
self.post_message(self.BranchChangeRequested(branch))
|
||||
self.toggle_popout()
|
||||
elif event.button.id == "btn-new":
|
||||
self.post_message(self.NewBranchRequested())
|
||||
self.toggle_popout()
|
||||
@@ -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(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(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(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))
|
||||
@@ -0,0 +1,283 @@
|
||||
"""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
|
||||
|
||||
# TypeScript highlight query (basic)
|
||||
TYPESCRIPT_HIGHLIGHTS = """
|
||||
(comment) @comment
|
||||
(string) @string
|
||||
(number) @number
|
||||
(identifier) @variable
|
||||
(type_identifier) @type
|
||||
(property_identifier) @property
|
||||
(function_declaration name: (identifier) @function)
|
||||
(method_definition name: (property_identifier) @function.method)
|
||||
(call_expression function: (identifier) @function.call)
|
||||
(import_statement) @keyword
|
||||
(export_statement) @keyword
|
||||
["const" "let" "var" "function" "class" "interface" "type" "enum"
|
||||
"if" "else" "for" "while" "do" "switch" "case" "default" "break"
|
||||
"continue" "return" "throw" "try" "catch" "finally" "new" "delete"
|
||||
"typeof" "instanceof" "in" "of" "async" "await" "yield" "import"
|
||||
"export" "from" "as" "extends" "implements" "static" "public"
|
||||
"private" "protected" "readonly" "abstract" "declare" "namespace"
|
||||
"module" "require"] @keyword
|
||||
["=>" "=" "+" "-" "*" "/" "%" "**" "++" "--" "==" "!=" "===" "!=="
|
||||
"<" ">" "<=" ">=" "&&" "||" "!" "?" ":" "?." "??" "&" "|" "^" "~"
|
||||
"<<" ">>" ">>>"] @operator
|
||||
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
|
||||
["," "." ";" ":"] @punctuation.delimiter
|
||||
(true) @constant.builtin
|
||||
(false) @constant.builtin
|
||||
(null) @constant.builtin
|
||||
(undefined) @constant.builtin
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
self._typescript_registered = False
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Register additional languages on mount."""
|
||||
self._register_typescript()
|
||||
|
||||
def _register_typescript(self) -> None:
|
||||
"""Register TypeScript and TSX languages if available."""
|
||||
if self._typescript_registered:
|
||||
return
|
||||
|
||||
try:
|
||||
import tree_sitter_typescript as tst
|
||||
|
||||
textarea = self.query_one(TextArea)
|
||||
|
||||
# Register TypeScript
|
||||
try:
|
||||
textarea.register_language(
|
||||
"typescript",
|
||||
tst.language_typescript(),
|
||||
TYPESCRIPT_HIGHLIGHTS,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Register TSX (reuse TypeScript highlights)
|
||||
try:
|
||||
textarea.register_language(
|
||||
"tsx",
|
||||
tst.language_tsx(),
|
||||
TYPESCRIPT_HIGHLIGHTS,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._typescript_registered = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
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,
|
||||
show_line_numbers=True,
|
||||
)
|
||||
yield Static(
|
||||
self._get_status_text(),
|
||||
classes="editor-status",
|
||||
)
|
||||
else:
|
||||
yield Static("No file open", classes="file-tab")
|
||||
yield TextArea(show_line_numbers=True)
|
||||
yield Static("", classes="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(TextArea)
|
||||
|
||||
# Register TypeScript if needed
|
||||
if buffer.language in ("typescript", "tsx"):
|
||||
self._register_typescript()
|
||||
|
||||
textarea.load_text(buffer.content)
|
||||
|
||||
# Set language (will use JavaScript as fallback for TS if registration failed)
|
||||
language = buffer.language
|
||||
if language in ("typescript", "tsx") and language not in textarea.available_languages:
|
||||
language = "javascript" # Fallback to JS highlighting
|
||||
|
||||
textarea.language = 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(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
|
||||
|
||||
try:
|
||||
content = FileService.read_file(path)
|
||||
except OSError as e:
|
||||
# Show error in editor
|
||||
content = f"# Error loading file\n# {e}"
|
||||
|
||||
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(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
|
||||
@@ -0,0 +1,143 @@
|
||||
"""File browser component using DirectoryTree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.text import Text
|
||||
from textual.message import Message
|
||||
from textual.widgets import DirectoryTree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from rich.style import Style
|
||||
from textual.widgets._directory_tree import DirEntry
|
||||
from textual.widgets._tree import TreeNode
|
||||
|
||||
|
||||
# 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
|
||||
is_dimmed = path.name.startswith(".") or path.name in self.DIMMED_PATHS
|
||||
|
||||
if path.is_dir():
|
||||
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED
|
||||
icon_style = "dim cyan" if is_dimmed else "bold cyan"
|
||||
else:
|
||||
icon = ICON_FILE
|
||||
icon_style = "dim"
|
||||
|
||||
label = Text()
|
||||
label.append(f"{icon} ", style=icon_style)
|
||||
label.append(path.name, style="dim" if is_dimmed else style)
|
||||
return label
|
||||
|
||||
# Directories that clutter the tree and are never needed in the IDE
|
||||
HIDDEN_DIRS = {
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
".git",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".tox",
|
||||
}
|
||||
|
||||
# Directories/files shown but dimmed (less important)
|
||||
DIMMED_PATHS = {
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
}
|
||||
|
||||
def filter_paths(self, paths: list[Path]) -> list[Path]:
|
||||
"""Filter out noisy directories but show hidden files."""
|
||||
return [
|
||||
p for p in paths if p.name not in self.HIDDEN_DIRS and not p.name.endswith(".egg-info")
|
||||
]
|
||||
|
||||
def refresh_tree(self) -> None:
|
||||
"""Refresh the directory tree."""
|
||||
self.reload()
|
||||
|
||||
def highlight_path(self, path: Path) -> None:
|
||||
"""Highlight a path in the tree (expand parents and scroll to it).
|
||||
|
||||
Used to show which file Claude is working with.
|
||||
"""
|
||||
# Normalize the path
|
||||
try:
|
||||
path = path.resolve()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Find and select the node
|
||||
def find_node(node, target_path):
|
||||
"""Recursively find a node by path."""
|
||||
if node.data and hasattr(node.data, "path"):
|
||||
if node.data.path.resolve() == target_path:
|
||||
return node
|
||||
for child in node.children:
|
||||
result = find_node(child, target_path)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
target_node = find_node(self.root, path)
|
||||
if target_node:
|
||||
# Expand all parent nodes
|
||||
parent = target_node.parent
|
||||
while parent:
|
||||
parent.expand()
|
||||
parent = parent.parent
|
||||
|
||||
# Select and scroll to the node
|
||||
self.select_node(target_node)
|
||||
self.scroll_to_node(target_node)
|
||||
|
||||
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
||||
"""Re-emit file selection as FilesView.FileSelected."""
|
||||
event.stop()
|
||||
self.post_message(self.FileSelected(node=event.node, path=event.path))
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Git changes view 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 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",
|
||||
}
|
||||
|
||||
# Map status to Rich color styles
|
||||
STATUS_COLORS = {
|
||||
ChangeStatus.ADDED: "green",
|
||||
ChangeStatus.MODIFIED: "yellow",
|
||||
ChangeStatus.DELETED: "red",
|
||||
ChangeStatus.RENAMED: "cyan",
|
||||
ChangeStatus.UNTRACKED: "magenta",
|
||||
ChangeStatus.COPIED: "cyan",
|
||||
ChangeStatus.UNMERGED: "red bold",
|
||||
ChangeStatus.IGNORED: "dim",
|
||||
}
|
||||
|
||||
def __init__(self, change: GitChange) -> None:
|
||||
super().__init__()
|
||||
self.change = change
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
from rich.markup import escape
|
||||
|
||||
icon = self.STATUS_ICONS.get(self.change.status, "?")
|
||||
color = self.STATUS_COLORS.get(self.change.status, "white")
|
||||
safe_path = escape(self.change.path)
|
||||
yield Static(
|
||||
f"[{color}]{icon}[/] {safe_path}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class GitChangesView(Vertical):
|
||||
"""View for staged and unstaged git changes."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GitChangesView {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
GitChangesView .section-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
GitChangesView ListView {
|
||||
height: 1fr;
|
||||
min-height: 3;
|
||||
scrollbar-size: 1 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
GitChangesView #staged-list {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GitChangesView #staged-list ListItem:even {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GitChangesView #staged-list ListItem:odd {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
GitChangesView #unstaged-list {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
GitChangesView #unstaged-list ListItem:even {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
GitChangesView #unstaged-list ListItem:odd {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GitChangesView ListItem {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
GitChangesView ListItem Static {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
GitChangesView .action-bar {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
background: $surface;
|
||||
border-top: solid $primary;
|
||||
}
|
||||
|
||||
GitChangesView .action-bar Button {
|
||||
min-width: 6;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileClicked(Message):
|
||||
"""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__()
|
||||
|
||||
class ClaudeActionRequested(Message):
|
||||
"""Emitted when a Claude git action is requested."""
|
||||
|
||||
def __init__(self, action: str) -> None:
|
||||
self.action = action # "commit", "stash", "pull", "push"
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
staged: list[GitChange] | None = None,
|
||||
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",
|
||||
)
|
||||
with Horizontal(classes="action-bar"):
|
||||
yield Button("Commit", id="btn-commit", variant="primary")
|
||||
yield Button("Stash", id="btn-stash")
|
||||
yield Button("Pull", id="btn-pull")
|
||||
yield Button("Push", id="btn-push")
|
||||
|
||||
def update_changes(
|
||||
self,
|
||||
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))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle action button clicks."""
|
||||
button_id = event.button.id
|
||||
action_map = {
|
||||
"btn-commit": "commit",
|
||||
"btn-stash": "stash",
|
||||
"btn-pull": "pull",
|
||||
"btn-push": "push",
|
||||
}
|
||||
if button_id in action_map:
|
||||
self.post_message(self.ClaudeActionRequested(action_map[button_id]))
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Git graph visualization component."""
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widgets import ListView, Static
|
||||
|
||||
from clide.models.git import GitCommit
|
||||
from clide.widgets.components.tile_list import TileItem, TileListView
|
||||
|
||||
|
||||
class CommitItem(TileItem):
|
||||
"""A single commit item displayed as a tile."""
|
||||
|
||||
COMMIT = "●"
|
||||
MERGE = "◆"
|
||||
|
||||
def __init__(self, commit: GitCommit) -> None:
|
||||
super().__init__()
|
||||
self.commit = commit
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
symbol = self.MERGE if self.commit.is_merge else self.COMMIT
|
||||
|
||||
# Format refs (branches, tags)
|
||||
refs_str = ""
|
||||
if self.commit.refs:
|
||||
refs = ", ".join(self.commit.refs)
|
||||
refs_str = f" [bold cyan]({escape(refs)})[/]"
|
||||
|
||||
# Truncate message
|
||||
message = self.commit.message[:60]
|
||||
if len(self.commit.message) > 60:
|
||||
message += "..."
|
||||
|
||||
# Multi-line tile format
|
||||
yield Static(
|
||||
f"[bold yellow]{symbol}[/] [bold]{escape(message)}[/]{refs_str}\n"
|
||||
f" [dim]{self.commit.short_hash} · {escape(self.commit.author)} · {self.commit.date}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class GitGraphView(TileListView):
|
||||
"""View for git commit graph visualization."""
|
||||
|
||||
class CommitSelected(Message):
|
||||
"""Emitted when a commit is selected."""
|
||||
|
||||
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 ListView(
|
||||
*[CommitItem(c) for c in self._commits],
|
||||
id="commit-list",
|
||||
)
|
||||
|
||||
def update_commits(self, commits: list[GitCommit]) -> None:
|
||||
"""Update the commit list."""
|
||||
self._commits = commits
|
||||
try:
|
||||
commit_list = self.query_one("#commit-list", ListView)
|
||||
commit_list.clear()
|
||||
for commit in commits:
|
||||
commit_list.append(CommitItem(commit))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle commit selection."""
|
||||
if isinstance(event.item, CommitItem):
|
||||
self.post_message(self.CommitSelected(event.item.commit))
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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: 1fr;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
JiraView .jira-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
JiraView Markdown {
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
JiraView .jira-actions {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
border-top: solid $primary;
|
||||
}
|
||||
|
||||
JiraView .disabled-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
background: $panel;
|
||||
}
|
||||
"""
|
||||
|
||||
class RefreshRequested(Message):
|
||||
"""Emitted when refresh is requested."""
|
||||
|
||||
pass
|
||||
|
||||
class IssueClicked(Message):
|
||||
"""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())
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Problems view component for linter errors."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widgets import ListView, Static
|
||||
|
||||
from clide.models.problems import Problem
|
||||
from clide.widgets.components.tile_list import TileItem, TileListView
|
||||
|
||||
|
||||
class ProblemItem(TileItem):
|
||||
"""A single problem item displayed as a tile."""
|
||||
|
||||
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
|
||||
# Show just filename
|
||||
filename = (
|
||||
self.problem.file_path.name
|
||||
if hasattr(self.problem.file_path, "name")
|
||||
else str(self.problem.file_path).split("/")[-1]
|
||||
)
|
||||
safe_message = escape(self.problem.message)
|
||||
|
||||
yield Static(
|
||||
f"[{severity_class}]{icon}[/] [{severity_class}]{safe_message}[/]\n"
|
||||
f" [dim]{filename}:{self.problem.line}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class ProblemsView(TileListView):
|
||||
"""View for linter problems/diagnostics."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ProblemsView {
|
||||
height: 1fr;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ProblemsView .problems-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Reusable PTY-based terminal emulator widget using pyte."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import codecs
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import struct
|
||||
import termios
|
||||
import time
|
||||
|
||||
from rich.text import Text
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
from clide.services.settings_service import get_settings_service
|
||||
|
||||
# Use vendored pyte with diagnostic logging support
|
||||
from clide.vendor import pyte
|
||||
|
||||
|
||||
def _setup_terminal_debug_logging() -> None:
|
||||
"""Set up terminal debug logging if enabled in settings."""
|
||||
settings = get_settings_service()
|
||||
if not settings.get("terminal_debug", False):
|
||||
return
|
||||
|
||||
# Create log file in settings directory
|
||||
log_path = settings.settings_dir / "terminal_debug.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open log file (append mode)
|
||||
log_file = open(log_path, "a", encoding="utf-8")
|
||||
|
||||
def debug_logger(message: str) -> None:
|
||||
"""Log debug message with timestamp."""
|
||||
import datetime
|
||||
|
||||
timestamp = datetime.datetime.now().isoformat()
|
||||
log_file.write(f"[{timestamp}] {message}\n")
|
||||
log_file.flush()
|
||||
|
||||
# Set up pyte debug logging
|
||||
pyte.set_debug_logger(debug_logger)
|
||||
|
||||
|
||||
class TerminalDisplay(Widget, can_focus=True):
|
||||
"""A terminal emulator widget using pyte."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TerminalDisplay {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
}
|
||||
"""
|
||||
|
||||
# Internal padding (rendered as part of terminal content, uses terminal bg)
|
||||
PADDING_LEFT = 1
|
||||
PADDING_RIGHT = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
|
||||
# Set up debug logging before creating pyte objects
|
||||
_setup_terminal_debug_logging()
|
||||
|
||||
self._screen = pyte.HistoryScreen(cols, rows, history=1000)
|
||||
self._screen.set_mode(pyte.modes.LNM) # Line feed mode
|
||||
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
|
||||
self._pending_start: tuple[str, str] | None = None
|
||||
# Incremental UTF-8 decoder to handle partial sequences at buffer boundaries
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
# Scroll offset for viewing history (0 = at bottom/current, positive = scrolled up)
|
||||
self._scroll_offset: int = 0
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize terminal size from widget dimensions."""
|
||||
# Get actual widget size, accounting for internal padding
|
||||
size = self.size
|
||||
if size.width > 0 and size.height > 0:
|
||||
self._cols = max(size.width - self.PADDING_LEFT - self.PADDING_RIGHT, 20)
|
||||
self._rows = size.height
|
||||
self._screen.resize(self._rows, self._cols)
|
||||
|
||||
# If start was called before mount, do it now
|
||||
if self._pending_start:
|
||||
command, cwd = self._pending_start
|
||||
self._pending_start = None
|
||||
self._do_start(command, cwd)
|
||||
|
||||
def on_resize(self, event) -> None:
|
||||
"""Handle terminal resize."""
|
||||
# Get new size from widget, accounting for internal padding
|
||||
new_cols = max(self.size.width - self.PADDING_LEFT - self.PADDING_RIGHT, 20)
|
||||
new_rows = max(self.size.height, 5)
|
||||
|
||||
if new_cols != self._cols or new_rows != self._rows:
|
||||
old_cols = self._cols
|
||||
|
||||
# Update dimensions first
|
||||
self._cols = new_cols
|
||||
self._rows = new_rows
|
||||
|
||||
# Resize pyte screen - this will preserve content where possible
|
||||
self._screen.resize(new_rows, new_cols)
|
||||
|
||||
# If screen got wider, clear the new columns to avoid stale data
|
||||
# pyte's resize should handle this, but let's be defensive
|
||||
if new_cols > old_cols:
|
||||
for y in range(new_rows):
|
||||
line = self._screen.buffer[y]
|
||||
for x in range(old_cols, new_cols):
|
||||
# Clear any stale data in new columns
|
||||
line[x] = pyte.screens.Char(" ")
|
||||
|
||||
# Update PTY size if running - child process will redraw
|
||||
if self._master_fd is not None:
|
||||
self._set_pty_size(self._master_fd, new_rows, new_cols)
|
||||
|
||||
# Force a full refresh
|
||||
self.refresh()
|
||||
|
||||
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
|
||||
"""Set the PTY window size and notify the child process."""
|
||||
try:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
# Send SIGWINCH to notify the child process of resize
|
||||
if self._pid is not None:
|
||||
import signal
|
||||
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGWINCH)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def start(self, command: str, cwd: str) -> None:
|
||||
"""Start a process in the terminal."""
|
||||
# If not mounted yet, defer start
|
||||
if not self.is_mounted:
|
||||
self._pending_start = (command, cwd)
|
||||
return
|
||||
|
||||
self._do_start(command, cwd)
|
||||
|
||||
def _do_start(self, command: str, cwd: str) -> None:
|
||||
"""Actually start the process in the terminal."""
|
||||
# Reset decoder state for new process
|
||||
self._decoder.reset()
|
||||
|
||||
# Get current widget size, accounting for internal padding
|
||||
size = self.size
|
||||
if size.width > 0 and size.height > 0:
|
||||
self._cols = max(size.width - self.PADDING_LEFT - self.PADDING_RIGHT, 20)
|
||||
self._rows = size.height
|
||||
self._screen.resize(self._rows, self._cols)
|
||||
|
||||
# Fork a PTY
|
||||
pid, master_fd = pty.fork()
|
||||
|
||||
if pid == 0:
|
||||
# Child process
|
||||
os.chdir(cwd)
|
||||
|
||||
# Strip Zellij env vars so child processes don't think
|
||||
# they're inside a Zellij session (Clide may run inside
|
||||
# Zellij for web deployment session persistence)
|
||||
for key in list(os.environ):
|
||||
if key.startswith("ZELLIJ"):
|
||||
del os.environ[key]
|
||||
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
os.environ["COLORTERM"] = "truecolor"
|
||||
os.environ["COLUMNS"] = str(self._cols)
|
||||
os.environ["LINES"] = str(self._rows)
|
||||
# Launch through a login shell so profile scripts are sourced
|
||||
# and PATH includes locations like ~/.local/bin where Claude's
|
||||
# native binary may be installed.
|
||||
# Use /etc/passwd shell, not $SHELL (which Zellij may override)
|
||||
import pwd
|
||||
|
||||
try:
|
||||
shell = pwd.getpwuid(os.getuid()).pw_shell
|
||||
except KeyError:
|
||||
shell = "/bin/bash"
|
||||
os.execvp(shell, [shell, "-l", "-c", 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)
|
||||
|
||||
# Regex to filter escape sequences that pyte doesn't handle
|
||||
# Kitty keyboard protocol, bracketed paste mode queries, etc.
|
||||
_UNSUPPORTED_ESCAPES = re.compile(
|
||||
r"\x1b\[[\=\>\<][0-9;]*[a-zA-Z]" # Kitty keyboard protocol (=, >, or < prefix)
|
||||
r"|\x1b\[\?[0-9;]*u" # Kitty keyboard query
|
||||
r"|\x1b\[\?[0-9;]*c" # Device attributes query
|
||||
r"|\x1b\[>[0-9;]*c" # Secondary device attributes
|
||||
r"|\x1b\]\d+;[^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences (title, etc.)
|
||||
r"|\x1b\[\?2026[hl]" # Synchronized update mode (not used by pyte)
|
||||
)
|
||||
|
||||
def _filter_unsupported_escapes(self, data: str) -> str:
|
||||
"""Filter out escape sequences that pyte doesn't handle."""
|
||||
return self._UNSUPPORTED_ESCAPES.sub("", data)
|
||||
|
||||
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
|
||||
|
||||
# Use incremental decoder to handle partial UTF-8 sequences
|
||||
# at buffer boundaries (prevents box-drawing chars getting corrupted)
|
||||
text = self._decoder.decode(data)
|
||||
if not text:
|
||||
continue # Still waiting for more bytes to complete a sequence
|
||||
text = self._filter_unsupported_escapes(text)
|
||||
|
||||
# Feed data to pyte
|
||||
self._stream.feed(text)
|
||||
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
|
||||
|
||||
# ANSI 256-color palette (standard 16 colors, softened for dark themes)
|
||||
ANSI_COLORS = {
|
||||
"black": "#282c34",
|
||||
"red": "#e06c75",
|
||||
"green": "#98c379",
|
||||
"brown": "#e5c07b",
|
||||
"yellow": "#e5c07b",
|
||||
"blue": "#61afef",
|
||||
"magenta": "#c678dd",
|
||||
"cyan": "#56b6c2",
|
||||
"white": "#abb2bf",
|
||||
"brightblack": "#5c6370",
|
||||
"brightred": "#e06c75",
|
||||
"brightgreen": "#98c379",
|
||||
"brightbrown": "#e5c07b",
|
||||
"brightyellow": "#e5c07b",
|
||||
"brightblue": "#61afef",
|
||||
"brightmagenta": "#c678dd",
|
||||
"brightcyan": "#56b6c2",
|
||||
"brightwhite": "#ffffff",
|
||||
}
|
||||
|
||||
def _convert_color(self, color: str, is_bg: bool = False) -> str | None:
|
||||
"""Convert pyte color to Rich color string."""
|
||||
if not color or color == "default":
|
||||
return None
|
||||
|
||||
# Handle hex colors (with or without # prefix)
|
||||
if color.startswith("#"):
|
||||
return color
|
||||
|
||||
# Check if it's a hex color without # (pyte returns "ff0000" not "#ff0000")
|
||||
if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color):
|
||||
return f"#{color}"
|
||||
|
||||
# Handle named ANSI colors
|
||||
color_lower = color.lower()
|
||||
if color_lower in self.ANSI_COLORS:
|
||||
return self.ANSI_COLORS[color_lower]
|
||||
|
||||
# Handle 256-color palette (numeric)
|
||||
try:
|
||||
num = int(color)
|
||||
if 0 <= num <= 255:
|
||||
return f"color({num})"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fallback: try to use the color name directly
|
||||
return color
|
||||
|
||||
def _get_line_at(self, y: int):
|
||||
"""Get the line at display position y, accounting for scroll offset.
|
||||
|
||||
When scrolled, we show lines from history mixed with current buffer.
|
||||
scroll_offset=0 means showing current screen.
|
||||
scroll_offset=N means the top of display shows N lines back in history.
|
||||
"""
|
||||
history = self._screen.history.top
|
||||
history_len = len(history)
|
||||
|
||||
if self._scroll_offset == 0:
|
||||
# Not scrolled - show current buffer
|
||||
return self._screen.buffer[y]
|
||||
|
||||
# Calculate which line to show
|
||||
# Display line 0 should show history[history_len - scroll_offset]
|
||||
# Display line N should show history[history_len - scroll_offset + N]
|
||||
# If that index >= history_len, we're into the current buffer
|
||||
|
||||
history_index = history_len - self._scroll_offset + y
|
||||
|
||||
if history_index < 0:
|
||||
# Before start of history - return blank
|
||||
return None
|
||||
elif history_index < history_len:
|
||||
# In history
|
||||
return history[history_index]
|
||||
else:
|
||||
# In current buffer
|
||||
buffer_index = history_index - history_len
|
||||
if buffer_index < self._rows:
|
||||
return self._screen.buffer[buffer_index]
|
||||
return None
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of the terminal."""
|
||||
# Screen buffer dimensions (what pyte has)
|
||||
screen_cols = self._cols
|
||||
screen_rows = self._rows
|
||||
|
||||
# Widget display dimensions (what we need to output)
|
||||
output_width = (
|
||||
self.size.width
|
||||
if self.size.width > 0
|
||||
else screen_cols + self.PADDING_LEFT + self.PADDING_RIGHT
|
||||
)
|
||||
|
||||
if y >= screen_rows:
|
||||
return Strip.blank(output_width)
|
||||
|
||||
# Get the line to render, accounting for scroll offset
|
||||
line = self._get_line_at(y)
|
||||
if line is None:
|
||||
return Strip.blank(output_width)
|
||||
|
||||
text = Text()
|
||||
|
||||
# Add left padding (uses terminal background)
|
||||
text.append(" " * self.PADDING_LEFT)
|
||||
|
||||
# Only access indices within the screen buffer
|
||||
cols_to_render = min(screen_cols, output_width - self.PADDING_LEFT - self.PADDING_RIGHT)
|
||||
|
||||
# Debug logging for render pipeline
|
||||
debug_logger = pyte.get_debug_logger()
|
||||
log_this_line = False
|
||||
|
||||
for x in range(cols_to_render):
|
||||
char = line[x]
|
||||
char_data = char.data if char.data else " "
|
||||
|
||||
# Check for box-drawing and other potentially problematic characters
|
||||
if len(char_data) == 1:
|
||||
code = ord(char_data)
|
||||
# Log box-drawing characters (U+2500-U+257F)
|
||||
if 0x2500 <= code <= 0x257F:
|
||||
log_this_line = True
|
||||
if debug_logger:
|
||||
debug_logger(
|
||||
f"RENDER y={y} x={x}: box-drawing U+{code:04X} char='{char_data}'"
|
||||
)
|
||||
|
||||
# Handle characters that may not render correctly
|
||||
if len(char_data) == 1:
|
||||
code = ord(char_data)
|
||||
# Control characters (except space)
|
||||
if code < 32 and code != 0 or 127 <= code <= 159:
|
||||
char_data = " "
|
||||
# Braille patterns (U+2800-U+28FF) - used for spinners
|
||||
# Replace with simple ASCII spinner chars or spaces
|
||||
elif 0x2800 <= code <= 0x28FF:
|
||||
# Map braille spinner to simple dots
|
||||
char_data = "·"
|
||||
|
||||
# Build style from pyte character attributes
|
||||
style_parts = []
|
||||
|
||||
# Foreground color
|
||||
fg = self._convert_color(char.fg)
|
||||
if fg:
|
||||
style_parts.append(fg)
|
||||
|
||||
# Background color
|
||||
bg = self._convert_color(char.bg, is_bg=True)
|
||||
if bg:
|
||||
style_parts.append(f"on {bg}")
|
||||
|
||||
# Text attributes
|
||||
if char.bold:
|
||||
style_parts.append("bold")
|
||||
if char.italics:
|
||||
style_parts.append("italic")
|
||||
if char.underscore:
|
||||
style_parts.append("underline")
|
||||
if char.strikethrough:
|
||||
style_parts.append("strike")
|
||||
if char.reverse:
|
||||
style_parts.append("reverse")
|
||||
|
||||
style = " ".join(style_parts) if style_parts else None
|
||||
text.append(char_data, style=style)
|
||||
|
||||
# Pad with spaces to fill remaining width (includes right padding)
|
||||
content_width = self.PADDING_LEFT + cols_to_render
|
||||
if output_width > content_width:
|
||||
text.append(" " * (output_width - content_width))
|
||||
|
||||
# Debug: log the Rich Text content before rendering
|
||||
if log_this_line and debug_logger:
|
||||
debug_logger(f"RENDER y={y}: Rich Text plain='{text.plain[:80]}...'")
|
||||
|
||||
# Render text to segments for Strip
|
||||
segments = list(text.render(self.app.console))
|
||||
|
||||
# Debug: log segments if we had box-drawing chars
|
||||
if log_this_line and debug_logger:
|
||||
for i, seg in enumerate(segments[:10]): # First 10 segments
|
||||
seg_text = seg.text if hasattr(seg, "text") else str(seg)
|
||||
if len(seg_text) <= 5:
|
||||
debug_logger(f"RENDER y={y} seg[{i}]: '{seg_text}' (repr: {repr(seg_text)})")
|
||||
|
||||
strip = Strip(segments)
|
||||
|
||||
# Ensure strip is exactly the output width
|
||||
if strip.cell_length != output_width:
|
||||
strip = strip.crop_extend(0, output_width, None)
|
||||
|
||||
return strip
|
||||
|
||||
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.key == "shift+pageup":
|
||||
# Scroll up in history
|
||||
self._scroll_up(self._rows // 2)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "shift+pagedown":
|
||||
# Scroll down in history
|
||||
self._scroll_down(self._rows // 2)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "shift+home":
|
||||
# Scroll to top of history
|
||||
self._scroll_to_top()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "shift+end":
|
||||
# Scroll to bottom (current)
|
||||
self._scroll_to_bottom()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.character and len(event.character) == 1:
|
||||
# Any typing scrolls to bottom
|
||||
self._scroll_to_bottom()
|
||||
self.send(event.character)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def on_mouse_scroll_up(self, event) -> None:
|
||||
"""Handle mouse scroll up (view older content)."""
|
||||
self._scroll_up(3)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def on_mouse_scroll_down(self, event) -> None:
|
||||
"""Handle mouse scroll down (view newer content)."""
|
||||
self._scroll_down(3)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def _scroll_up(self, lines: int) -> None:
|
||||
"""Scroll up (back in history) by given number of lines."""
|
||||
max_scroll = len(self._screen.history.top)
|
||||
new_offset = min(self._scroll_offset + lines, max_scroll)
|
||||
if new_offset != self._scroll_offset:
|
||||
self._scroll_offset = new_offset
|
||||
self.refresh()
|
||||
|
||||
def _scroll_down(self, lines: int) -> None:
|
||||
"""Scroll down (forward toward current) by given number of lines."""
|
||||
new_offset = max(self._scroll_offset - lines, 0)
|
||||
if new_offset != self._scroll_offset:
|
||||
self._scroll_offset = new_offset
|
||||
self.refresh()
|
||||
|
||||
def _scroll_to_top(self) -> None:
|
||||
"""Scroll to the top of history."""
|
||||
max_scroll = len(self._screen.history.top)
|
||||
if self._scroll_offset != max_scroll:
|
||||
self._scroll_offset = max_scroll
|
||||
self.refresh()
|
||||
|
||||
def _scroll_to_bottom(self) -> None:
|
||||
"""Scroll to the bottom (current screen)."""
|
||||
if self._scroll_offset != 0:
|
||||
self._scroll_offset = 0
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def history_size(self) -> int:
|
||||
"""Get the number of lines in scrollback history."""
|
||||
return len(self._screen.history.top)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Terminal pane component with full PTY support."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static
|
||||
|
||||
from clide.widgets.components.terminal_display import TerminalDisplay
|
||||
|
||||
|
||||
class TerminalPane(Vertical):
|
||||
"""Full PTY terminal pane running an interactive shell."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TerminalPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
TerminalPane .terminal-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TerminalPane TerminalDisplay {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, cwd: Path | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cwd = cwd or Path.cwd()
|
||||
self._terminal: TerminalDisplay | None = None
|
||||
self._shell = self._find_shell()
|
||||
|
||||
@staticmethod
|
||||
def _find_shell() -> str:
|
||||
"""Find the user's real shell, ignoring Zellij overrides."""
|
||||
import pwd
|
||||
|
||||
# Get shell from /etc/passwd (most reliable)
|
||||
try:
|
||||
return pwd.getpwuid(os.getuid()).pw_shell
|
||||
except KeyError:
|
||||
pass
|
||||
# Fallback
|
||||
return "/bin/bash"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
|
||||
self._terminal = TerminalDisplay()
|
||||
yield self._terminal
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start the shell when mounted."""
|
||||
if self._terminal:
|
||||
self._terminal.start(self._shell, str(self._cwd))
|
||||
|
||||
@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 focus_terminal(self) -> None:
|
||||
"""Focus the terminal display."""
|
||||
if self._terminal:
|
||||
self._terminal.focus()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the terminal process."""
|
||||
if self._terminal:
|
||||
self._terminal.stop()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""Clean up PTY when widget is destroyed."""
|
||||
self.stop()
|
||||
|
||||
def restart(self) -> None:
|
||||
"""Restart the shell."""
|
||||
if self._terminal:
|
||||
self._terminal.stop()
|
||||
self._terminal.start(self._shell, str(self._cwd))
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reusable tile/card list components for consistent styling."""
|
||||
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import ListItem
|
||||
|
||||
|
||||
class TileItem(ListItem):
|
||||
"""A styled tile/card list item.
|
||||
|
||||
Subclass this and override compose() to create custom tiles.
|
||||
The tile will automatically get alternating background colors
|
||||
and hover effects from TileListView.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TileListView(Vertical):
|
||||
"""A list view with tile/card styling.
|
||||
|
||||
Provides:
|
||||
- Alternating row colors using theme colors
|
||||
- Hover effects
|
||||
- Consistent padding and spacing
|
||||
- Scrollbar styling
|
||||
|
||||
Usage:
|
||||
class MyTileItem(TileItem):
|
||||
def __init__(self, data: MyData) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"[bold]{self.data.title}[/]\\n[dim]{self.data.subtitle}[/]", markup=True)
|
||||
|
||||
class MyView(TileListView):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield ListView(*[MyTileItem(d) for d in self.items], id="my-list")
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TileListView {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TileListView ListView {
|
||||
height: 1fr;
|
||||
scrollbar-size: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TileListView ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
TileListView ListItem:even {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TileListView ListItem:odd {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
TileListView ListItem:hover {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
TileListView ListItem:focus {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
TileListView ListItem Static {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
TileListView .tile-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
TileListView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $secondary;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# CSS that can be included in other components for tile styling
|
||||
TILE_LIST_CSS = """
|
||||
/* Tile list styling - include in your component's DEFAULT_CSS */
|
||||
|
||||
ListView {
|
||||
height: 1fr;
|
||||
scrollbar-size: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
ListItem:even {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ListItem:odd {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
ListItem:hover {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ListItem:focus {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ListItem Static {
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,365 @@
|
||||
"""TODOs view component with sub-tabs for Project and Comment TODOs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, ListView, Static, TabbedContent, TabPane
|
||||
|
||||
from clide.models.todos import ProjectTodoItem, TodoItem, TodoType
|
||||
from clide.widgets.components.tile_list import TileItem, TileListView
|
||||
|
||||
# Template for new TODO.md files
|
||||
TODO_MD_TEMPLATE = """# TODO
|
||||
|
||||
<!--
|
||||
Clide Integration: This file is parsed by Clide's TODO panel.
|
||||
|
||||
Format:
|
||||
- Use ## for sections and ### for subsections
|
||||
- Use markdown checkboxes: - [ ] for open items, - [x] for completed
|
||||
- Items appear in the TODOs panel grouped by section
|
||||
- Click an item in Clide to jump to this file at that line
|
||||
|
||||
For AI agents: Add new items under the appropriate section using the
|
||||
checkbox format. Mark items as done with [x] when completed.
|
||||
-->
|
||||
|
||||
Project TODO items.
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] Add your first feature here
|
||||
- [ ] Another feature to implement
|
||||
|
||||
## Bugs
|
||||
|
||||
- [ ] Bug to fix
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Documentation to write
|
||||
"""
|
||||
|
||||
|
||||
class TodoListItem(TileItem):
|
||||
"""A single code TODO item displayed as a tile."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
super().__init__()
|
||||
self.item = item
|
||||
self.is_project_item = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.type_icon
|
||||
type_class = self.item.todo_type.value.lower()
|
||||
# Show just filename, not full path
|
||||
filename = (
|
||||
self.item.file_path.name
|
||||
if hasattr(self.item.file_path, "name")
|
||||
else str(self.item.file_path).split("/")[-1]
|
||||
)
|
||||
# Escape user content to prevent markup errors
|
||||
safe_text = escape(self.item.text)
|
||||
|
||||
yield Static(
|
||||
f"[{type_class}]{icon}[/] {safe_text}\n" f" [dim]{filename}:{self.item.line}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectTodoListItem(TileItem):
|
||||
"""A single project TODO item from TODO.md displayed as a tile."""
|
||||
|
||||
def __init__(self, item: ProjectTodoItem) -> None:
|
||||
super().__init__()
|
||||
self.item = item
|
||||
self.is_project_item = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.icon
|
||||
category = f"[dim]{escape(self.item.category)}[/] " if self.item.subsection else ""
|
||||
safe_text = escape(self.item.text)
|
||||
|
||||
text_part = f"[dim strike]{safe_text}[/]" if self.item.checked else safe_text
|
||||
|
||||
yield Static(
|
||||
f"[project]{icon}[/] {category}{text_part}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class TodosView(TileListView):
|
||||
"""View for TODO/FIXME comments and project TODOs with sub-tabs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TodosView {
|
||||
height: 1fr;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
TodosView TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView Tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
TodosView Tab {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
TodosView TabPane {
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
TodosView ContentSwitcher {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView #project-todos-list,
|
||||
TodosView #code-todos-list {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView ListItem {
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
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 .project { color: $accent; }
|
||||
|
||||
TodosView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
TodosView .create-todo-section {
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
TodosView .create-todo-message {
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
TodosView #create-todo-btn {
|
||||
width: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
class TodoClicked(Message):
|
||||
"""Emitted when a code TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class ProjectTodoClicked(Message):
|
||||
"""Emitted when a project TODO is clicked."""
|
||||
|
||||
def __init__(self, item: ProjectTodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class CreateTodoMdRequested(Message):
|
||||
"""Emitted when user wants to create a TODO.md file."""
|
||||
|
||||
pass
|
||||
|
||||
class TodoMdCreated(Message):
|
||||
"""Emitted when TODO.md has been created."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list[TodoItem] | None = None,
|
||||
project_items: list[ProjectTodoItem] | None = None,
|
||||
project_path: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._items = items or []
|
||||
self._project_items = project_items or []
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._has_todo_md = (self._project_path / "TODO.md").exists()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
# Calculate initial counts
|
||||
unchecked_project = sum(1 for i in self._project_items if not i.checked)
|
||||
code_count = len(self._items)
|
||||
|
||||
with TabbedContent(id="todos-tabs"):
|
||||
# Project TODOs tab (default, for Claude collaboration)
|
||||
with TabPane(f"Project ({unchecked_project})", id="tab-project"):
|
||||
# Create TODO.md section (shown when file doesn't exist)
|
||||
with Vertical(classes="create-todo-section", id="create-todo-section"):
|
||||
yield Static(
|
||||
"No TODO.md found in project",
|
||||
classes="create-todo-message",
|
||||
)
|
||||
yield Button("Create TODO.md", id="create-todo-btn", variant="primary")
|
||||
|
||||
yield ListView(id="project-todos-list")
|
||||
yield Static("No project TODOs", classes="empty-message", id="project-empty")
|
||||
|
||||
# Code Comment TODOs tab
|
||||
with TabPane(f"Comments ({code_count})", id="tab-comments"):
|
||||
yield ListView(id="code-todos-list")
|
||||
yield Static("No code TODOs found ✓", classes="empty-message", id="code-empty")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the lists with items."""
|
||||
self._refresh_lists()
|
||||
|
||||
def _refresh_lists(self) -> None:
|
||||
"""Refresh both list views with current items."""
|
||||
try:
|
||||
create_section = self.query_one("#create-todo-section", Vertical)
|
||||
project_list = self.query_one("#project-todos-list", ListView)
|
||||
project_empty = self.query_one("#project-empty", Static)
|
||||
code_list = self.query_one("#code-todos-list", ListView)
|
||||
code_empty = self.query_one("#code-empty", Static)
|
||||
|
||||
# Clear both lists
|
||||
project_list.clear()
|
||||
code_list.clear()
|
||||
|
||||
# Check if TODO.md exists
|
||||
self._has_todo_md = (self._project_path / "TODO.md").exists()
|
||||
|
||||
# Project TODOs tab
|
||||
if not self._has_todo_md and not self._project_items:
|
||||
# Show create button
|
||||
create_section.display = True
|
||||
project_list.display = False
|
||||
project_empty.display = False
|
||||
else:
|
||||
create_section.display = False
|
||||
|
||||
if self._project_items:
|
||||
# Add unchecked items
|
||||
for item in self._project_items:
|
||||
if not item.checked:
|
||||
project_list.append(ProjectTodoListItem(item))
|
||||
|
||||
unchecked = sum(1 for i in self._project_items if not i.checked)
|
||||
project_list.display = unchecked > 0
|
||||
project_empty.display = unchecked == 0
|
||||
else:
|
||||
project_list.display = False
|
||||
project_empty.display = True
|
||||
|
||||
# Code TODOs tab
|
||||
if self._items:
|
||||
for item in self._items:
|
||||
code_list.append(TodoListItem(item))
|
||||
code_list.display = True
|
||||
code_empty.display = False
|
||||
else:
|
||||
code_list.display = False
|
||||
code_empty.display = True
|
||||
|
||||
# Update tab labels with counts
|
||||
self._update_tab_labels()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_tab_labels(self) -> None:
|
||||
"""Update tab labels with current counts."""
|
||||
try:
|
||||
tabs = self.query_one("#todos-tabs", TabbedContent)
|
||||
|
||||
unchecked_project = sum(1 for i in self._project_items if not i.checked)
|
||||
code_count = len(self._items)
|
||||
|
||||
# Update tab labels via the Tabs widget
|
||||
# Tab IDs include the pane ID, e.g., "--content-tab-tab-project"
|
||||
for tab in tabs.query("Tab"):
|
||||
tab_id = str(tab.id) if tab.id else ""
|
||||
if "tab-project" in tab_id:
|
||||
tab.label = f"Project ({unchecked_project})"
|
||||
elif "tab-comments" in tab_id:
|
||||
tab.label = f"Comments ({code_count})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_tab_counts(self, project_count: int, code_count: int) -> None:
|
||||
"""Update tab labels with provided counts (called from ContextPanel)."""
|
||||
try:
|
||||
tabs = self.query_one("#todos-tabs", TabbedContent)
|
||||
|
||||
for tab in tabs.query("Tab"):
|
||||
tab_id = str(tab.id) if tab.id else ""
|
||||
if "tab-project" in tab_id:
|
||||
tab.label = f"Project ({project_count})"
|
||||
elif "tab-comments" in tab_id:
|
||||
tab.label = f"Comments ({code_count})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_items(
|
||||
self,
|
||||
items: list[TodoItem],
|
||||
project_items: list[ProjectTodoItem] | None = None,
|
||||
) -> None:
|
||||
"""Update both TODO lists."""
|
||||
self._items = items
|
||||
self._project_items = project_items or []
|
||||
|
||||
# Refresh the lists
|
||||
self._refresh_lists()
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Filter code TODOs by 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))
|
||||
elif isinstance(event.item, ProjectTodoListItem):
|
||||
self.post_message(self.ProjectTodoClicked(event.item.item))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "create-todo-btn":
|
||||
self._create_todo_md()
|
||||
|
||||
def _create_todo_md(self) -> None:
|
||||
"""Create a new TODO.md file with template."""
|
||||
todo_path = self._project_path / "TODO.md"
|
||||
if todo_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
todo_path.write_text(TODO_MD_TEMPLATE)
|
||||
self._has_todo_md = True
|
||||
self.post_message(self.TodoMdCreated(todo_path))
|
||||
# Notify app to refresh TODOs
|
||||
self.post_message(self.CreateTodoMdRequested())
|
||||
except OSError as e:
|
||||
self.app.notify(f"Failed to create TODO.md: {e}", severity="error")
|
||||
|
||||
def set_project_path(self, path: Path) -> None:
|
||||
"""Update the project path."""
|
||||
self._project_path = path
|
||||
self._has_todo_md = (path / "TODO.md").exists()
|
||||
self._refresh_lists()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Claude panel with embedded terminal running Claude Code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
|
||||
from clide.widgets.components.terminal_display import TerminalDisplay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
|
||||
|
||||
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%;
|
||||
border-top: solid $surface;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Context panel with Jira, TODOs, and Problems 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.problems import Problem
|
||||
from clide.models.todos import ProjectTodoItem, 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 Jira, TODOs, and Problems tabs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ContextPanel TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
ContextPanel TabPane {
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
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 code TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class ProjectTodoClicked(Message):
|
||||
"""Emitted when a project TODO (from TODO.md) is clicked."""
|
||||
|
||||
def __init__(self, item: ProjectTodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class JiraRefreshRequested(Message):
|
||||
"""Emitted when Jira refresh is requested."""
|
||||
|
||||
pass
|
||||
|
||||
class TodoMdCreated(Message):
|
||||
"""Emitted when TODO.md has been created."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
# Reactive state with counts for tab badges
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
project_todo_count: reactive[int] = reactive(0)
|
||||
code_todo_count: reactive[int] = reactive(0)
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_enabled: bool = True,
|
||||
project_path: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._jira_enabled = jira_enabled
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self.id = "panel-context"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent(id="context-tabs"):
|
||||
with TabPane("Jira", id="context-jira"):
|
||||
yield JiraView(enabled=self._jira_enabled, id="jira-view")
|
||||
with TabPane("TODOs (0)", id="context-todos"):
|
||||
yield TodosView(project_path=self._project_path, id="todos-view")
|
||||
with TabPane("Problems (0)", id="context-problems"):
|
||||
yield ProblemsView(id="problems-view")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize tab counts."""
|
||||
self._update_tab_headers()
|
||||
|
||||
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_headers()
|
||||
|
||||
def watch_todo_count(self, count: int) -> None:
|
||||
"""Update todo count display."""
|
||||
self._update_tab_headers()
|
||||
|
||||
def _update_tab_headers(self) -> None:
|
||||
"""Update the tab headers with counts."""
|
||||
try:
|
||||
tabs = self.query_one("#context-tabs", TabbedContent)
|
||||
|
||||
# Update tab labels via the Tabs widget
|
||||
for tab in tabs.query("Tab"):
|
||||
tab_id = str(tab.id) if tab.id else ""
|
||||
if "context-todos" in tab_id:
|
||||
tab.label = f"TODOs ({self.todo_count})"
|
||||
elif "context-problems" in tab_id:
|
||||
tab.label = f"Problems ({self.problem_count})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_todos_subtabs(self) -> None:
|
||||
"""Update the TODOs view sub-tab labels."""
|
||||
try:
|
||||
view = self.query_one("#todos-view", TodosView)
|
||||
view.update_tab_counts(self.project_todo_count, self.code_todo_count)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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],
|
||||
project_items: list[ProjectTodoItem] | None = None,
|
||||
) -> None:
|
||||
"""Update TODOs view and count."""
|
||||
project_items = project_items or []
|
||||
# Count includes both code TODOs and unchecked project TODOs
|
||||
unchecked_project = sum(1 for p in project_items if not p.checked)
|
||||
self.project_todo_count = unchecked_project
|
||||
self.code_todo_count = len(items)
|
||||
self.todo_count = unchecked_project + len(items)
|
||||
try:
|
||||
view = self.query_one("#todos-view", TodosView)
|
||||
view.update_items(items, project_items)
|
||||
# Update sub-tab counts
|
||||
self._update_todos_subtabs()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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 code todo click."""
|
||||
self.post_message(self.TodoClicked(event.item))
|
||||
|
||||
def on_todos_view_project_todo_clicked(
|
||||
self,
|
||||
event: TodosView.ProjectTodoClicked,
|
||||
) -> None:
|
||||
"""Forward project todo click."""
|
||||
self.post_message(self.ProjectTodoClicked(event.item))
|
||||
|
||||
def on_jira_view_refresh_requested(
|
||||
self,
|
||||
event: JiraView.RefreshRequested,
|
||||
) -> None:
|
||||
"""Forward Jira refresh request."""
|
||||
self.post_message(self.JiraRefreshRequested())
|
||||
|
||||
def on_todos_view_todo_md_created(
|
||||
self,
|
||||
event: TodosView.TodoMdCreated,
|
||||
) -> None:
|
||||
"""Forward TODO.md created event."""
|
||||
self.post_message(self.TodoMdCreated(event.path))
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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 TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel TabPane {
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
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__()
|
||||
|
||||
class ClaudeCommandRequested(Message):
|
||||
"""Emitted when a Claude command is requested (e.g., /commit)."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
# Reactive state
|
||||
current_branch: reactive[str] = reactive("main")
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
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 and branch stats."""
|
||||
try:
|
||||
git_view = self.query_one(GitChangesView)
|
||||
git_view.update_changes(staged, unstaged)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update branch status with staged/unstaged counts
|
||||
try:
|
||||
branch_status = self.query_one(BranchStatus)
|
||||
branch_status.update_stats(len(staged), len(unstaged))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_git_graph(self, commits: list) -> None:
|
||||
"""Update git graph view."""
|
||||
try:
|
||||
graph = self.query_one(GitGraphView)
|
||||
graph.update_commits(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 highlight_file(self, path: Path) -> None:
|
||||
"""Highlight a file in the file browser.
|
||||
|
||||
Used to show which file Claude is working with.
|
||||
"""
|
||||
try:
|
||||
files_view = self.query_one(FilesView)
|
||||
files_view.highlight_path(path)
|
||||
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(Path(event.change.path), event.change.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))
|
||||
|
||||
def on_git_changes_view_claude_action_requested(
|
||||
self,
|
||||
event: GitChangesView.ClaudeActionRequested,
|
||||
) -> None:
|
||||
"""Forward Claude action request (commit, stash, pull, push)."""
|
||||
# Convert action to Claude skill command
|
||||
command = f"/{event.action}"
|
||||
self.post_message(self.ClaudeCommandRequested(command))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user