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:
2026-04-20 20:30:51 +02:00
co-authored by Claude Opus 4.7
parent 30b8cf7db9
commit a355751437
163 changed files with 0 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Clide - A TUI CLI IDE for Claude Code CLI."""
__version__ = "1.0.0"
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m clide."""
from clide.cli import app
if __name__ == "__main__":
app()
+863
View File
@@ -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()
+105
View File
@@ -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)
+20
View File
@@ -0,0 +1,20 @@
"""Domain controllers for Clide."""
from clide.controllers.base import ControllerMixin, controller
from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
__all__ = [
"controller",
"ControllerMixin",
"GitController",
"EditorController",
"DiffController",
"ProblemsController",
"TodosController",
"JiraController",
]
+102
View File
@@ -0,0 +1,102 @@
"""Base controller utilities using decorator pattern."""
from functools import wraps
from typing import TYPE_CHECKING
from textual.message import Message
if TYPE_CHECKING:
from textual.app import App
def controller[T](cls: type[T]) -> type[T]:
"""Decorator to add controller capabilities to a class.
Adds:
- _app attribute for parent application reference
- set_app() method to set the application
- post_message() method to emit messages
- initialize() and shutdown() lifecycle hooks (if not defined)
Usage:
@controller
class GitController:
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
async def get_status(self) -> GitStatus:
...
"""
original_init = cls.__init__
@wraps(original_init)
def new_init(self, *args, **kwargs):
self._app = None
original_init(self, *args, **kwargs)
cls.__init__ = new_init
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
# Only add methods if they don't exist
if not hasattr(cls, "set_app"):
cls.set_app = set_app
if not hasattr(cls, "post_message"):
cls.post_message = post_message
if not hasattr(cls, "initialize"):
cls.initialize = initialize
if not hasattr(cls, "shutdown"):
cls.shutdown = shutdown
return cls
class ControllerMixin:
"""Mixin alternative for controller capabilities.
Use this if you prefer inheritance over decorators.
Usage:
class GitController(ControllerMixin):
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
"""
_app: "App[object] | None" = None
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
@property
def app(self) -> "App[object] | None":
"""Get the parent application."""
return self._app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
+330
View File
@@ -0,0 +1,330 @@
"""Diff controller for viewing and managing diffs."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.services.git_service import GitService
@controller
class DiffController:
"""Controller for diff viewing and Claude-proposed changes."""
class DiffLoaded(Message):
"""Emitted when a diff is loaded."""
def __init__(self, diff: DiffContent) -> None:
self.diff = diff
super().__init__()
class HunkAccepted(Message):
"""Emitted when a hunk is accepted."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class HunkRejected(Message):
"""Emitted when a hunk is rejected."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class AllChangesAccepted(Message):
"""Emitted when all changes are accepted."""
pass
class AllChangesRejected(Message):
"""Emitted when all changes are rejected."""
pass
def __init__(self, repo_path: Path) -> None:
self._git_service = GitService(repo_path)
self._state = DiffViewState()
@property
def state(self) -> DiffViewState:
"""Get diff view state."""
return self._state
@property
def diff(self) -> DiffContent | None:
"""Get current diff content."""
return self._state.diff
@property
def is_proposal(self) -> bool:
"""Check if current diff is a Claude proposal."""
return self._state.is_proposal
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Load diff from git.
Args:
path: File path
staged: Whether to load staged diff
Returns:
DiffContent or None if no diff
"""
diff_text = await self._git_service.get_diff(path, staged)
if not diff_text:
return None
diff = self._parse_diff(path, diff_text)
self._state.diff = diff
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
"""Load a Claude-proposed change as a diff.
Args:
path: File path
old_content: Original content
new_content: Proposed content
Returns:
DiffContent of the proposal
"""
diff = self._create_diff_from_content(path, old_content, new_content)
self._state.diff = diff
self._state.is_proposal = True
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def accept_hunk(self, index: int) -> None:
"""Accept a specific hunk.
Args:
index: Hunk index
"""
self._state.accepted_hunks.add(index)
self._state.rejected_hunks.discard(index)
def reject_hunk(self, index: int) -> None:
"""Reject a specific hunk.
Args:
index: Hunk index
"""
self._state.rejected_hunks.add(index)
self._state.accepted_hunks.discard(index)
def accept_all(self) -> None:
"""Accept all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.accepted_hunks.add(i)
self._state.rejected_hunks.clear()
def reject_all(self) -> None:
"""Reject all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.rejected_hunks.add(i)
self._state.accepted_hunks.clear()
def clear(self) -> None:
"""Clear current diff."""
self._state.diff = None
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
def toggle_side_by_side(self) -> bool:
"""Toggle side-by-side view.
Returns:
New side_by_side value
"""
self._state.side_by_side = not self._state.side_by_side
return self._state.side_by_side
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
"""Parse git diff output into DiffContent."""
hunks: list[DiffHunk] = []
current_hunk_lines: list[DiffLine] = []
current_header = ""
old_start = old_count = new_start = new_count = 0
for line in diff_text.split("\n"):
if line.startswith("@@"):
# Save previous hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
current_hunk_lines = []
# Parse hunk header
current_header = line
# Format: @@ -old_start,old_count +new_start,new_count @@
import re
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
if match:
old_start = int(match.group(1))
old_count = int(match.group(2)) if match.group(2) else 1
new_start = int(match.group(3))
new_count = int(match.group(4)) if match.group(4) else 1
elif line.startswith("+") and not line.startswith("+++"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line[1:],
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
))
elif line.startswith("-") and not line.startswith("---"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line[1:],
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
))
elif line.startswith(" "):
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
current_hunk_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line[1:],
old_line_num=old_num,
new_line_num=new_num,
))
# Save last hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
return DiffContent(
file_path=path,
hunks=tuple(hunks),
)
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Get diff for a file (alias for load_git_diff).
Args:
path: File path
staged: Whether to get staged diff
Returns:
DiffContent or None
"""
return await self.load_git_diff(path, staged)
async def accept_proposal(self, _file_path: str) -> bool:
"""Accept a proposed change and apply it.
Args:
file_path: Path to the file
Returns:
True if successful
"""
if not self._state.diff or not self._state.is_proposal:
return False
self.accept_all()
# TODO: Apply the changes to the file
self.clear()
return True
async def reject_proposal(self, _file_path: str) -> bool:
"""Reject a proposed change.
Args:
file_path: Path to the file
Returns:
True if successful
"""
self.reject_all()
self.clear()
return True
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
"""Create diff from old and new content."""
import difflib
old_lines = old.splitlines(keepends=True)
new_lines = new.splitlines(keepends=True)
diff_lines: list[DiffLine] = []
old_num = new_num = 1
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
if tag == "equal":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line.rstrip("\n"),
old_line_num=old_num,
new_line_num=new_num,
))
old_num += 1
new_num += 1
elif tag == "delete":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
elif tag == "insert":
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
elif tag == "replace":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
hunk = DiffHunk(
header="@@ -1 +1 @@",
old_start=1,
old_count=len(old_lines),
new_start=1,
new_count=len(new_lines),
lines=tuple(diff_lines),
)
return DiffContent(
file_path=path,
hunks=(hunk,),
)
+214
View File
@@ -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
+303
View File
@@ -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}"
+158
View File
@@ -0,0 +1,158 @@
"""Jira controller for Jira CLI integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.services.process_service import ProcessService
@controller
class JiraController:
"""Controller for Jira CLI integration."""
class JiraOutputUpdated(Message):
"""Emitted when Jira output is updated."""
def __init__(self, output: str) -> None:
self.output = output
super().__init__()
class JiraError(Message):
"""Emitted when Jira command fails."""
def __init__(self, error: str) -> None:
self.error = error
super().__init__()
def __init__(
self,
project_path: Path | None = None,
jira_cli_path: str = "jira",
enabled: bool = False,
) -> None:
self._project_path = project_path or Path.cwd()
self._jira_cli = jira_cli_path
self._enabled = enabled
self._last_output: str = ""
self._last_error: str = ""
@property
def enabled(self) -> bool:
"""Check if Jira integration is enabled."""
return self._enabled
@property
def last_output(self) -> str:
"""Get last Jira output."""
return self._last_output
@property
def last_error(self) -> str:
"""Get last error message."""
return self._last_error
def enable(self) -> None:
"""Enable Jira integration."""
self._enabled = True
def disable(self) -> None:
"""Disable Jira integration."""
self._enabled = False
async def run_command(self, *args: str) -> str:
"""Run a Jira CLI command.
Args:
*args: Command arguments
Returns:
Command output
"""
if not self._enabled:
return "Jira integration is disabled"
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, *args)
if result.success:
self._last_output = result.stdout
self._last_error = ""
return result.stdout
else:
self._last_error = result.stderr
return f"Error: {result.stderr}"
async def list_issues(self, project: str | None = None) -> str:
"""List Jira issues.
Args:
project: Optional project key
Returns:
Formatted issue list
"""
args = ["issue", "list"]
if project:
args.extend(["--project", project])
return await self.run_command(*args)
async def get_issue(self, issue_key: str) -> str:
"""Get a specific issue.
Args:
issue_key: Issue key (e.g., PROJ-123)
Returns:
Issue details
"""
return await self.run_command("issue", "view", issue_key)
async def get_my_issues(self) -> str:
"""Get issues assigned to current user.
Returns:
Formatted issue list
"""
return await self.run_command("issue", "list", "--assignee", "@me")
async def get_sprint_issues(self) -> str:
"""Get issues in current sprint.
Returns:
Formatted issue list
"""
return await self.run_command("sprint", "list", "--current")
async def refresh(self) -> str:
"""Refresh Jira data (get my issues).
Returns:
Updated output
"""
return await self.get_my_issues()
async def check_available(self) -> bool:
"""Check if Jira CLI is available.
Returns:
True if available
"""
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, "--version")
return result.success
async def get_content(self) -> str | None:
"""Get Jira content for display.
Returns:
Markdown content or None
"""
if not self._enabled:
return None
try:
return await self.get_my_issues()
except Exception:
return None
+180
View File
@@ -0,0 +1,180 @@
"""Problems controller for linter integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.services.linter_service import LinterService
@controller
class ProblemsController:
"""Controller for problems/diagnostics from linters."""
class ProblemsUpdated(Message):
"""Emitted when problems list is updated."""
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
self.problems = problems
self.summary = summary
super().__init__()
class ProblemSelected(Message):
"""Emitted when a problem is selected."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
self._service = LinterService(project_path)
self._linters = linters or ["ruff"]
self._state = ProblemsState()
@property
def state(self) -> ProblemsState:
"""Get problems state."""
return self._state
@property
def problems(self) -> list[Problem]:
"""Get list of problems."""
return self._state.problems
@property
def summary(self) -> ProblemsSummary:
"""Get problems summary."""
return self._state.summary
@property
def error_count(self) -> int:
"""Get error count."""
return self._state.summary.errors
@property
def warning_count(self) -> int:
"""Get warning count."""
return self._state.summary.warnings
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
"""Refresh problems from all linters.
Returns:
Tuple of (problems, summary)
"""
problems, summary = await self._service.run_all(self._linters)
self._state.problems = problems
self._state.summary = summary
return problems, summary
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
"""Filter problems by severity.
Args:
severity: Severity to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_severity = severity
if severity is None:
return self._state.problems
return [p for p in self._state.problems if p.severity == severity]
def filter_by_source(self, source: str | None) -> list[Problem]:
"""Filter problems by source linter.
Args:
source: Source to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_source = source
if source is None:
return self._state.problems
return [p for p in self._state.problems if p.source == source]
def get_problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file.
Args:
path: File path
Returns:
List of problems for that file
"""
return self._state.problems_for_file(path)
def select_problem(self, index: int) -> Problem | None:
"""Select a problem by index.
Args:
index: Problem index
Returns:
Selected problem or None
"""
if 0 <= index < len(self._state.problems):
self._state.selected_index = index
return self._state.problems[index]
return None
def next_problem(self) -> Problem | None:
"""Select next problem.
Returns:
Next problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = 0
else:
self._state.selected_index = (
self._state.selected_index + 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def prev_problem(self) -> Problem | None:
"""Select previous problem.
Returns:
Previous problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = len(self._state.problems) - 1
else:
self._state.selected_index = (
self._state.selected_index - 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def clear(self) -> None:
"""Clear all problems."""
self._state.problems = []
self._state.summary = ProblemsSummary()
self._state.selected_index = None
async def run_all(self) -> list[Problem]:
"""Run all linters and return problems.
Returns:
List of problems
"""
problems, _ = await self.refresh()
return problems
+163
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
"""Extension system for Clide using pluggy."""
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
from clide.extensions.manager import ExtensionManager
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
@@ -0,0 +1 @@
"""Built-in extensions for Clide."""
+116
View File
@@ -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"})
"""
+108
View 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)
+1
View File
@@ -0,0 +1 @@
"""Shared utility functions and helpers for Clide."""
+65
View File
@@ -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",
]
+94
View File
@@ -0,0 +1,94 @@
"""Configuration models using Pydantic Settings."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from pydantic_settings import BaseSettings, SettingsConfigDict
class ClideSettings(BaseSettings):
"""Main application settings loaded from environment and config files."""
model_config = SettingsConfigDict(
env_prefix="CLIDE_",
env_file=".config/.env",
env_file_encoding="utf-8",
extra="ignore",
)
# Appearance
theme: str = "summer-night" # Default to Summer Night
# Paths
claude_path: str = "claude"
default_workdir: Path = Path.cwd()
jira_cli_path: str = "jira"
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
confluence_enabled: bool = False
imagin_enabled: bool = False
# Linters
linters: list[str] = ["ruff"]
class PanelConfig(BaseModel):
"""Panel visibility and layout configuration."""
model_config = ConfigDict(strict=True, frozen=True)
sidebar_visible: bool = True
context_visible: bool = True
workspace_visible: bool = False # Hidden by default
sidebar_width_percent: int = 20
context_width_percent: int = 25
class KeybindingsConfig(BaseModel):
"""Keybinding configuration."""
model_config = ConfigDict(strict=True)
# Global
command_palette: str = "ctrl+shift+p"
quick_open: str = "ctrl+p"
toggle_sidebar: str = "ctrl+b"
toggle_context: str = "ctrl+shift+b"
toggle_terminal: str = "ctrl+`"
toggle_compact: str = "ctrl+shift+c"
fullscreen: str = "f11"
# Navigation
focus_claude: str = "ctrl+1"
focus_editor: str = "ctrl+2"
focus_terminal: str = "ctrl+3"
focus_sidebar: str = "ctrl+0"
next_tab: str = "ctrl+tab"
prev_tab: str = "ctrl+shift+tab"
close_tab: str = "ctrl+w"
# Git
git_panel: str = "ctrl+shift+g"
stage_file: str = "ctrl+enter"
unstage_file: str = "ctrl+backspace"
# Search
find_in_file: str = "ctrl+f"
find_in_project: str = "ctrl+shift+f"
problems_panel: str = "ctrl+shift+m"
next_problem: str = "f8"
prev_problem: str = "shift+f8"
# Editor
save: str = "ctrl+s"
undo: str = "ctrl+z"
redo: str = "ctrl+shift+z"
go_to_line: str = "ctrl+g"
# Theme
select_theme: str = "ctrl+k ctrl+t"
+50
View File
@@ -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
+66
View File
@@ -0,0 +1,66 @@
"""Diff-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeType(str, Enum):
"""Type of change in a diff line."""
ADDED = "added"
REMOVED = "removed"
CONTEXT = "context"
HEADER = "header"
class DiffLine(BaseModel):
"""A single line in a diff."""
model_config = ConfigDict(strict=True, frozen=True)
change_type: ChangeType
content: str
old_line_num: int | None = None
new_line_num: int | None = None
class DiffHunk(BaseModel):
"""A hunk (section) of a diff."""
model_config = ConfigDict(strict=True, frozen=True)
header: str
old_start: int
old_count: int
new_start: int
new_count: int
lines: tuple[DiffLine, ...]
class DiffContent(BaseModel):
"""Complete diff for a file."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: str
old_path: str | None = None # For renames
hunks: tuple[DiffHunk, ...]
is_binary: bool = False
is_new_file: bool = False
is_deleted: bool = False
class DiffViewState(BaseModel):
"""State of the diff viewer."""
model_config = ConfigDict(strict=True)
diff: DiffContent | None = None
scroll_offset: int = 0
selected_hunk_index: int | None = None
side_by_side: bool = True
# For Claude-proposed changes
is_proposal: bool = False
accepted_hunks: set[int] = set()
rejected_hunks: set[int] = set()
+72
View File
@@ -0,0 +1,72 @@
"""Editor-related Pydantic models."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class CursorPosition(BaseModel):
"""Cursor position in editor."""
model_config = ConfigDict(strict=True, frozen=True)
line: int
column: int
class Selection(BaseModel):
"""Text selection range."""
model_config = ConfigDict(strict=True, frozen=True)
start: CursorPosition
end: CursorPosition
class FileBuffer(BaseModel):
"""A file buffer in the editor."""
model_config = ConfigDict(strict=True)
path: Path
content: str
language: str | None = None
is_modified: bool = False
cursor: CursorPosition = CursorPosition(line=0, column=0)
selection: Selection | None = None
scroll_offset: int = 0
@property
def filename(self) -> str:
"""Get the filename from path."""
return self.path.name
@property
def display_name(self) -> str:
"""Get display name with modification indicator."""
prefix = "● " if self.is_modified else ""
return f"{prefix}{self.filename}"
class EditorState(BaseModel):
"""State of the editor panel."""
model_config = ConfigDict(strict=True)
buffers: list[FileBuffer] = []
active_buffer_index: int | None = None
recent_files: list[Path] = []
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
if self.active_buffer_index is not None and self.buffers:
return self.buffers[self.active_buffer_index]
return None
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
"""Find a buffer by its file path."""
for buffer in self.buffers:
if buffer.path == path:
return buffer
return None
+80
View File
@@ -0,0 +1,80 @@
"""Git-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeStatus(str, Enum):
"""Git file change status."""
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
RENAMED = "renamed"
COPIED = "copied"
UNTRACKED = "untracked"
IGNORED = "ignored"
UNMERGED = "unmerged"
class GitChange(BaseModel):
"""A single file change in git."""
model_config = ConfigDict(strict=True, frozen=True)
path: str
status: ChangeStatus
staged: bool
old_path: str | None = None # For renames
class GitStatus(BaseModel):
"""Current git repository status."""
model_config = ConfigDict(strict=True, frozen=True)
branch: str
ahead: int = 0
behind: int = 0
staged: tuple[GitChange, ...]
unstaged: tuple[GitChange, ...]
untracked: tuple[str, ...] = ()
has_conflicts: bool = False
class GitBranch(BaseModel):
"""Git branch information."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
is_current: bool = False
is_remote: bool = False
tracking: str | None = None
commit_hash: str | None = None
commit_message: str | None = None
class GitCommit(BaseModel):
"""Git commit information for graph view."""
model_config = ConfigDict(strict=True, frozen=True)
hash: str
short_hash: str
message: str
author: str
date: str
is_merge: bool = False
refs: tuple[str, ...] = () # branch names, tags
parents: tuple[str, ...] = ()
class GitGraph(BaseModel):
"""Git log graph data."""
model_config = ConfigDict(strict=True, frozen=True)
commits: tuple[GitCommit, ...]
branches: tuple[GitBranch, ...]
+88
View File
@@ -0,0 +1,88 @@
"""Problems (linter errors) Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class Severity(str, Enum):
"""Problem severity level."""
ERROR = "error"
WARNING = "warning"
INFO = "info"
HINT = "hint"
class Problem(BaseModel):
"""A single linter problem/diagnostic."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
end_line: int | None = None
end_column: int | None = None
severity: Severity
message: str
source: str # e.g., "ruff", "mypy", "eslint"
code: str | None = None # e.g., "E501", "W0612"
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}:{self.column}"
@property
def severity_icon(self) -> str:
"""Icon for severity level."""
icons = {
Severity.ERROR: "✖",
Severity.WARNING: "⚠",
Severity.INFO: "ℹ",
Severity.HINT: "💡",
}
return icons[self.severity]
class ProblemsSummary(BaseModel):
"""Summary of problems in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
errors: int = 0
warnings: int = 0
infos: int = 0
hints: int = 0
@property
def total(self) -> int:
"""Total number of problems."""
return self.errors + self.warnings + self.infos + self.hints
@property
def display_text(self) -> str:
"""Text for tab badge."""
if self.errors:
return f"⚠ {self.errors}"
if self.warnings:
return f"⚠ {self.warnings}"
return f"✓ {self.total}"
class ProblemsState(BaseModel):
"""State of the problems panel."""
model_config = ConfigDict(strict=True)
problems: list[Problem] = []
summary: ProblemsSummary = ProblemsSummary()
filter_severity: Severity | None = None
filter_source: str | None = None
selected_index: int | None = None
def problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file."""
return [p for p in self.problems if p.file_path == path]
+102
View File
@@ -0,0 +1,102 @@
"""Theme-related Pydantic models."""
import re
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, field_validator
if TYPE_CHECKING:
from textual.theme import Theme
def validate_hex_color(value: str) -> str:
"""Validate hex color format."""
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
raise ValueError(f"Invalid hex color: {value}")
return value.lower()
class ThemeColors(BaseModel):
"""Color definitions for a theme."""
model_config = ConfigDict(strict=True, frozen=True)
# Core colors
primary: str
secondary: str
accent: str
# Backgrounds
background: str
surface: str
panel: str
# Text
foreground: str
# Status
success: str
warning: str
error: str
@field_validator("*", mode="before")
@classmethod
def validate_colors(cls, v: str) -> str:
"""Validate all color fields are valid hex."""
return validate_hex_color(v)
class ThemeDefinition(BaseModel):
"""Complete theme definition."""
model_config = ConfigDict(strict=True)
name: str # e.g., "summer-night"
display_name: str # e.g., "Summer Night"
dark: bool # True for dark themes
colors: ThemeColors
def to_textual_colors(self) -> dict[str, str]:
"""Convert to Textual theme color dict."""
return {
"primary": self.colors.primary,
"secondary": self.colors.secondary,
"accent": self.colors.accent,
"background": self.colors.background,
"surface": self.colors.surface,
"panel": self.colors.panel,
"foreground": self.colors.foreground,
"success": self.colors.success,
"warning": self.colors.warning,
"error": self.colors.error,
}
def to_textual_theme(self) -> "Theme":
"""Convert to a Textual Theme object."""
from textual.theme import Theme
return Theme(
name=self.name,
primary=self.colors.primary,
secondary=self.colors.secondary,
accent=self.colors.accent,
background=self.colors.background,
surface=self.colors.surface,
panel=self.colors.panel,
foreground=self.colors.foreground,
success=self.colors.success,
warning=self.colors.warning,
error=self.colors.error,
dark=self.dark,
)
class ThemeMetadata(BaseModel):
"""Theme metadata for listing themes."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
display_name: str
dark: bool
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
+138
View File
@@ -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
+35
View File
@@ -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
+20
View File
@@ -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",
]
+238
View File
@@ -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)
+48
View File
@@ -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
+204
View File
@@ -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
+287
View File
@@ -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
+273
View File
@@ -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
+127
View File
@@ -0,0 +1,127 @@
"""Linter integration service."""
import json
from pathlib import Path
from clide.models.problems import Problem, ProblemsSummary, Severity
from clide.services.process_service import ProcessService
class LinterService:
"""Service for running linters and parsing output."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def run_ruff(self) -> list[Problem]:
"""Run ruff linter.
Returns:
List of problems found
"""
result = await self._process.run(
"ruff", "check", "--output-format=json", "."
)
problems: list[Problem] = []
if result.stdout:
try:
data = json.loads(result.stdout)
for item in data:
severity = self._ruff_severity(item.get("code", ""))
problems.append(Problem(
file_path=Path(item["filename"]),
line=item["location"]["row"],
column=item["location"]["column"],
end_line=item.get("end_location", {}).get("row"),
end_column=item.get("end_location", {}).get("column"),
severity=severity,
message=item["message"],
source="ruff",
code=item.get("code"),
))
except json.JSONDecodeError:
pass
return problems
def _ruff_severity(self, code: str) -> Severity:
"""Map ruff code to severity."""
if code.startswith("E") or code.startswith("F"):
return Severity.ERROR
if code.startswith("W"):
return Severity.WARNING
return Severity.INFO
async def run_mypy(self) -> list[Problem]:
"""Run mypy type checker.
Returns:
List of problems found
"""
result = await self._process.run(
"mypy", "--output=json", "."
)
problems: list[Problem] = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
try:
data = json.loads(line)
severity = self._mypy_severity(data.get("severity", "error"))
problems.append(Problem(
file_path=Path(data["file"]),
line=data["line"],
column=data.get("column", 1),
severity=severity,
message=data["message"],
source="mypy",
code=data.get("code"),
))
except (json.JSONDecodeError, KeyError):
continue
return problems
def _mypy_severity(self, severity: str) -> Severity:
"""Map mypy severity to Severity enum."""
mapping = {
"error": Severity.ERROR,
"warning": Severity.WARNING,
"note": Severity.INFO,
}
return mapping.get(severity, Severity.ERROR)
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
"""Run all configured linters.
Args:
linters: List of linter names to run
Returns:
Tuple of (problems list, summary)
"""
all_problems: list[Problem] = []
for linter in linters:
if linter == "ruff":
all_problems.extend(await self.run_ruff())
elif linter == "mypy":
all_problems.extend(await self.run_mypy())
# Create summary
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
summary = ProblemsSummary(
errors=errors,
warnings=warnings,
infos=infos,
hints=hints,
)
return all_problems, summary
+124
View File
@@ -0,0 +1,124 @@
"""Generic subprocess management service."""
import asyncio
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CommandResult:
"""Result of a command execution."""
returncode: int
stdout: str
stderr: str
@property
def success(self) -> bool:
"""Check if command succeeded."""
return self.returncode == 0
class ProcessService:
"""Service for running subprocess commands."""
def __init__(self, cwd: Path | None = None) -> None:
self.cwd = cwd or Path.cwd()
async def run(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
env: dict[str, str] | None = None,
) -> CommandResult:
"""Run a command asynchronously.
Args:
*args: Command and arguments
cwd: Working directory (defaults to service cwd)
timeout: Timeout in seconds
env: Environment variables to add
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
env=env,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
return CommandResult(
returncode=process.returncode or 0,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
)
except TimeoutError:
process.kill()
return CommandResult(
returncode=-1,
stdout="",
stderr="Command timed out",
)
except Exception as e:
return CommandResult(
returncode=-1,
stdout="",
stderr=str(e),
)
def run_sync(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
) -> CommandResult:
"""Run a command synchronously (for use in threads).
Args:
*args: Command and arguments
cwd: Working directory
timeout: Timeout in seconds
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
result = subprocess.run(
args,
capture_output=True,
cwd=working_dir,
timeout=timeout,
text=True,
)
return CommandResult(
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
)
except subprocess.TimeoutExpired:
return CommandResult(
returncode=-1,
stdout="",
stderr="Command timed out",
)
except Exception as e:
return CommandResult(
returncode=-1,
stdout="",
stderr=str(e),
)
+162
View File
@@ -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
+285
View File
@@ -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
+166
View File
@@ -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
+244
View File
@@ -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
+359
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
"""Theme system for Clide."""
from clide.themes.registry import (
DEFAULT_THEME,
get_all_themes,
get_theme,
get_themes_by_category,
register_theme,
)
__all__ = [
"get_theme",
"get_all_themes",
"get_themes_by_category",
"register_theme",
"DEFAULT_THEME",
]
+51
View File
@@ -0,0 +1,51 @@
"""Built-in themes for Clide."""
from clide.themes.builtin import (
all_hallows_eve,
christmas,
dark_autumn,
dracula,
fall,
gamma,
gruvbox_dark,
gruvbox_light,
hacker_style,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
__all__ = [
"summer_night",
"summer_day",
"one_dark",
"one_dark_pro",
"one_light",
"dracula",
"nord",
"gruvbox_dark",
"gruvbox_light",
"one_dark_teal",
"gamma",
"winter_is_coming",
"monokai_winter",
"fall",
"dark_autumn",
"all_hallows_eve",
"halloween",
"christmas",
"santa_baby",
"pro_hacker",
"hacker_style",
"houston",
]
@@ -0,0 +1,21 @@
"""All Hallows' Eve Plus theme - Halloween."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="all-hallows-eve",
display_name="All Hallows' Eve",
dark=True,
colors=ThemeColors(
primary="#ff7518", # Pumpkin orange
secondary="#9932cc", # Dark orchid
accent="#ff6347",
background="#1a0a1a",
surface="#2d1a2d",
panel="#401a40",
foreground="#dda0dd",
success="#32cd32",
warning="#ff7518",
error="#dc143c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="christmas",
display_name="Christmas",
dark=True,
colors=ThemeColors(
primary="#ff0000", # Christmas red
secondary="#228b22", # Forest green
accent="#ffd700", # Gold
background="#0a1a0a",
surface="#1a2a1a",
panel="#2a3a2a",
foreground="#f0f0f0",
success="#228b22",
warning="#ffd700",
error="#ff0000",
),
)
@@ -0,0 +1,21 @@
"""Dark Autumn Frost theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dark-autumn",
display_name="Dark Autumn Frost",
dark=True,
colors=ThemeColors(
primary="#c49a6c",
secondary="#8b7355",
accent="#a0522d",
background="#1c1410",
surface="#2a1f18",
panel="#382a20",
foreground="#d2b48c",
success="#6b8e23",
warning="#b8860b",
error="#8b0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Dracula theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dracula",
display_name="Dracula",
dark=True,
colors=ThemeColors(
primary="#bd93f9",
secondary="#8be9fd",
accent="#ff79c6",
background="#282a36",
surface="#21222c",
panel="#343746",
foreground="#f8f8f2",
success="#50fa7b",
warning="#ffb86c",
error="#ff5555",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Fall theme - Autumn colors."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="fall",
display_name="Fall",
dark=True,
colors=ThemeColors(
primary="#e9967a",
secondary="#daa520",
accent="#cd853f",
background="#2d1f1f",
surface="#3d2929",
panel="#4d3333",
foreground="#f5deb3",
success="#8fbc8f",
warning="#daa520",
error="#cd5c5c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gamma theme - GitKraken Gamma style."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gamma",
display_name="Gamma",
dark=True,
colors=ThemeColors(
primary="#00d4aa",
secondary="#7c3aed",
accent="#f472b6",
background="#0f172a",
surface="#1e293b",
panel="#334155",
foreground="#e2e8f0",
success="#22c55e",
warning="#f59e0b",
error="#ef4444",
),
)
@@ -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",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Halloween theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="halloween",
display_name="Halloween",
dark=True,
colors=ThemeColors(
primary="#ff6600",
secondary="#8a2be2",
accent="#ff4500",
background="#0d0d0d",
surface="#1a1a1a",
panel="#262626",
foreground="#e6e6e6",
success="#00ff00",
warning="#ff6600",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Houston theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="houston",
display_name="Houston",
dark=True,
colors=ThemeColors(
primary="#ff6f00",
secondary="#00bcd4",
accent="#ff4081",
background="#17212b",
surface="#232e3c",
panel="#2e3a48",
foreground="#eeffff",
success="#4caf50",
warning="#ff9800",
error="#f44336",
),
)
@@ -0,0 +1,21 @@
"""Monokai Winter Night theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="monokai-winter",
display_name="Monokai Winter Night",
dark=True,
colors=ThemeColors(
primary="#66d9ef",
secondary="#a6e22e",
accent="#f92672",
background="#1a1a2e",
surface="#16213e",
panel="#0f3460",
foreground="#f8f8f2",
success="#a6e22e",
warning="#e6db74",
error="#f92672",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Nord theme - Arctic, north-bluish color palette."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="nord",
display_name="Nord",
dark=True,
colors=ThemeColors(
primary="#88c0d0",
secondary="#81a1c1",
accent="#b48ead",
background="#2e3440",
surface="#3b4252",
panel="#434c5e",
foreground="#eceff4",
success="#a3be8c",
warning="#ebcb8b",
error="#bf616a",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Dark theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark",
display_name="One Dark",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#98c379",
warning="#e5c07b",
error="#e06c75",
),
)
@@ -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",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-light",
display_name="One Light",
dark=False,
colors=ThemeColors(
primary="#4078f2",
secondary="#0184bc",
accent="#a626a4",
background="#fafafa",
surface="#f0f0f0",
panel="#e5e5e6",
foreground="#383a42",
success="#50a14f",
warning="#c18401",
error="#e45649",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Pro Hacker theme - Green on black."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="pro-hacker",
display_name="Pro Hacker",
dark=True,
colors=ThemeColors(
primary="#00ff00",
secondary="#00cc00",
accent="#00ff88",
background="#000000",
surface="#0a0a0a",
panel="#141414",
foreground="#00ff00",
success="#00ff00",
warning="#ffff00",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Santa Baby theme - Light Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="santa-baby",
display_name="Santa Baby",
dark=False,
colors=ThemeColors(
primary="#c41e3a", # Cardinal red
secondary="#228b22", # Forest green
accent="#b8860b", # Dark goldenrod
background="#fff8f0",
surface="#f0e8e0",
panel="#e0d8d0",
foreground="#2f1f1f",
success="#228b22",
warning="#daa520",
error="#c41e3a",
),
)
+24
View File
@@ -0,0 +1,24 @@
"""Summer Day theme - Light variant of Summer Night.
Inverted lightness scale with adjusted accent hues for readability.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="summer-day",
display_name="Summer Day",
dark=False,
colors=ThemeColors(
primary="#0088b0",
secondary="#008a99",
accent="#d03060",
background="#f5f7fa",
surface="#e8ebf0",
panel="#dde1e8",
foreground="#21262f",
success="#008a7a",
warning="#b06830",
error="#c04048",
),
)
@@ -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",
),
)
+87
View File
@@ -0,0 +1,87 @@
"""Custom theme loader for user-defined themes."""
import tomllib
from pathlib import Path
from clide.models.theme import ThemeColors, ThemeDefinition
from clide.themes.registry import register_theme
def load_custom_themes(themes_dir: Path) -> list[str]:
"""Load custom themes from a directory.
Args:
themes_dir: Directory containing .toml theme files
Returns:
List of loaded theme names
"""
loaded = []
if not themes_dir.exists():
return loaded
for theme_file in themes_dir.glob("*.toml"):
try:
theme = load_theme_file(theme_file)
if theme:
register_theme(theme, "custom")
loaded.append(theme.name)
except Exception as e:
# Log but don't crash on bad theme files
print(f"Failed to load theme {theme_file}: {e}")
return loaded
def load_theme_file(path: Path) -> ThemeDefinition | None:
"""Load a single theme from a TOML file.
Args:
path: Path to the theme TOML file
Returns:
Theme definition or None if invalid
Example TOML format:
name = "my-theme"
display_name = "My Custom Theme"
dark = true
[colors]
primary = "#007acc"
secondary = "#3c3c3c"
accent = "#0e639c"
background = "#1e1e1e"
surface = "#252526"
panel = "#2d2d2d"
foreground = "#d4d4d4"
success = "#4ec9b0"
warning = "#dcdcaa"
error = "#f14c4c"
"""
with open(path, "rb") as f:
data = tomllib.load(f)
# Validate required fields
required = ["name", "display_name", "dark", "colors"]
for field in required:
if field not in data:
raise ValueError(f"Missing required field: {field}")
colors_data = data["colors"]
color_fields = [
"primary", "secondary", "accent", "background", "surface",
"panel", "foreground", "success", "warning", "error"
]
for field in color_fields:
if field not in colors_data:
raise ValueError(f"Missing color field: {field}")
colors = ThemeColors(**colors_data)
return ThemeDefinition(
name=data["name"],
display_name=data["display_name"],
dark=data["dark"],
colors=colors,
)
+139
View File
@@ -0,0 +1,139 @@
"""Theme registry for managing available themes."""
from clide.models.theme import ThemeDefinition, ThemeMetadata
# Theme registry
_themes: dict[str, ThemeDefinition] = {}
_theme_metadata: dict[str, ThemeMetadata] = {}
DEFAULT_THEME = "summer-night"
def register_theme(
theme: ThemeDefinition,
category: str = "custom",
) -> None:
"""Register a theme in the registry.
Args:
theme: Theme definition to register
category: Theme category (core, popular, seasonal, custom)
"""
_themes[theme.name] = theme
_theme_metadata[theme.name] = ThemeMetadata(
name=theme.name,
display_name=theme.display_name,
dark=theme.dark,
category=category,
)
def get_theme(name: str) -> ThemeDefinition | None:
"""Get a theme by name.
Args:
name: Theme name
Returns:
Theme definition or None if not found
"""
return _themes.get(name)
def get_all_themes() -> list[ThemeMetadata]:
"""Get metadata for all registered themes.
Returns:
List of theme metadata sorted by category then name
"""
themes = list(_theme_metadata.values())
# Sort: core first, then alphabetically by category, then by name
category_order = {"core": 0, "popular": 1, "gitkraken": 2, "seasonal": 3, "hacker": 4, "custom": 5}
themes.sort(key=lambda t: (category_order.get(t.category, 99), t.name))
return themes
def get_themes_by_category(category: str) -> list[ThemeMetadata]:
"""Get themes filtered by category.
Args:
category: Category to filter by
Returns:
List of theme metadata in that category
"""
return [t for t in _theme_metadata.values() if t.category == category]
def _load_builtin_themes() -> None:
"""Load all built-in themes."""
# Import here to avoid circular imports
from clide.themes.builtin import (
all_hallows_eve,
christmas,
dark_autumn,
dracula,
fall,
gamma,
gruvbox_dark,
gruvbox_light,
hacker_style,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
# Core themes
register_theme(summer_night.theme, "core")
register_theme(summer_day.theme, "core")
# Popular themes
register_theme(one_dark.theme, "popular")
register_theme(one_dark_pro.theme, "popular")
register_theme(one_light.theme, "popular")
register_theme(dracula.theme, "popular")
register_theme(nord.theme, "popular")
register_theme(gruvbox_dark.theme, "popular")
register_theme(gruvbox_light.theme, "popular")
# GitKraken style
register_theme(one_dark_teal.theme, "gitkraken")
register_theme(gamma.theme, "gitkraken")
# Seasonal - Winter
register_theme(winter_is_coming.theme, "seasonal")
register_theme(monokai_winter.theme, "seasonal")
# Seasonal - Fall
register_theme(fall.theme, "seasonal")
register_theme(dark_autumn.theme, "seasonal")
# Seasonal - Halloween
register_theme(all_hallows_eve.theme, "seasonal")
register_theme(halloween.theme, "seasonal")
# Seasonal - Christmas
register_theme(christmas.theme, "seasonal")
register_theme(santa_baby.theme, "seasonal")
# Hacker style
register_theme(pro_hacker.theme, "hacker")
register_theme(hacker_style.theme, "hacker")
# Bonus
register_theme(houston.theme, "popular")
# Load built-in themes on module import
_load_builtin_themes()
+1
View File
@@ -0,0 +1 @@
"""Vendored third-party libraries for Clide."""
+19
View File
@@ -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
+67
View File
@@ -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())
+139
View File
@@ -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
}
+77
View File
@@ -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
+154
View File
@@ -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 = "'"
+148
View File
@@ -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]
+61
View File
@@ -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
+878
View File
@@ -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
+519
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
"""Textual widgets for Clide UI."""
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
from clide.widgets.components.git_graph import GitGraphView
from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.terminal_pane import TerminalPane
from clide.widgets.components.todos_view import TodosView
__all__ = [
"BranchStatus",
"DiffPane",
"EditorPane",
"FilesView",
"GitChangesView",
"GitGraphView",
"JiraView",
"ProblemsView",
"TerminalPane",
"TodosView",
]
@@ -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()
+13
View File
@@ -0,0 +1,13 @@
"""Main layout panels for Clide."""
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
from clide.widgets.panels.sidebar import SidebarPanel
from clide.widgets.panels.workspace import WorkspacePanel
__all__ = [
"ClaudePanel",
"ContextPanel",
"SidebarPanel",
"WorkspacePanel",
]
+180
View File
@@ -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
+255
View File
@@ -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))
+200
View File
@@ -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