move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual implementation moves wholesale into legacy/ rather than being deleted: its pane model, panel set, git skills, and panel communication design are real thought that should remain readable next to the new code while the rebuild finds its shape. Git's rename tracking preserves history, so `git log -- legacy/` still works. The Flutter rebuild lives at the repo root alongside a Go sidecar (the architecture claudian was heading toward, which folds into clide as a core component rather than a separate plugin project). Bootstrap of the new shape lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# Gitea Actions workflow for building and releasing Clide
|
||||
# Triggers on version tags (v*)
|
||||
# Builds Linux AppImage and Windows installer automatically
|
||||
# macOS must be built locally (no macOS runners)
|
||||
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.12'
|
||||
|
||||
jobs:
|
||||
# Build Linux AppImage
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[build]"
|
||||
|
||||
- name: Install FUSE (for appimagetool)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fuse libfuse2
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
chmod +x scripts/build-linux.sh
|
||||
./scripts/build-linux.sh ${{ github.ref_name }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: dist/*.AppImage
|
||||
retention-days: 5
|
||||
|
||||
# Build Windows installer
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[build]"
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup -y
|
||||
|
||||
- name: Build installer
|
||||
run: .\scripts\build-windows.ps1 -Version ${{ github.ref_name }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist/*-setup.exe
|
||||
retention-days: 5
|
||||
|
||||
# Create GitHub/Gitea release with all artifacts
|
||||
create-release:
|
||||
needs: [build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Linux artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: artifacts/linux
|
||||
|
||||
- name: Download Windows artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: artifacts/windows
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
artifacts/linux/*.AppImage
|
||||
artifacts/windows/*-setup.exe
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
### Linux (AppImage)
|
||||
```bash
|
||||
chmod +x Clide-*-linux-*.AppImage
|
||||
./Clide-*-linux-*.AppImage
|
||||
```
|
||||
|
||||
### Windows
|
||||
Download and run the installer. For best experience, use Windows Terminal.
|
||||
|
||||
### macOS
|
||||
macOS builds are created manually. See releases for DMG when available.
|
||||
|
||||
## Requirements
|
||||
- **Linux**: glibc 2.17+ (Ubuntu 18.04+, Fedora 25+, etc.)
|
||||
- **Windows**: Windows 10+ with Windows Terminal recommended
|
||||
- **macOS**: macOS 10.13+ (when available)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Pre-commit configuration for Clide
|
||||
# See https://pre-commit.com for more information
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.0
|
||||
hooks:
|
||||
# Run the linter
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
# Run the formatter
|
||||
- id: ruff-format
|
||||
@@ -0,0 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.2.0] - 2026-02-24
|
||||
|
||||
### Added
|
||||
- **clide-web**: Python web server replacing ttyd + zellij for browser access
|
||||
- FastAPI + uvicorn with WebSocket ↔ PTY bridge via tmux
|
||||
- Project switching via `/projects/<name>` URL routing
|
||||
- Vendored xterm.js (no CDN, works on disconnected LAN)
|
||||
- Auto-respawn on Clide exit (tmux `pane-died` hook)
|
||||
- Setup wizard for first-run configuration (`clide-web-setup`)
|
||||
- Service management make targets (start-server, stop-server, etc.)
|
||||
- **SQLite database layer**: SQLModel tables (Project, Session, UserPreference, ConnectionLog) shared between clide and clide-web
|
||||
- **Dynamic tabbed workspace**: Multi-file editor and multi-terminal support with closable tabs
|
||||
|
||||
### Changed
|
||||
- Context panel (Jira, TODOs, Problems) backgrounds now match sidebar (`$surface`)
|
||||
- Makefile uses `printf` instead of `echo` for consistent ANSI color rendering
|
||||
- Full PTY terminal emulator with pyte replaces simple terminal widget
|
||||
- Nerd Font support and clipboard paste in terminal
|
||||
|
||||
### Removed
|
||||
- **ttyd** (C binary fork with Nerd Font) — replaced by clide-web
|
||||
- **zellij** (Rust binary for session persistence) — replaced by tmux
|
||||
- `clide-launcher` shell script — logic absorbed into clide-web
|
||||
- `update-ttyd.sh` build script
|
||||
|
||||
## [1.0.0] - 2026-02-01
|
||||
|
||||
### Added
|
||||
- **Skill Installer Service**: Install bundled Claude Code skills (commit, branch, push, pull, stash) to user or project scope
|
||||
- **TileListView Component**: Reusable card-style list widget with consistent styling
|
||||
- **TODO.md Integration**: Parse and display project TODO.md files in the TODOs panel
|
||||
- **File Watcher**: Real-time file system monitoring with reactive updates
|
||||
- **Git Graph View**: Visual commit graph in sidebar
|
||||
- **Branch Status Widget**: Enhanced branch display with remote tracking info
|
||||
- **Theme System**: 22 built-in themes including Summer Night (default)
|
||||
- **Alt-key Shortcuts**: VSCode-familiar keybindings that don't interfere with input fields
|
||||
|
||||
### Changed
|
||||
- Restructured project with clear separation of controllers, services, and widgets
|
||||
- Improved panel architecture with state preservation on hide/show
|
||||
- Enhanced git integration with better status display
|
||||
|
||||
### Fixed
|
||||
- Test fixtures updated for new widget architecture
|
||||
|
||||
## [0.1.0] - 2026-01-15
|
||||
|
||||
### Added
|
||||
- Initial project structure and TUI framework
|
||||
- Basic panel layout (sidebar, workspace, context)
|
||||
- Claude Code integration as primary workspace
|
||||
- Pydantic models for data validation
|
||||
- pytest test infrastructure
|
||||
@@ -0,0 +1,345 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Clide is a TUI IDE wrapper for Claude Code CLI, designed to be Claude-centric with VSCode-familiar keybindings. See `docs/tui-ide-spec.md` for full specification.
|
||||
|
||||
### Design Principles
|
||||
- **Claude-centric**: Claude Code is the primary workspace, always visible
|
||||
- **Contextual panels**: Editor/Diff/Terminal appear only when needed
|
||||
- **Alt-key shortcuts**: Alt-based keybindings don't interfere with input fields
|
||||
- **Responsive**: Works on 13" laptop to widescreen monitors
|
||||
- **State preservation**: Hiding panels preserves all state (never destroy widgets)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Library | Version |
|
||||
|-----------|---------|---------|
|
||||
| Runtime | Python | 3.12+ |
|
||||
| TUI Framework | Textual | latest |
|
||||
| CLI | Typer | latest |
|
||||
| Data Validation | Pydantic | v2 (strict mode) |
|
||||
| Settings | pydantic-settings | latest |
|
||||
| Database | SQLModel + SQLite | latest |
|
||||
| Web Server | FastAPI + uvicorn | latest |
|
||||
| Testing | pytest + pytest-asyncio + pytest-textual-snapshot | latest |
|
||||
| Extensions | pluggy | latest |
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Clide (TUI)
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make test-single # Run single test (TEST=path::test_name)
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff check
|
||||
make format # Run ruff format
|
||||
make build # Build for current platform
|
||||
```
|
||||
|
||||
### clide-web (Web Server)
|
||||
|
||||
```bash
|
||||
cd clide-web/
|
||||
make setup # Create venv, install deps, run setup wizard
|
||||
make run # Run the web server (foreground)
|
||||
make dev # Run with auto-reload
|
||||
make start-server # Start systemd service
|
||||
make stop-server # Stop systemd service
|
||||
make restart-server # Restart systemd service
|
||||
make status-server # Show service status
|
||||
make logs-server # Tail service logs
|
||||
```
|
||||
|
||||
## Panel Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ panel-sidebar │ panel-workspace (60%) │ panel-context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (hidden when inactive) │ [Problems][TODOs]│
|
||||
│ [Tree] ├─────────────────────────┤ [Jira] │
|
||||
│ │ │ │
|
||||
│ (content area) │ panel-claude │ (content area) │
|
||||
│ │ (40% when workspace │ │
|
||||
│ │ visible, else 100%) │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ [⚠ 3][✓12][Jira]│
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
clide/
|
||||
├── clide/ # Package source
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py
|
||||
│ ├── app.py # Main App, layout, keybindings
|
||||
│ ├── cli.py # Typer entry point
|
||||
│ ├── controllers/ # Domain logic (no UI)
|
||||
│ ├── widgets/ # UI components
|
||||
│ │ ├── panels/ # Main layout containers
|
||||
│ │ └── components/ # Reusable UI pieces
|
||||
│ ├── models/ # Pydantic data models
|
||||
│ ├── services/ # Background task logic
|
||||
│ ├── themes/ # Theme system
|
||||
│ ├── extensions/ # Plugin system
|
||||
│ └── helpers/ # Utility functions
|
||||
│
|
||||
├── tests/
|
||||
│ ├── conftest.py
|
||||
│ ├── harnesses/
|
||||
│ │ ├── app_harness.py
|
||||
│ │ └── controller_harness.py
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ └── snapshots/
|
||||
│
|
||||
├── .config/ # User config (gitignored)
|
||||
│ ├── settings.toml # User settings
|
||||
│ └── themes/ # Custom user themes
|
||||
│ └── my-theme.toml
|
||||
├── docs/
|
||||
│ ├── tui-ide-spec.md # Full UI/UX specification
|
||||
│ ├── web-deployment.md # Web deployment architecture
|
||||
│ └── ARCHITECTURE.md # Framework best practices
|
||||
├── pyproject.toml
|
||||
└── Makefile
|
||||
│
|
||||
clide-web/ # Web server package (wraps clide)
|
||||
├── clide_web/
|
||||
│ ├── server.py # FastAPI app, routes, WebSocket handler
|
||||
│ ├── sessions.py # tmux session manager
|
||||
│ ├── pty_bridge.py # PTY ↔ WebSocket bridge
|
||||
│ ├── config.py # Pydantic settings with DB overlay
|
||||
│ ├── setup_wizard.py # Interactive first-run config
|
||||
│ └── static/
|
||||
│ ├── index.html # HTML page (toolbar + xterm.js)
|
||||
│ └── vendor/ # Vendored xterm.js (offline)
|
||||
├── pyproject.toml
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Panel Visibility (Hide, Don't Destroy)
|
||||
|
||||
```python
|
||||
def toggle_workspace(self, visible: bool) -> None:
|
||||
workspace = self.query_one("#panel-workspace")
|
||||
workspace.display = visible # Preserves all child state
|
||||
|
||||
claude = self.query_one("#panel-claude")
|
||||
claude.styles.height = "40%" if visible else "100%"
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Use `@work` decorator for non-blocking operations:
|
||||
|
||||
```python
|
||||
@work(thread=True)
|
||||
def refresh_git_status(self) -> None:
|
||||
result = subprocess.run(["git", "status", "--porcelain"], ...)
|
||||
self.call_from_thread(self.update_git_view, result.stdout)
|
||||
```
|
||||
|
||||
### Reactive State
|
||||
|
||||
```python
|
||||
class ClideApp(App):
|
||||
current_file: reactive[str | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
```
|
||||
|
||||
### Pydantic Models (Strict + Frozen)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class GitChange(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
path: str
|
||||
status: Literal["added", "modified", "deleted", "untracked", "renamed"]
|
||||
staged: bool
|
||||
```
|
||||
|
||||
### Controller → Widget Communication
|
||||
|
||||
Controllers emit Textual messages; widgets subscribe:
|
||||
|
||||
```python
|
||||
# In controller
|
||||
class GitStatusUpdated(Message):
|
||||
def __init__(self, status: GitStatus) -> None:
|
||||
self.status = status
|
||||
super().__init__()
|
||||
|
||||
self.post_message(GitStatusUpdated(status))
|
||||
|
||||
# In widget
|
||||
def on_git_status_updated(self, event: GitStatusUpdated) -> None:
|
||||
self.refresh_view(event.status)
|
||||
```
|
||||
|
||||
## Theme System
|
||||
|
||||
### Default Theme: Summer Night
|
||||
|
||||
Based on [jackw01/summer-night-vscode-theme](https://github.com/jackw01/summer-night-vscode-theme):
|
||||
|
||||
```python
|
||||
SUMMER_NIGHT = ThemeColors(
|
||||
primary="#00a3d2", # cyan
|
||||
secondary="#00a9b9", # teal
|
||||
accent="#fa5f8b", # pink
|
||||
background="#21262f", # mono_8
|
||||
surface="#393e48", # mono_7
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5", # mono_1
|
||||
success="#00ab9a", # green
|
||||
warning="#d08447", # orange
|
||||
error="#f06c6f", # red
|
||||
)
|
||||
```
|
||||
|
||||
### Built-in Themes (22 total)
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| GitKraken | one-dark-teal, gamma |
|
||||
| Seasonal - Winter | winter-is-coming, monokai-winter |
|
||||
| Seasonal - Fall | fall, dark-autumn |
|
||||
| Seasonal - Halloween | all-hallows-eve, halloween |
|
||||
| Seasonal - Christmas | christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
| Bonus | houston |
|
||||
|
||||
### Theme Definition
|
||||
|
||||
```python
|
||||
class ThemeColors(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
primary: str # Main accent
|
||||
secondary: str # Secondary accent
|
||||
accent: str # Highlight accent
|
||||
background: str # Main background
|
||||
surface: str # Elevated surfaces
|
||||
panel: str # Panel backgrounds
|
||||
foreground: str # Primary text
|
||||
success: str # Success/green
|
||||
warning: str # Warning/yellow
|
||||
error: str # Error/red
|
||||
|
||||
class ThemeDefinition(BaseModel):
|
||||
name: str # Identifier (e.g., "summer-night")
|
||||
display_name: str # Human-readable name
|
||||
dark: bool # Dark or light theme
|
||||
colors: ThemeColors
|
||||
```
|
||||
|
||||
### Custom Themes
|
||||
|
||||
Users can add themes in `.config/themes/`:
|
||||
|
||||
```toml
|
||||
# .config/themes/my-theme.toml
|
||||
name = "my-theme"
|
||||
display_name = "My Custom Theme"
|
||||
dark = true
|
||||
|
||||
[colors]
|
||||
primary = "#007acc"
|
||||
secondary = "#3c3c3c"
|
||||
accent = "#0e639c"
|
||||
background = "#1e1e1e"
|
||||
surface = "#252526"
|
||||
panel = "#2d2d30"
|
||||
foreground = "#d4d4d4"
|
||||
success = "#4ec9b0"
|
||||
warning = "#dcdcaa"
|
||||
error = "#f44747"
|
||||
```
|
||||
|
||||
### Theme Switching
|
||||
|
||||
- Keybinding: `Ctrl+K Ctrl+T`
|
||||
- Settings: `theme = "summer-night"` in ClideSettings
|
||||
- Runtime: `app.theme = "dracula"`
|
||||
|
||||
## Keybindings (Alt-based)
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Quit | `Alt+Q` |
|
||||
| Command palette | `Alt+P` |
|
||||
| Quick open | `Alt+O` |
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
| Git panel | `Alt+G` |
|
||||
| Problems panel | `Alt+M` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Save file | `Alt+S` |
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are loaded from multiple sources (in priority order):
|
||||
1. Environment variables (`CLIDE_*`)
|
||||
2. `.config/settings.toml`
|
||||
3. Defaults in ClideSettings
|
||||
|
||||
```python
|
||||
class ClideSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".env",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
theme: str = "summer-night"
|
||||
jira_enabled: bool = False
|
||||
jira_cli_path: str = "jira"
|
||||
|
||||
panels: PanelConfig = PanelConfig()
|
||||
keybindings: KeybindingsConfig = KeybindingsConfig()
|
||||
```
|
||||
|
||||
## Documentation Links
|
||||
|
||||
### Core Stack
|
||||
- [Textual Documentation](https://textual.textualize.io/)
|
||||
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
|
||||
- [Textual Themes Guide](https://textual.textualize.io/guide/design/)
|
||||
- [Typer Documentation](https://typer.tiangolo.com/)
|
||||
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
|
||||
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
||||
|
||||
### Testing
|
||||
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/)
|
||||
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
|
||||
|
||||
### Extensions
|
||||
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
|
||||
|
||||
### Build
|
||||
- [PyInstaller Documentation](https://pyinstaller.org/)
|
||||
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
||||
|
||||
### Theme References
|
||||
- [Summer Night VSCode Theme](https://github.com/jackw01/summer-night-vscode-theme)
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
.PHONY: setup run test test-single typecheck lint format build build-macos build-linux build-windows build-all clean help
|
||||
|
||||
PYTHON := python3.12
|
||||
VENV := .venv
|
||||
BIN := $(VENV)/bin
|
||||
TEST ?= tests/
|
||||
VERSION ?= 1.0.0
|
||||
|
||||
# Colors for output
|
||||
BLUE := \033[0;34m
|
||||
GREEN := \033[0;32m
|
||||
RESET := \033[0m
|
||||
|
||||
help:
|
||||
@printf "$(BLUE)Clide Development Commands$(RESET)\n"
|
||||
@printf "\n"
|
||||
@printf "$(GREEN)setup$(RESET) Create venv and install dependencies\n"
|
||||
@printf "$(GREEN)run$(RESET) Run the application\n"
|
||||
@printf "$(GREEN)test$(RESET) Run all tests\n"
|
||||
@printf "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)\n"
|
||||
@printf "$(GREEN)typecheck$(RESET) Run mypy type checking\n"
|
||||
@printf "$(GREEN)lint$(RESET) Run ruff linter\n"
|
||||
@printf "$(GREEN)format$(RESET) Run ruff formatter\n"
|
||||
@printf "$(GREEN)build$(RESET) Build executable for current platform\n"
|
||||
@printf "$(GREEN)clean$(RESET) Remove build artifacts and caches\n"
|
||||
@printf "\n"
|
||||
@printf "$(BLUE)Distribution Builds$(RESET)\n"
|
||||
@printf "\n"
|
||||
@printf "$(GREEN)build-macos$(RESET) Build macOS DMG (VERSION=x.x.x)\n"
|
||||
@printf "$(GREEN)build-linux$(RESET) Build Linux AppImage (VERSION=x.x.x)\n"
|
||||
@printf "$(GREEN)build-windows$(RESET) Build Windows installer (VERSION=x.x.x)\n"
|
||||
|
||||
setup:
|
||||
@echo "Creating virtual environment..."
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
@echo "Installing dependencies..."
|
||||
$(BIN)/pip install --upgrade pip
|
||||
$(BIN)/pip install -e ".[dev,build]"
|
||||
@echo "Installing pre-commit hooks..."
|
||||
$(BIN)/pre-commit install || true
|
||||
@printf "$(GREEN)Setup complete! Activate with: source $(VENV)/bin/activate$(RESET)\n"
|
||||
|
||||
run:
|
||||
$(BIN)/python -m clide
|
||||
|
||||
test:
|
||||
$(BIN)/pytest $(TEST)
|
||||
|
||||
test-single:
|
||||
$(BIN)/pytest $(TEST) -v
|
||||
|
||||
test-cov:
|
||||
$(BIN)/pytest --cov=clide/clide --cov-report=html --cov-report=term
|
||||
|
||||
test-snapshots:
|
||||
$(BIN)/pytest tests/snapshots/
|
||||
|
||||
test-snapshots-update:
|
||||
$(BIN)/pytest tests/snapshots/ --snapshot-update
|
||||
|
||||
typecheck:
|
||||
$(BIN)/mypy clide/
|
||||
|
||||
lint:
|
||||
$(BIN)/ruff check clide/ tests/
|
||||
|
||||
format:
|
||||
$(BIN)/ruff format clide/ tests/
|
||||
$(BIN)/ruff check --fix clide/ tests/
|
||||
|
||||
build:
|
||||
$(BIN)/pyinstaller clide.spec --clean
|
||||
|
||||
build-onefile:
|
||||
$(BIN)/pyinstaller \
|
||||
--name clide \
|
||||
--onefile \
|
||||
--clean \
|
||||
--noconfirm \
|
||||
clide/clide/__main__.py
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV)
|
||||
rm -rf dist/ build/
|
||||
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
|
||||
rm -rf htmlcov/ .coverage
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# CI targets (for GitHub Actions)
|
||||
ci-lint:
|
||||
pip install ruff mypy
|
||||
ruff check clide/ tests/
|
||||
mypy clide/
|
||||
|
||||
ci-test:
|
||||
pip install -e ".[dev]"
|
||||
pytest --cov=clide/clide --cov-report=xml
|
||||
|
||||
ci-build:
|
||||
pip install -e ".[build]"
|
||||
pyinstaller clide.spec --clean
|
||||
|
||||
# Distribution builds
|
||||
build-macos:
|
||||
@printf "$(BLUE)Building macOS distribution...$(RESET)\n"
|
||||
./scripts/build-macos.sh $(VERSION)
|
||||
|
||||
build-linux:
|
||||
@printf "$(BLUE)Building Linux AppImage...$(RESET)\n"
|
||||
./scripts/build-linux.sh $(VERSION)
|
||||
|
||||
build-windows:
|
||||
@echo "$(BLUE)Building Windows installer...$(RESET)"
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build-windows.ps1 -Version $(VERSION)
|
||||
|
||||
build-all: build-linux
|
||||
@printf "\n"
|
||||
@printf "$(GREEN)Linux build complete.$(RESET)\n"
|
||||
@printf "$(BLUE)Note:$(RESET) macOS build requires: make build-macos VERSION=$(VERSION)\n"
|
||||
@printf "$(BLUE)Note:$(RESET) Windows build requires Windows: make build-windows VERSION=$(VERSION)\n"
|
||||
@@ -0,0 +1,168 @@
|
||||
# Clide
|
||||
|
||||
A terminal-based IDE that wraps Claude Code CLI, putting AI-assisted development at the center of your workflow.
|
||||
|
||||
## Why Clide?
|
||||
|
||||
Claude Code is powerful, but switching between terminal, editor, and project tools breaks your flow. Clide brings everything into one interface:
|
||||
|
||||
- **Claude stays visible** — Claude Code runs in the center panel, always accessible
|
||||
- **Context at a glance** — File tree, git status, problems, and TODOs in dedicated panels
|
||||
- **Panels appear when needed** — Editor, diff viewer, and terminal stay hidden until you need them
|
||||
- **Git integration** — Commit, stash, pull, and push via Claude with built-in skills
|
||||
- **22 themes** — From summer-night to dracula, with custom theme support
|
||||
|
||||
## Screenshot
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ Sidebar │ Workspace │ Context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (appears when needed) │ [Jira][TODOs] │
|
||||
│ [Tree] ├─────────────────────────┤ [Problems] │
|
||||
│ │ │ │
|
||||
│ │ Claude │ │
|
||||
│ │ (always visible) │ │
|
||||
│ │ │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ │
|
||||
│ staged: 2 │ │ │
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Left Sidebar
|
||||
- **Files** — Project file tree with syntax-aware icons
|
||||
- **Git** — Staged/unstaged changes with action buttons
|
||||
- **Tree** — Visual branch graph
|
||||
- **Branch status** — Current branch with quick switcher
|
||||
|
||||
### Center
|
||||
- **Claude Code** — Full PTY terminal integration, always visible
|
||||
- **Editor** — Syntax highlighting via tree-sitter
|
||||
- **Diff** — Side-by-side diff viewer
|
||||
- **Terminal** — Command execution
|
||||
|
||||
### Right Context
|
||||
- **Jira** — Issue display via CLI integration
|
||||
- **TODOs** — Code comments and TODO.md items
|
||||
- **Problems** — Linter errors and warnings
|
||||
|
||||
### Git Operations
|
||||
|
||||
Click buttons in the Git panel to delegate operations to Claude:
|
||||
|
||||
| Button | Skill | What Claude Does |
|
||||
|--------|-------|------------------|
|
||||
| Commit | `/commit` | Reviews changes, writes commit message |
|
||||
| Stash | `/stash` | Stashes working changes |
|
||||
| Pull | `/pull` | Pulls with rebase, helps resolve conflicts |
|
||||
| Push | `/push` | Pushes to remote, sets upstream if needed |
|
||||
|
||||
Skills are installed automatically to your project's `.claude/skills/` directory.
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- Git
|
||||
- Claude Code CLI (installed and authenticated)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd clide
|
||||
make setup
|
||||
make run
|
||||
```
|
||||
|
||||
Or install directly:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
clide
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
All shortcuts use `Alt` to avoid conflicts with Claude Code input.
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Quit | `Alt+Q` |
|
||||
|
||||
## Themes
|
||||
|
||||
22 built-in themes. Press `Alt+T` to switch.
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| Seasonal | winter-is-coming, monokai-winter, fall, dark-autumn |
|
||||
| Special | all-hallows-eve, halloween, christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
|
||||
Create custom themes in `~/.clide/themes/` as TOML files.
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings stored in `~/.clide/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "summer-night",
|
||||
"compact_mode": false,
|
||||
"jira_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Override with environment variables:
|
||||
|
||||
```bash
|
||||
CLIDE_THEME=dracula clide
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Library |
|
||||
|-----------|---------|
|
||||
| Runtime | Python 3.12+ |
|
||||
| TUI Framework | Textual |
|
||||
| CLI | Typer |
|
||||
| Data Validation | Pydantic v2 |
|
||||
| Extensions | Pluggy |
|
||||
| Syntax Highlighting | tree-sitter |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [User Manual](docs/user-manual.md) — How to use Clide
|
||||
- [UI/UX Specification](docs/tui-ide-spec.md) — Design decisions
|
||||
- [Architecture](docs/ARCHITECTURE.md) — Technical overview
|
||||
- [Code Organization](docs/code-organization.md) — Project structure
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
-->
|
||||
|
||||
Long-term open items for Clide development.
|
||||
|
||||
## Core Features
|
||||
|
||||
### Claude Integration
|
||||
- [ ] Streaming markdown responses in Claude panel
|
||||
- [ ] Visual distinction between Claude responses, tool calls, and user input
|
||||
- [ ] Claude history browser (past conversations)
|
||||
- [x] Claude diff flow (propose changes → diff tab → accept/reject)
|
||||
|
||||
### Editor
|
||||
- [ ] Multi-file tab support with state preservation
|
||||
- [ ] Cursor position and scroll position persistence
|
||||
- [ ] Undo/redo history preservation when hiding panel
|
||||
- [ ] Find in file (`Ctrl+F`)
|
||||
- [ ] Go to line (`Ctrl+G`)
|
||||
|
||||
### Diff Panel
|
||||
- [ ] Side-by-side diff view
|
||||
- [x] Unified diff view toggle
|
||||
- [x] Accept/Reject buttons for Claude-proposed changes
|
||||
- [ ] Syntax highlighting in diff content
|
||||
|
||||
### Terminal
|
||||
- [x] Full PTY integration for terminal emulation
|
||||
- [ ] Command history preservation
|
||||
- [x] Output buffer retention when hiding
|
||||
|
||||
### Git Integration
|
||||
- [x] Stage/unstage files from Git tab
|
||||
- [x] Discard changes context menu
|
||||
- [x] Git graph visualization (Tree tab)
|
||||
- [x] Branch popout with checkout/new branch actions
|
||||
|
||||
## Context Panel (Right Sidebar)
|
||||
|
||||
### Problems View
|
||||
- [ ] Linter integration (ruff, eslint, etc.)
|
||||
- [x] Click to navigate to file:line
|
||||
- [x] Reactive problem count badge
|
||||
|
||||
### TODOs View
|
||||
- [x] Scan for TODO/FIXME/HACK/XXX comments
|
||||
- [x] Click to navigate to file:line
|
||||
- [x] Reactive count badge
|
||||
|
||||
### Jira View
|
||||
- [x] Render Jira CLI markdown output
|
||||
- [ ] Auto-refresh on panel focus
|
||||
- [ ] Configurable refresh interval
|
||||
|
||||
## UI/UX
|
||||
|
||||
### Responsiveness
|
||||
- [ ] CSS breakpoints for different terminal widths
|
||||
- [ ] Auto-hide sidebars on narrow terminals (<100 cols)
|
||||
- [x] Compact mode toggle (`Alt+C`)
|
||||
|
||||
### Fullscreen Mode
|
||||
- [ ] Any panel can go fullscreen (`F11`)
|
||||
- [ ] Exit fullscreen with `Escape`
|
||||
|
||||
### Command Palette
|
||||
- [ ] Implement command palette (`Alt+P`)
|
||||
- [ ] Quick open file (`Alt+O`)
|
||||
|
||||
## State Management
|
||||
|
||||
### Session Persistence
|
||||
- [ ] Remember open files across sessions
|
||||
- [ ] Persist panel sizes and layout
|
||||
- [ ] Save last git state
|
||||
- [ ] Remember expanded/collapsed sections
|
||||
|
||||
### Multiple Projects
|
||||
- [ ] Workspace switcher
|
||||
- [ ] Recent projects list
|
||||
|
||||
## Plugin System
|
||||
|
||||
- [ ] User-defined panels via pluggy
|
||||
- [x] Custom integrations support (hookspecs defined)
|
||||
- [ ] Extension API documentation
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] Snapshot tests for all panels
|
||||
- [x] Integration tests for panel communication
|
||||
- [x] Unit tests for controllers
|
||||
- [x] Unit tests for services
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] User guide
|
||||
- [ ] Plugin development guide
|
||||
- [ ] Architecture documentation updates
|
||||
|
||||
## Web Deployment (ttyd)
|
||||
|
||||
- [ ] Image paste support: intercept browser clipboard image on Ctrl+V, upload blob to server, save as temp file. Claude Code already handles Ctrl+V as image paste — just needs the image on the filesystem
|
||||
- [ ] Right-click context menu for copy/paste in web terminal
|
||||
- [ ] Multiple terminal tabs: the workspace terminal already runs inside a shell — leverage this for tabbed terminal sessions (new tab, close tab, switch tabs)
|
||||
|
||||
## Build & Distribution
|
||||
|
||||
- [ ] PyInstaller builds for macOS
|
||||
- [ ] PyInstaller builds for Linux
|
||||
- [ ] CI/CD pipeline with Gitea Actions
|
||||
@@ -0,0 +1,86 @@
|
||||
.PHONY: setup run dev test lint format clean help start-server stop-server restart-server status-server install-server logs-server
|
||||
|
||||
PYTHON := python3.12
|
||||
VENV := .venv
|
||||
BIN := $(VENV)/bin
|
||||
SERVICE := clide-web
|
||||
|
||||
BLUE := \033[0;34m
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[0;33m
|
||||
RESET := \033[0m
|
||||
|
||||
help:
|
||||
@printf "$(BLUE)clide-web Commands$(RESET)\n\n"
|
||||
@printf "$(YELLOW)Development$(RESET)\n"
|
||||
@printf " $(GREEN)setup$(RESET) Create venv, install deps, run setup wizard\n"
|
||||
@printf " $(GREEN)run$(RESET) Run the web server (foreground)\n"
|
||||
@printf " $(GREEN)dev$(RESET) Run with auto-reload\n"
|
||||
@printf " $(GREEN)test$(RESET) Run tests\n"
|
||||
@printf " $(GREEN)lint$(RESET) Run ruff linter\n"
|
||||
@printf " $(GREEN)format$(RESET) Run ruff formatter\n"
|
||||
@printf " $(GREEN)clean$(RESET) Remove build artifacts\n"
|
||||
@printf "\n$(YELLOW)Service$(RESET)\n"
|
||||
@printf " $(GREEN)install-server$(RESET) Install systemd service (requires sudo)\n"
|
||||
@printf " $(GREEN)start-server$(RESET) Start the systemd service\n"
|
||||
@printf " $(GREEN)stop-server$(RESET) Stop the systemd service\n"
|
||||
@printf " $(GREEN)restart-server$(RESET) Restart the systemd service\n"
|
||||
@printf " $(GREEN)status-server$(RESET) Show service status\n"
|
||||
@printf " $(GREEN)logs-server$(RESET) Tail service logs\n"
|
||||
|
||||
# --- Development ---
|
||||
|
||||
setup:
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
$(BIN)/pip install --upgrade pip
|
||||
$(BIN)/pip install -e ".[dev]"
|
||||
$(BIN)/clide-web-setup
|
||||
@printf "$(GREEN)Setup complete!$(RESET)\n"
|
||||
|
||||
run:
|
||||
$(BIN)/clide-web
|
||||
|
||||
dev:
|
||||
$(BIN)/uvicorn clide_web.server:app --reload --host 0.0.0.0 --port 8888
|
||||
|
||||
test:
|
||||
$(BIN)/pytest tests/
|
||||
|
||||
lint:
|
||||
$(BIN)/ruff check clide_web/
|
||||
|
||||
format:
|
||||
$(BIN)/ruff format clide_web/
|
||||
$(BIN)/ruff check --fix clide_web/
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV) dist/ build/
|
||||
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# --- Service ---
|
||||
|
||||
install-server:
|
||||
@if [ "$$(id -u)" -ne 0 ]; then printf "$(YELLOW)Run with sudo: sudo make install-server$(RESET)\n"; exit 1; fi
|
||||
cp ../deploy/clide-web.service /etc/systemd/system/$(SERVICE).service
|
||||
systemctl daemon-reload
|
||||
systemctl enable $(SERVICE)
|
||||
@printf "$(GREEN)Service installed. Run 'make start-server' to start.$(RESET)\n"
|
||||
|
||||
start-server:
|
||||
sudo systemctl start $(SERVICE)
|
||||
@systemctl is-active --quiet $(SERVICE) && printf "$(GREEN)$(SERVICE) started$(RESET)\n" || printf "$(YELLOW)Failed to start. Check: make logs-server$(RESET)\n"
|
||||
|
||||
stop-server:
|
||||
sudo systemctl stop $(SERVICE)
|
||||
@printf "$(GREEN)$(SERVICE) stopped$(RESET)\n"
|
||||
|
||||
restart-server:
|
||||
sudo systemctl restart $(SERVICE)
|
||||
@systemctl is-active --quiet $(SERVICE) && printf "$(GREEN)$(SERVICE) restarted$(RESET)\n" || printf "$(YELLOW)Failed to restart. Check: make logs-server$(RESET)\n"
|
||||
|
||||
status-server:
|
||||
@systemctl status $(SERVICE) --no-pager || true
|
||||
|
||||
logs-server:
|
||||
journalctl -u $(SERVICE) -f --no-pager
|
||||
@@ -0,0 +1,3 @@
|
||||
"""clide-web: Web server for Clide TUI."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide_web."""
|
||||
|
||||
from clide_web.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Configuration for clide-web using Pydantic Settings + DB preferences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = Path.home() / ".clide" / "clide.db"
|
||||
|
||||
|
||||
class ClideWebSettings(BaseSettings):
|
||||
"""Web server settings.
|
||||
|
||||
Priority: env vars > .env file > DB preferences > defaults.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_WEB_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8888
|
||||
|
||||
# Paths
|
||||
projects_dir: Path = Path("/mnt/media/Projects")
|
||||
clide_bin: str = "clide"
|
||||
|
||||
# Database
|
||||
db_path: Path = DB_PATH
|
||||
|
||||
# Sessions
|
||||
session_timeout_seconds: int = 3600
|
||||
session_cleanup_interval_seconds: int = 60
|
||||
|
||||
# Terminal
|
||||
default_cols: int = 120
|
||||
default_rows: int = 40
|
||||
term: str = "xterm-256color"
|
||||
|
||||
# UI
|
||||
font_family: str = "JetBrains Mono, monospace"
|
||||
font_size: int = 14
|
||||
default_theme: str = "summer-night"
|
||||
|
||||
|
||||
def load_settings() -> ClideWebSettings:
|
||||
"""Load settings, overlaying DB preferences onto defaults.
|
||||
|
||||
Env vars still take highest priority (Pydantic handles that).
|
||||
DB preferences override hardcoded defaults for fields not set via env.
|
||||
"""
|
||||
import os
|
||||
|
||||
# First load from env/defaults
|
||||
settings = ClideWebSettings()
|
||||
|
||||
# Then overlay DB preferences for fields not explicitly set via env
|
||||
try:
|
||||
prefs = _load_db_preferences(settings.db_path)
|
||||
except Exception:
|
||||
logger.debug("Could not load DB preferences (DB may not exist yet)")
|
||||
return settings
|
||||
|
||||
env_prefix = "CLIDE_WEB_"
|
||||
field_map = {
|
||||
"projects_dir": ("projects_dir", Path),
|
||||
"clide_bin": ("clide_bin", str),
|
||||
"port": ("port", int),
|
||||
"font_size": ("font_size", int),
|
||||
"default_theme": ("default_theme", str),
|
||||
"font_family": ("font_family", str),
|
||||
"host": ("host", str),
|
||||
}
|
||||
|
||||
for pref_key, (field_name, field_type) in field_map.items():
|
||||
env_var = f"{env_prefix}{field_name.upper()}"
|
||||
# Only apply DB pref if env var is NOT set
|
||||
if env_var not in os.environ and pref_key in prefs:
|
||||
try:
|
||||
setattr(settings, field_name, field_type(prefs[pref_key]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def _load_db_preferences(db_path: Path) -> dict[str, str]:
|
||||
"""Read UserPreference records from the database."""
|
||||
if not db_path.exists():
|
||||
return {}
|
||||
|
||||
from clide.models.db import UserPreference
|
||||
from clide.services.database import get_engine
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
engine = get_engine(db_path)
|
||||
with DBSession(engine) as db:
|
||||
stmt = select(UserPreference)
|
||||
return {p.key: p.value for p in db.exec(stmt).all()}
|
||||
Binary file not shown.
@@ -0,0 +1,169 @@
|
||||
"""PTY bridge: fork a PTY running tmux attach, async I/O to WebSocket clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class PtyBridge:
|
||||
"""Manages a single PTY connection to a tmux session.
|
||||
|
||||
One PtyBridge per WebSocket connection. The PTY runs `tmux attach -t <session>`.
|
||||
When the WebSocket disconnects, the PTY is killed but the tmux session persists.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmux_session: str,
|
||||
on_output: Callable[[bytes], None],
|
||||
on_exit: Callable[[], None],
|
||||
rows: int = 40,
|
||||
cols: int = 120,
|
||||
) -> None:
|
||||
self._tmux_session = tmux_session
|
||||
self._on_output = on_output
|
||||
self._on_exit = on_exit
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
self._pid: int | None = None
|
||||
self._master_fd: int | None = None
|
||||
self._read_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
if self._pid is None:
|
||||
return False
|
||||
try:
|
||||
pid, _ = os.waitpid(self._pid, os.WNOHANG)
|
||||
return pid == 0
|
||||
except ChildProcessError:
|
||||
return False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Fork a PTY and exec tmux attach."""
|
||||
pid, master_fd = pty.fork()
|
||||
|
||||
if pid == 0:
|
||||
# Child process
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
os.environ["COLORTERM"] = "truecolor"
|
||||
os.environ["COLUMNS"] = str(self._cols)
|
||||
os.environ["LINES"] = str(self._rows)
|
||||
os.execvp("tmux", ["tmux", "attach-session", "-t", self._tmux_session])
|
||||
else:
|
||||
# Parent process
|
||||
self._pid = pid
|
||||
self._master_fd = master_fd
|
||||
|
||||
# Set non-blocking I/O
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set initial PTY size
|
||||
self._set_pty_size(self._rows, self._cols)
|
||||
|
||||
# Start async read loop
|
||||
self._read_task = asyncio.create_task(self._read_output())
|
||||
|
||||
async def _read_output(self) -> None:
|
||||
"""Read PTY output and forward to callback."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
fd = self._master_fd
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for data using event loop (more efficient than polling)
|
||||
await _wait_for_fd(loop, fd)
|
||||
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
self._on_output(data)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._on_exit()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Write input data to the PTY."""
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def resize(self, rows: int, cols: int) -> None:
|
||||
"""Resize the PTY and notify the child process."""
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
if self._master_fd is not None:
|
||||
self._set_pty_size(rows, cols)
|
||||
|
||||
def _set_pty_size(self, rows: int, cols: int) -> None:
|
||||
"""Set PTY window size via ioctl and send SIGWINCH."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
try:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGWINCH)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Kill the PTY process and clean up."""
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
self._read_task = None
|
||||
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(self._pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
self._pid = None
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
|
||||
async def _wait_for_fd(loop: asyncio.AbstractEventLoop, fd: int) -> None:
|
||||
"""Wait until a file descriptor has data ready to read."""
|
||||
future: asyncio.Future[None] = loop.create_future()
|
||||
|
||||
def _ready() -> None:
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
|
||||
loop.add_reader(fd, _ready)
|
||||
try:
|
||||
await future
|
||||
finally:
|
||||
loop.remove_reader(fd)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""FastAPI application: HTTP routes + WebSocket terminal bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from clide.models.db import ConnectionLog
|
||||
from clide.services.database import get_db, init_db
|
||||
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session as DBSession
|
||||
|
||||
from clide_web.config import ClideWebSettings, load_settings
|
||||
from clide_web.pty_bridge import PtyBridge
|
||||
from clide_web.sessions import TmuxSessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level settings and session manager (initialized in lifespan)
|
||||
_settings: ClideWebSettings | None = None
|
||||
_session_mgr: TmuxSessionManager | None = None
|
||||
_cleanup_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifespan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI): # type: ignore[no-untyped-def]
|
||||
global _settings, _session_mgr, _cleanup_task
|
||||
|
||||
_settings = load_settings()
|
||||
_session_mgr = TmuxSessionManager(_settings)
|
||||
|
||||
# Initialize database
|
||||
init_db(_settings.db_path)
|
||||
logger.info("Database initialized at %s", _settings.db_path)
|
||||
|
||||
# Start periodic session cleanup
|
||||
_cleanup_task = asyncio.create_task(_periodic_cleanup())
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if _cleanup_task:
|
||||
_cleanup_task.cancel()
|
||||
try:
|
||||
await _cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def _periodic_cleanup() -> None:
|
||||
"""Periodically sync DB session records with live tmux state."""
|
||||
assert _settings is not None
|
||||
assert _session_mgr is not None
|
||||
while True:
|
||||
await asyncio.sleep(_settings.session_cleanup_interval_seconds)
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
await _session_mgr.cleanup_dead_sessions(db)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Session cleanup failed")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# App
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="clide-web", version="1.0.0", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
"""Redirect root to project list or serve HTML."""
|
||||
return _serve_index()
|
||||
|
||||
|
||||
@app.get("/projects/{project_name}", response_class=HTMLResponse)
|
||||
@app.head("/projects/{project_name}")
|
||||
async def project_page(project_name: str): # noqa: ARG001
|
||||
"""Serve the terminal page for a specific project."""
|
||||
return _serve_index()
|
||||
|
||||
|
||||
def _serve_index() -> FileResponse | HTMLResponse:
|
||||
index_path = STATIC_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(index_path, media_type="text/html")
|
||||
return HTMLResponse("<h1>clide-web</h1><p>index.html not found</p>", status_code=500)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check for reverse proxy."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def list_projects():
|
||||
"""List available git projects."""
|
||||
assert _session_mgr is not None
|
||||
return {"projects": _session_mgr.list_projects()}
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions():
|
||||
"""List active tmux sessions."""
|
||||
assert _session_mgr is not None
|
||||
sessions = await _session_mgr.list_sessions()
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket terminal bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.websocket("/projects/{project}/ws")
|
||||
async def websocket_terminal(
|
||||
ws: WebSocket,
|
||||
project: str,
|
||||
db: DBSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
"""Bridge WebSocket ↔ PTY (via tmux attach).
|
||||
|
||||
Protocol:
|
||||
Prefix "0" + data → terminal I/O
|
||||
Prefix "1" + json → control messages
|
||||
Prefix "2" + C,R → resize (cols,rows)
|
||||
"""
|
||||
assert _settings is not None
|
||||
assert _session_mgr is not None
|
||||
|
||||
await ws.accept()
|
||||
|
||||
if not project:
|
||||
await ws.send_text('1{"type":"error","message":"No project specified"}')
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Validate and create/attach tmux session
|
||||
try:
|
||||
tmux_name = await _session_mgr.create_session(project, db)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await ws.send_text(f'1{json.dumps({"type": "error", "message": str(e)})}')
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Log connection
|
||||
client_ip = ws.client.host if ws.client else "unknown"
|
||||
log_entry = ConnectionLog(project_name=project, client_ip=client_ip)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
db.refresh(log_entry)
|
||||
|
||||
# Send session info
|
||||
await ws.send_text(
|
||||
f'1{json.dumps({"type": "session_info", "project": project, "tmux_session": tmux_name})}'
|
||||
)
|
||||
|
||||
# Set up PTY bridge to tmux session
|
||||
send_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
def on_output(data: bytes) -> None:
|
||||
send_queue.put_nowait(data)
|
||||
|
||||
def on_exit() -> None:
|
||||
send_queue.put_nowait(b"") # Sentinel for EOF
|
||||
|
||||
bridge = PtyBridge(
|
||||
tmux_session=tmux_name,
|
||||
on_output=on_output,
|
||||
on_exit=on_exit,
|
||||
rows=_settings.default_rows,
|
||||
cols=_settings.default_cols,
|
||||
)
|
||||
bridge.start()
|
||||
|
||||
# Task to forward PTY output → WebSocket
|
||||
async def _forward_output() -> None:
|
||||
while True:
|
||||
data = await send_queue.get()
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
await ws.send_bytes(b"0" + data)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
output_task = asyncio.create_task(_forward_output())
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
text = message["text"]
|
||||
if not text:
|
||||
continue
|
||||
prefix = text[0]
|
||||
payload = text[1:]
|
||||
|
||||
if prefix == "0":
|
||||
# Terminal input
|
||||
bridge.write(payload.encode("utf-8", errors="surrogateescape"))
|
||||
elif prefix == "1":
|
||||
# Control message
|
||||
await _handle_control(ws, payload, project)
|
||||
elif prefix == "2":
|
||||
# Resize: "2cols,rows"
|
||||
try:
|
||||
cols_str, rows_str = payload.split(",", 1)
|
||||
bridge.resize(int(rows_str), int(cols_str))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
elif "bytes" in message:
|
||||
raw = message["bytes"]
|
||||
if raw and len(raw) > 1:
|
||||
prefix = raw[0:1]
|
||||
payload_bytes = raw[1:]
|
||||
if prefix == b"0":
|
||||
bridge.write(payload_bytes)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("WebSocket error for project %s", project)
|
||||
finally:
|
||||
bridge.stop()
|
||||
output_task.cancel()
|
||||
try:
|
||||
await output_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Update connection log
|
||||
log_entry.disconnected_at = datetime.utcnow()
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _handle_control(ws: WebSocket, payload: str, project: str) -> None: # noqa: ARG001
|
||||
"""Handle a JSON control message from the client."""
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
msg_type = msg.get("type", "")
|
||||
|
||||
if msg_type == "list_projects":
|
||||
assert _session_mgr is not None
|
||||
projects = _session_mgr.list_projects()
|
||||
await ws.send_text(f'1{json.dumps({"type": "projects", "projects": projects})}')
|
||||
|
||||
elif msg_type == "ping":
|
||||
await ws.send_text(f'1{json.dumps({"type": "pong"})}')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the clide-web server."""
|
||||
settings = load_settings()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
)
|
||||
logger.info("Starting clide-web on %s:%d", settings.host, settings.port)
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="info",
|
||||
ws_ping_interval=20,
|
||||
ws_ping_timeout=20,
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""tmux session manager: create, attach, list, kill sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from clide.models.db import Project, Session
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide_web.config import ClideWebSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TmuxSessionManager:
|
||||
"""Manages tmux sessions, one per project."""
|
||||
|
||||
def __init__(self, settings: ClideWebSettings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[str]:
|
||||
"""List git repos in the projects directory."""
|
||||
projects_dir = self._settings.projects_dir
|
||||
if not projects_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in projects_dir.iterdir() if d.is_dir() and (d / ".git").exists()
|
||||
)
|
||||
|
||||
def validate_project(self, name: str) -> Path | None:
|
||||
"""Return project path if valid, else None."""
|
||||
project_dir = self._settings.projects_dir / name
|
||||
if project_dir.is_dir() and (project_dir / ".git").exists():
|
||||
return project_dir
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tmux operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _session_name(project: str) -> str:
|
||||
return f"clide-{project}"
|
||||
|
||||
async def session_exists(self, project: str) -> bool:
|
||||
"""Check if a tmux session exists for this project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
return proc.returncode == 0
|
||||
|
||||
async def create_session(self, project: str, db: DBSession) -> str:
|
||||
"""Create a new tmux session running clide for the given project.
|
||||
|
||||
Returns the tmux session name.
|
||||
"""
|
||||
name = self._session_name(project)
|
||||
project_dir = self.validate_project(project)
|
||||
if project_dir is None:
|
||||
raise ValueError(f"Project '{project}' not found")
|
||||
|
||||
# Check if session already exists
|
||||
if await self.session_exists(project):
|
||||
logger.info("tmux session %s already exists, reusing", name)
|
||||
else:
|
||||
clide_bin = _resolve_clide_bin(self._settings.clide_bin)
|
||||
env = _build_env(self._settings.term)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d", # detached
|
||||
"-s",
|
||||
name, # session name
|
||||
"-c",
|
||||
str(project_dir), # working directory
|
||||
"-x",
|
||||
str(self._settings.default_cols),
|
||||
"-y",
|
||||
str(self._settings.default_rows),
|
||||
clide_bin, # command to run
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create tmux session: {stderr.decode().strip()}")
|
||||
|
||||
# Configure session: hide status bar, auto-respawn on exit
|
||||
# pane-died hook: kill the dead pane, clear all history, respawn clean
|
||||
respawn_cmd = (
|
||||
f"respawn-pane -k -t {name} -c {project_dir} {clide_bin} \\; "
|
||||
f"clear-history -t {name}"
|
||||
)
|
||||
for opt_args in [
|
||||
["set-option", "-t", name, "status", "off"],
|
||||
["set-option", "-t", name, "remain-on-exit", "on"],
|
||||
["set-hook", "-t", name, "pane-died", respawn_cmd],
|
||||
]:
|
||||
await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
*opt_args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
logger.info("Created tmux session %s for project %s", name, project)
|
||||
|
||||
# Persist to DB
|
||||
_upsert_project(db, project, str(project_dir))
|
||||
_upsert_session(db, project, name)
|
||||
|
||||
return name
|
||||
|
||||
async def list_sessions(self) -> list[dict[str, str]]:
|
||||
"""List active tmux sessions matching clide-* pattern."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}:#{session_created}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for line in stdout.decode().strip().splitlines():
|
||||
if not line.startswith("clide-"):
|
||||
continue
|
||||
parts = line.split(":", 1)
|
||||
name = parts[0]
|
||||
project = name.removeprefix("clide-")
|
||||
sessions.append({"name": name, "project": project})
|
||||
return sessions
|
||||
|
||||
async def kill_session(self, project: str) -> None:
|
||||
"""Kill a tmux session for a project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
logger.info("Killed tmux session %s", name)
|
||||
|
||||
async def cleanup_dead_sessions(self, db: DBSession) -> None:
|
||||
"""Sync DB session records with actual tmux state."""
|
||||
live = await self.list_sessions()
|
||||
live_names = {s["name"] for s in live}
|
||||
|
||||
stmt = select(Session).where(Session.status == "active")
|
||||
for session in db.exec(stmt).all():
|
||||
if session.tmux_session not in live_names:
|
||||
session.status = "dead"
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_env(term: str) -> dict[str, str]:
|
||||
"""Build environment for tmux sessions, inheriting the current env."""
|
||||
import os
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TERM"] = term
|
||||
env["COLORTERM"] = "truecolor"
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_clide_bin(clide_bin: str) -> str:
|
||||
"""Resolve clide binary path to absolute if relative."""
|
||||
import shutil
|
||||
|
||||
path = Path(clide_bin)
|
||||
if path.is_absolute():
|
||||
return clide_bin
|
||||
# Relative path — resolve against cwd
|
||||
resolved = Path.cwd() / path
|
||||
if resolved.is_file():
|
||||
return str(resolved)
|
||||
# Try to find it on PATH
|
||||
found = shutil.which(clide_bin)
|
||||
if found:
|
||||
return found
|
||||
return clide_bin # Last resort: return as-is
|
||||
|
||||
|
||||
def _upsert_project(db: DBSession, name: str, path: str) -> Project:
|
||||
"""Create or update a project record."""
|
||||
stmt = select(Project).where(Project.name == name)
|
||||
project = db.exec(stmt).first()
|
||||
if project is None:
|
||||
project = Project(name=name, path=path)
|
||||
project.last_accessed = datetime.utcnow()
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
def _upsert_session(db: DBSession, project_name: str, tmux_session: str) -> Session:
|
||||
"""Create or update a session record."""
|
||||
stmt = select(Session).where(Session.tmux_session == tmux_session)
|
||||
session = db.exec(stmt).first()
|
||||
if session is None:
|
||||
session = Session(project_name=project_name, tmux_session=tmux_session)
|
||||
session.status = "active"
|
||||
session.last_activity = datetime.utcnow()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Interactive setup wizard for clide-web configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.db import UserPreference
|
||||
from clide.services.database import get_engine, init_db
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
|
||||
def run_setup() -> None:
|
||||
"""Run the interactive setup wizard, persisting config to the database."""
|
||||
print()
|
||||
print(" ╔═══════════════════════════════════════╗")
|
||||
print(" ║ clide-web Setup Wizard ║")
|
||||
print(" ╚═══════════════════════════════════════╝")
|
||||
print()
|
||||
|
||||
db_path = Path.home() / ".clide" / "clide.db"
|
||||
init_db(db_path)
|
||||
engine = get_engine(db_path)
|
||||
|
||||
with DBSession(engine) as db:
|
||||
# Projects directory
|
||||
current = _get_pref(db, "projects_dir")
|
||||
default = current or _guess_projects_dir()
|
||||
projects_dir = _prompt("Projects directory", default)
|
||||
path = Path(projects_dir).expanduser().resolve()
|
||||
if not path.is_dir():
|
||||
print(f" Warning: '{path}' does not exist yet")
|
||||
_set_pref(db, "projects_dir", str(path))
|
||||
|
||||
# Clide binary
|
||||
current = _get_pref(db, "clide_bin")
|
||||
default = current or _find_clide_bin()
|
||||
clide_bin = _prompt("Clide binary path", default)
|
||||
_set_pref(db, "clide_bin", clide_bin)
|
||||
|
||||
# Port
|
||||
current = _get_pref(db, "port")
|
||||
port = _prompt("Server port", current or "8888")
|
||||
_set_pref(db, "port", port)
|
||||
|
||||
# Font size
|
||||
current = _get_pref(db, "font_size")
|
||||
font_size = _prompt("Terminal font size", current or "14")
|
||||
_set_pref(db, "font_size", font_size)
|
||||
|
||||
db.commit()
|
||||
|
||||
print()
|
||||
print(" Configuration saved to ~/.clide/clide.db")
|
||||
print()
|
||||
print(" Run with: clide-web")
|
||||
print()
|
||||
|
||||
|
||||
def _prompt(label: str, default: str) -> str:
|
||||
"""Prompt the user with a default value."""
|
||||
result = input(f" {label} [{default}]: ").strip()
|
||||
return result if result else default
|
||||
|
||||
|
||||
def _get_pref(db: DBSession, key: str) -> str | None:
|
||||
"""Get a preference from the database."""
|
||||
stmt = select(UserPreference).where(UserPreference.key == key)
|
||||
pref = db.exec(stmt).first()
|
||||
return pref.value if pref else None
|
||||
|
||||
|
||||
def _set_pref(db: DBSession, key: str, value: str) -> None:
|
||||
"""Set a preference in the database."""
|
||||
stmt = select(UserPreference).where(UserPreference.key == key)
|
||||
pref = db.exec(stmt).first()
|
||||
if pref is None:
|
||||
pref = UserPreference(key=key, value=value)
|
||||
else:
|
||||
pref.value = value
|
||||
db.add(pref)
|
||||
|
||||
|
||||
def _guess_projects_dir() -> str:
|
||||
"""Try to guess a sensible default for projects directory."""
|
||||
candidates = [
|
||||
Path.home() / "Projects",
|
||||
Path.home() / "projects",
|
||||
Path.home() / "src",
|
||||
Path.home() / "code",
|
||||
Path("/mnt/media/Projects"),
|
||||
]
|
||||
for p in candidates:
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
return str(Path.home() / "Projects")
|
||||
|
||||
|
||||
def _find_clide_bin() -> str:
|
||||
"""Try to find the clide binary."""
|
||||
found = shutil.which("clide")
|
||||
if found:
|
||||
return found
|
||||
# Check common venv locations
|
||||
candidates = [
|
||||
Path.cwd() / ".venv" / "bin" / "clide",
|
||||
Path.cwd().parent / ".venv" / "bin" / "clide",
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return str(p)
|
||||
return "clide"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_setup()
|
||||
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Clide</title>
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.min.css">
|
||||
<style>
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #21262f;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
/* Kill all scrollbars */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
/* Kill xterm.js internal scrollbar — TUI handles its own scrolling */
|
||||
.xterm-viewport {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
#toolbar {
|
||||
height: 36px;
|
||||
background: #292e38;
|
||||
border-bottom: 1px solid #393e48;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #e2e8f5;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#toolbar .logo {
|
||||
font-weight: 600;
|
||||
color: #00a3d2;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#toolbar .separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #393e48;
|
||||
}
|
||||
|
||||
#toolbar select {
|
||||
background: #393e48;
|
||||
color: #e2e8f5;
|
||||
border: 1px solid #525762;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
#toolbar select:hover {
|
||||
border-color: #00a3d2;
|
||||
}
|
||||
#toolbar select:focus {
|
||||
border-color: #00a3d2;
|
||||
box-shadow: 0 0 0 1px #00a3d2;
|
||||
}
|
||||
|
||||
#toolbar label {
|
||||
font-size: 11px;
|
||||
color: #898e9a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-spacer { flex: 1; }
|
||||
|
||||
#status {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #525762;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#status .dot.connected { background: #00ab9a; }
|
||||
#status .dot.reconnecting { background: #d08447; animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Terminal container */
|
||||
#terminal-container {
|
||||
height: calc(100% - 36px);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Reconnect overlay */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(33, 38, 47, 0.92);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #e2e8f5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
#overlay .message {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#overlay .sub {
|
||||
font-size: 13px;
|
||||
color: #898e9a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span class="logo">Clide</span>
|
||||
<div class="separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<label>Project</label>
|
||||
<select id="project-select"><option value="">Loading...</option></select>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div id="status">
|
||||
<div class="dot" id="status-dot"></div>
|
||||
<span id="status-text">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-container"></div>
|
||||
<div id="overlay">
|
||||
<div class="message">Reconnecting...</div>
|
||||
<div class="sub">Session is preserved</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/vendor/xterm.min.js"></script>
|
||||
<script src="/static/vendor/addon-fit.min.js"></script>
|
||||
<script src="/static/vendor/addon-web-links.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// --- State ---
|
||||
// Extract project from path: /projects/<name> or fallback to ?project=<name>
|
||||
function getProjectFromUrl() {
|
||||
var match = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
return new URLSearchParams(location.search).get("project") || "";
|
||||
}
|
||||
let currentProject = getProjectFromUrl();
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
|
||||
// --- DOM refs ---
|
||||
const statusDot = document.getElementById("status-dot");
|
||||
const statusText = document.getElementById("status-text");
|
||||
const overlay = document.getElementById("overlay");
|
||||
const projectSelect = document.getElementById("project-select");
|
||||
|
||||
// --- Terminal ---
|
||||
const term = new Terminal({
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: "#21262f",
|
||||
foreground: "#e2e8f5",
|
||||
cursor: "#00a3d2",
|
||||
cursorAccent: "#21262f",
|
||||
selectionBackground: "rgba(0, 163, 210, 0.3)",
|
||||
black: "#21262f",
|
||||
red: "#f06c6f",
|
||||
green: "#00ab9a",
|
||||
yellow: "#d08447",
|
||||
blue: "#00a3d2",
|
||||
magenta: "#fa5f8b",
|
||||
cyan: "#00a9b9",
|
||||
white: "#e2e8f5",
|
||||
brightBlack: "#393e48",
|
||||
brightRed: "#f06c6f",
|
||||
brightGreen: "#00ab9a",
|
||||
brightYellow: "#d08447",
|
||||
brightBlue: "#00a3d2",
|
||||
brightMagenta: "#fa5f8b",
|
||||
brightCyan: "#00a9b9",
|
||||
brightWhite: "#e2e8f5",
|
||||
},
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
scrollback: 0,
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
term.open(document.getElementById("terminal-container"));
|
||||
fitAddon.fit();
|
||||
|
||||
// --- Status helpers ---
|
||||
function setStatus(state, text) {
|
||||
statusDot.className = "dot " + state;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
function connect() {
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = proto + "//" + location.host + "/projects/" + encodeURIComponent(currentProject) + "/ws";
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = function() {
|
||||
setStatus("connected", currentProject || "Connected");
|
||||
overlay.style.display = "none";
|
||||
reconnectDelay = 1000;
|
||||
|
||||
// Send initial terminal size (slight delay to ensure xterm is rendered)
|
||||
setTimeout(function() {
|
||||
fitAddon.fit();
|
||||
sendResize();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
ws.onmessage = function(evt) {
|
||||
let prefix, payload;
|
||||
if (evt.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(evt.data);
|
||||
if (bytes.length < 1) return;
|
||||
prefix = String.fromCharCode(bytes[0]);
|
||||
payload = bytes.slice(1);
|
||||
if (prefix === "0") {
|
||||
term.write(payload);
|
||||
} else if (prefix === "1") {
|
||||
handleControl(new TextDecoder().decode(payload));
|
||||
}
|
||||
} else {
|
||||
// Text frame
|
||||
prefix = evt.data[0];
|
||||
payload = evt.data.slice(1);
|
||||
if (prefix === "0") {
|
||||
term.write(payload);
|
||||
} else if (prefix === "1") {
|
||||
handleControl(payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
setStatus("reconnecting", "Reconnecting...");
|
||||
overlay.style.display = "flex";
|
||||
reconnectTimer = setTimeout(function() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
// --- Terminal input → WebSocket ---
|
||||
term.onData(function(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send("0" + data);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Resize ---
|
||||
function sendResize() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws.send("2" + dims.cols + "," + dims.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(function() {
|
||||
fitAddon.fit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(document.getElementById("terminal-container"));
|
||||
|
||||
// --- Control messages ---
|
||||
function handleControl(jsonStr) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(jsonStr); } catch(e) { return; }
|
||||
|
||||
if (msg.type === "projects") {
|
||||
populateProjects(msg.projects);
|
||||
} else if (msg.type === "session_info") {
|
||||
setStatus("connected", msg.project);
|
||||
document.title = "Clide — " + msg.project;
|
||||
} else if (msg.type === "error") {
|
||||
term.write("\r\n\x1b[31mError: " + msg.message + "\x1b[0m\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Project management ---
|
||||
function populateProjects(projects) {
|
||||
projectSelect.innerHTML = "";
|
||||
projects.forEach(function(p) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
if (p === currentProject) opt.selected = true;
|
||||
projectSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
projectSelect.addEventListener("change", function() {
|
||||
currentProject = projectSelect.value;
|
||||
history.pushState(null, "", "/projects/" + encodeURIComponent(currentProject));
|
||||
document.title = "Clide — " + currentProject;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
term.clear();
|
||||
term.reset();
|
||||
connect();
|
||||
});
|
||||
|
||||
// --- Load projects and connect ---
|
||||
fetch("/api/projects")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
populateProjects(data.projects);
|
||||
// If no project selected, pick first available
|
||||
if (!currentProject && data.projects.length > 0) {
|
||||
currentProject = data.projects[0];
|
||||
projectSelect.value = currentProject;
|
||||
history.replaceState(null, "", "/projects/" + encodeURIComponent(currentProject));
|
||||
}
|
||||
if (currentProject) {
|
||||
connect();
|
||||
} else {
|
||||
setStatus("", "No projects found");
|
||||
term.write("\r\nNo projects found in configured projects directory.\r\n");
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
setStatus("reconnecting", "Server unreachable");
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.11.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-web-links@0.12.0/lib/addon-web-links.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "clide-web"
|
||||
version = "1.0.0"
|
||||
description = "Web server for Clide TUI — replaces ttyd + zellij"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
authors = [
|
||||
{ name = "Jeroen Schweitzer", email = "you@example.com" }
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: FastAPI",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
dependencies = [
|
||||
"clide",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"pydantic-settings>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
"ruff>=0.3.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
clide-web = "clide_web.__main__:main"
|
||||
clide-web-setup = "clide_web.setup_wizard:run_setup"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["clide_web"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["clide_web"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM", "TCH", "PTH", "ASYNC",
|
||||
]
|
||||
ignore = [
|
||||
"E501", "ARG002", "SIM102", "SIM105", "SIM115", "PTH123", "TCH002", "TCH003",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["clide_web"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
@@ -0,0 +1,149 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller spec file for Clide.
|
||||
|
||||
Cross-platform configuration that produces:
|
||||
- macOS: .app bundle (universal2 for Intel + Apple Silicon)
|
||||
- Linux: Single executable (for AppImage packaging)
|
||||
- Windows: Single executable (for Inno Setup packaging)
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Collect data files
|
||||
datas = [
|
||||
('clide/templates', 'clide/templates'), # Skill templates (SKILL.md files)
|
||||
]
|
||||
|
||||
# Hidden imports for dynamic modules
|
||||
hiddenimports = [
|
||||
# Textual internals
|
||||
'textual._context',
|
||||
'textual.css',
|
||||
# Tree-sitter grammars
|
||||
'tree_sitter_python',
|
||||
'tree_sitter_javascript',
|
||||
'tree_sitter_typescript',
|
||||
'tree_sitter_html',
|
||||
'tree_sitter_css',
|
||||
'tree_sitter_json',
|
||||
'tree_sitter_yaml',
|
||||
'tree_sitter_toml',
|
||||
'tree_sitter_markdown',
|
||||
'tree_sitter_rust',
|
||||
'tree_sitter_go',
|
||||
'tree_sitter_java',
|
||||
'tree_sitter_bash',
|
||||
# Watchdog backends
|
||||
'watchdog.observers.fsevents', # macOS
|
||||
'watchdog.observers.inotify', # Linux
|
||||
'watchdog.observers.read_directory_changes', # Windows
|
||||
# Pydantic
|
||||
'pydantic',
|
||||
'pydantic_settings',
|
||||
# Vendored pyte
|
||||
'clide.vendor.pyte',
|
||||
]
|
||||
|
||||
# Exclude dev/test dependencies
|
||||
excludes = [
|
||||
'pytest',
|
||||
'mypy',
|
||||
'ruff',
|
||||
'pre_commit',
|
||||
'pytest_asyncio',
|
||||
'pytest_textual_snapshot',
|
||||
'pytest_cov',
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['clide/__main__.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=excludes,
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, cipher=block_cipher)
|
||||
|
||||
# Platform-specific executable settings
|
||||
if sys.platform == 'darwin':
|
||||
# macOS: Create .app bundle
|
||||
# Use CLIDE_UNIVERSAL=1 env var for universal binary (requires fat Python)
|
||||
import os
|
||||
target_arch = 'universal2' if os.environ.get('CLIDE_UNIVERSAL') else None
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='clide',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False, # UPX breaks macOS code signing
|
||||
console=True,
|
||||
target_arch=target_arch,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
name='Clide',
|
||||
)
|
||||
app = BUNDLE(
|
||||
coll,
|
||||
name='Clide.app',
|
||||
icon=None, # TODO: Add icon.icns
|
||||
bundle_identifier='net.schweitz.clide',
|
||||
info_plist={
|
||||
'CFBundleShortVersionString': '1.0.0',
|
||||
'CFBundleName': 'Clide',
|
||||
'CFBundleDisplayName': 'Clide',
|
||||
'NSHighResolutionCapable': True,
|
||||
'LSEnvironment': {
|
||||
'TERM': 'xterm-256color',
|
||||
},
|
||||
},
|
||||
)
|
||||
elif sys.platform == 'win32':
|
||||
# Windows: Single executable
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='clide',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
icon=None, # TODO: Add icon.ico
|
||||
)
|
||||
else:
|
||||
# Linux: Single executable (for AppImage packaging)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='clide',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
console=True,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Clide - A TUI CLI IDE for Claude Code CLI."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide."""
|
||||
|
||||
from clide.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,863 @@
|
||||
"""Main Textual Application for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header
|
||||
from textual.worker import Worker, WorkerState
|
||||
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
from clide.services.claude_events import (
|
||||
ClaudeEvent,
|
||||
FileEditEvent,
|
||||
FileReadEvent,
|
||||
FileWriteEvent,
|
||||
setup_event_parsing,
|
||||
)
|
||||
from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching
|
||||
from clide.services.settings_service import get_settings_service
|
||||
from clide.services.syntax_service import register_languages
|
||||
from clide.themes.registry import get_all_themes, get_theme
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
|
||||
class ClideApp(App[None]):
|
||||
"""Clide TUI Application - Claude Code IDE.
|
||||
|
||||
Panel architecture:
|
||||
- Sidebar (left): Files, Git, Tree tabs + branch status
|
||||
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
|
||||
- Context (right): Problems, TODOs, Jira tabs
|
||||
|
||||
Workspace is hidden by default. Claude takes full height when
|
||||
workspace is hidden, 40% when visible.
|
||||
"""
|
||||
|
||||
TITLE = "Clide"
|
||||
SUB_TITLE = "Claude Code IDE"
|
||||
|
||||
CSS: ClassVar[str] = """
|
||||
/* Main layout */
|
||||
Screen {
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#center-column {
|
||||
width: 1fr;
|
||||
height: 100%;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
/* Panel styling */
|
||||
SidebarPanel {
|
||||
width: 20%;
|
||||
min-width: 25;
|
||||
max-width: 50;
|
||||
}
|
||||
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
max-width: 50;
|
||||
}
|
||||
|
||||
/* Workspace + Claude layout */
|
||||
WorkspacePanel {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
WorkspacePanel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ClaudePanel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ClaudePanel.with-workspace {
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
/* Compact mode - applied when .compact class is on #main-container */
|
||||
#main-container.compact SidebarPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#main-container.compact ContextPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fullscreen mode */
|
||||
.fullscreen {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
layer: fullscreen;
|
||||
}
|
||||
|
||||
/* Global button styling - outlined look */
|
||||
Button {
|
||||
background: transparent;
|
||||
border: solid $secondary;
|
||||
color: $foreground;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background: $secondary 20%;
|
||||
border: solid $secondary;
|
||||
}
|
||||
|
||||
Button:focus {
|
||||
border: solid $primary;
|
||||
}
|
||||
|
||||
Button.-primary {
|
||||
border: solid $primary;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
Button.-primary:hover {
|
||||
background: $primary 20%;
|
||||
}
|
||||
"""
|
||||
|
||||
# Keybindings
|
||||
# OS-native shortcuts (Ctrl on Windows/Linux, Cmd on Mac mapped to ctrl in terminal)
|
||||
# Alt-based shortcuts for actions that shouldn't interfere with input fields
|
||||
# Note: priority=True ensures bindings work even when widgets have focus
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
# Global
|
||||
Binding("alt+q", "quit", "Quit", priority=True),
|
||||
Binding("alt+p", "command_palette", "Commands"),
|
||||
Binding("alt+o", "quick_open", "Quick Open"),
|
||||
Binding("alt+b", "toggle_sidebar", "Sidebar", priority=True),
|
||||
Binding("alt+shift+b", "toggle_context", "Context", priority=True),
|
||||
Binding("alt+`", "toggle_terminal", "Terminal", priority=True),
|
||||
Binding("alt+c", "toggle_compact", "Compact", priority=True),
|
||||
Binding("f11", "toggle_fullscreen", "Fullscreen"),
|
||||
Binding("escape", "escape", "Escape", show=False),
|
||||
# Navigation
|
||||
Binding("alt+1", "focus_claude", "Claude", show=False, priority=True),
|
||||
Binding("alt+2", "focus_editor", "Editor", show=False, priority=True),
|
||||
Binding("alt+3", "focus_terminal", "Terminal", show=False, priority=True),
|
||||
Binding("alt+0", "focus_sidebar", "Sidebar", show=False, priority=True),
|
||||
Binding("alt+w", "close_tab", "Close Tab", show=False),
|
||||
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
|
||||
# Git
|
||||
Binding("alt+g", "open_git", "Git", show=False, priority=True),
|
||||
# Problems
|
||||
Binding("alt+m", "open_problems", "Problems", show=False, priority=True),
|
||||
Binding("f8", "next_problem", "Next Problem", show=False),
|
||||
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
|
||||
# Editor - OS-native shortcuts with priority
|
||||
Binding("ctrl+s", "save_file", "Save", priority=True),
|
||||
Binding("alt+s", "save_file", "Save", show=False, priority=True),
|
||||
Binding("ctrl+z", "undo", "Undo", show=False, priority=True),
|
||||
# Theme
|
||||
Binding("alt+t", "select_theme", "Theme", priority=True),
|
||||
]
|
||||
|
||||
# Reactive state
|
||||
current_file: reactive[Path | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
fullscreen_panel: reactive[str | None] = reactive(None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
settings: ClideSettings | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.workdir = self._resolve_workdir(workdir)
|
||||
self.settings = settings or ClideSettings()
|
||||
self._test_mode = test_mode
|
||||
|
||||
# User settings persistence
|
||||
self._settings_service = get_settings_service()
|
||||
self._user_settings = self._settings_service.load()
|
||||
|
||||
# Extension manager
|
||||
self.extension_manager = ExtensionManager()
|
||||
|
||||
# Controllers
|
||||
self.git_controller = GitController(self.workdir)
|
||||
self.editor_controller = EditorController()
|
||||
self.diff_controller = DiffController(self.workdir)
|
||||
self.problems_controller = ProblemsController(self.workdir)
|
||||
self.todos_controller = TodosController(self.workdir)
|
||||
# Use settings parameter if jira_enabled explicitly set, otherwise user settings
|
||||
jira_enabled = self.settings.jira_enabled or self._user_settings.jira_enabled
|
||||
self.jira_controller = JiraController(
|
||||
enabled=jira_enabled,
|
||||
)
|
||||
|
||||
# Register themes
|
||||
self._register_themes()
|
||||
|
||||
# Register additional syntax highlighting languages
|
||||
register_languages()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_workdir(workdir: Path | None) -> Path:
|
||||
"""Resolve the working directory to the git root when possible."""
|
||||
import subprocess
|
||||
|
||||
target = workdir.resolve() if workdir and workdir.is_dir() else Path.cwd()
|
||||
|
||||
# Find git root -- anchors to the project root even if cwd is a subdirectory
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=str(target),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
git_root = Path(result.stdout.strip())
|
||||
if git_root.is_dir():
|
||||
return git_root
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
return target
|
||||
|
||||
def _register_themes(self) -> None:
|
||||
"""Register all themes with Textual."""
|
||||
for theme_meta in get_all_themes():
|
||||
theme_def = get_theme(theme_meta.name)
|
||||
if theme_def:
|
||||
self.register_theme(theme_def.to_textual_theme())
|
||||
|
||||
# Set initial theme: settings parameter takes precedence over user settings
|
||||
# Use settings.theme if different from default, otherwise user settings
|
||||
if self.settings.theme != "summer-night":
|
||||
self.theme = self.settings.theme
|
||||
else:
|
||||
self.theme = self._user_settings.theme
|
||||
|
||||
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
|
||||
"""Set the application theme.
|
||||
|
||||
Args:
|
||||
theme_name: Name of the theme to apply
|
||||
save: Whether to persist the setting (default: True)
|
||||
"""
|
||||
self.theme = theme_name
|
||||
if save:
|
||||
self._settings_service.set("theme", theme_name)
|
||||
self.notify(f"Theme set to: {theme_name}", severity="information")
|
||||
|
||||
def save_user_settings(self) -> None:
|
||||
"""Save current user settings to disk."""
|
||||
self._settings_service.update(
|
||||
theme=self.theme,
|
||||
compact_mode=self.compact_mode,
|
||||
sidebar_visible=self.query_one(SidebarPanel).display,
|
||||
context_visible=self.query_one(ContextPanel).display,
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the main layout."""
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left sidebar
|
||||
yield SidebarPanel(workdir=self.workdir)
|
||||
|
||||
# Center column with workspace and claude
|
||||
with Vertical(id="center-column"):
|
||||
yield WorkspacePanel(workdir=self.workdir)
|
||||
yield ClaudePanel(
|
||||
workdir=self.workdir,
|
||||
auto_start=not self._test_mode,
|
||||
)
|
||||
|
||||
# Right context panel
|
||||
yield ContextPanel(
|
||||
jira_enabled=self.settings.jira_enabled,
|
||||
project_path=self.workdir,
|
||||
)
|
||||
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Initialize application on mount."""
|
||||
# Apply saved user settings
|
||||
self._apply_user_settings()
|
||||
|
||||
# Load extensions
|
||||
self.extension_manager.load_extensions()
|
||||
await self.extension_manager.trigger_app_startup(self)
|
||||
|
||||
# Set up file watching for real-time sync
|
||||
self._setup_file_watching()
|
||||
|
||||
# Set up Claude event parsing for IDE integration
|
||||
self._setup_claude_events()
|
||||
|
||||
# Initial data refresh
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
await self._refresh_todos()
|
||||
if self._user_settings.jira_enabled:
|
||||
await self._refresh_jira()
|
||||
|
||||
# Focus Claude panel
|
||||
self.action_focus_claude()
|
||||
|
||||
def _setup_file_watching(self) -> None:
|
||||
"""Set up file system watching for real-time updates."""
|
||||
self._file_watcher = setup_file_watching(
|
||||
self.workdir,
|
||||
handlers=[self._on_file_changed],
|
||||
)
|
||||
|
||||
def _setup_claude_events(self) -> None:
|
||||
"""Set up Claude event parsing for IDE integration."""
|
||||
setup_event_parsing(self._on_claude_event)
|
||||
|
||||
def _on_file_changed(self, event: FileEvent) -> None:
|
||||
"""Handle file system changes by posting a FileEventMessage.
|
||||
|
||||
This bridges the watchdog callback to Textual's message system,
|
||||
allowing widgets to subscribe to file events via on_file_event_message.
|
||||
|
||||
Note: This is called from the watchdog thread, so we use call_from_thread
|
||||
to safely execute on the main thread. The FileEvent (Pydantic model) is
|
||||
thread-safe, but we create the Message on the main thread to avoid any
|
||||
potential Textual threading issues.
|
||||
"""
|
||||
|
||||
def post_file_event():
|
||||
self.post_message(FileEventMessage(event))
|
||||
|
||||
self.call_from_thread(post_file_event)
|
||||
|
||||
async def on_file_event_message(self, message: FileEventMessage) -> None:
|
||||
"""Handle file event messages from the file watcher.
|
||||
|
||||
This is the central handler for all file system events.
|
||||
The App coordinates updates to child widgets since Textual
|
||||
messages bubble up (not down to children).
|
||||
"""
|
||||
event = message.event
|
||||
|
||||
# Ignore files in .clide directory (settings, etc.)
|
||||
if ".clide" in str(event.path):
|
||||
return
|
||||
|
||||
# Trigger extension hooks
|
||||
self.extension_manager.trigger_file_changed(event)
|
||||
|
||||
# Debounce: skip if we refreshed recently (within 1 second)
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
if hasattr(self, "_last_file_refresh") and now - self._last_file_refresh < 1.0:
|
||||
return
|
||||
self._last_file_refresh = now
|
||||
|
||||
# Refresh file tree for created/deleted/moved files
|
||||
if event.event_type in ("created", "deleted", "moved"):
|
||||
try:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.refresh_files()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Use call_later to avoid blocking the event loop during refreshes
|
||||
# This ensures UI responsiveness isn't affected by heavy git operations
|
||||
if event.event_type in ("created", "modified", "deleted", "moved"):
|
||||
self.call_later(self._async_refresh_after_file_change, event)
|
||||
|
||||
async def _async_refresh_after_file_change(self, event: FileEvent) -> None:
|
||||
"""Perform async refreshes after a file change without blocking UI."""
|
||||
# Refresh git status for all file changes
|
||||
await self._refresh_git()
|
||||
|
||||
# Only refresh problems/todos for Python/text files
|
||||
if event.path.suffix in (".py", ".pyi", ".txt", ".md", ".rst"):
|
||||
if event.event_type in ("created", "modified"):
|
||||
await self._refresh_problems()
|
||||
await self._refresh_todos()
|
||||
|
||||
def _on_claude_event(self, event: ClaudeEvent) -> None:
|
||||
"""Handle Claude Code events for IDE integration.
|
||||
|
||||
When Claude reads/edits files, we can update the UI accordingly.
|
||||
"""
|
||||
# Schedule handling on the main thread
|
||||
self.call_later(self._handle_claude_event, event)
|
||||
|
||||
async def _handle_claude_event(self, event: ClaudeEvent) -> None:
|
||||
"""Async handler for Claude events."""
|
||||
if isinstance(event, FileReadEvent):
|
||||
# Claude read a file - highlight in sidebar file tree
|
||||
try:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.highlight_file(event.path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
|
||||
|
||||
elif isinstance(event, FileEditEvent):
|
||||
# Claude edited a file - notify user
|
||||
# Note: Git refresh is handled by FileEventMessage from the file watcher
|
||||
self.notify(f"Claude edited: {event.path.name}", severity="information")
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
|
||||
|
||||
elif isinstance(event, FileWriteEvent):
|
||||
# Claude created/wrote a file - notify user
|
||||
# Note: Git refresh and file tree refresh are handled by
|
||||
# FileEventMessage from the file watcher (event-driven)
|
||||
self.notify(f"Claude wrote: {event.path.name}", severity="information")
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
|
||||
|
||||
def _apply_user_settings(self) -> None:
|
||||
"""Apply saved user settings on startup."""
|
||||
# Panel visibility
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = self._user_settings.sidebar_visible
|
||||
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = self._user_settings.context_visible
|
||||
|
||||
# Compact mode
|
||||
self.compact_mode = self._user_settings.compact_mode
|
||||
|
||||
# Re-apply theme after mount (Textual needs this for proper initialization)
|
||||
if self._user_settings.theme:
|
||||
self.theme = self._user_settings.theme
|
||||
|
||||
# Reactive watchers
|
||||
def watch_workspace_visible(self, visible: bool) -> None:
|
||||
"""Update panels when workspace visibility changes."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
claude = self.query_one(ClaudePanel)
|
||||
|
||||
workspace.visible = visible
|
||||
claude.workspace_visible = visible
|
||||
|
||||
def watch_compact_mode(self, compact: bool) -> None:
|
||||
"""Toggle compact mode class."""
|
||||
container = self.query_one("#main-container")
|
||||
if compact:
|
||||
container.add_class("compact")
|
||||
else:
|
||||
container.remove_class("compact")
|
||||
|
||||
# Data refresh methods
|
||||
async def _refresh_git(self) -> None:
|
||||
"""Refresh git status."""
|
||||
status = await self.git_controller.get_status()
|
||||
if status:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_status(status.staged, status.unstaged)
|
||||
sidebar.current_branch = status.branch
|
||||
|
||||
branches = await self.git_controller.get_branches()
|
||||
if branches:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_branches([b.name for b in branches])
|
||||
|
||||
commits = await self.git_controller.get_log(limit=50)
|
||||
if commits:
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_graph(commits)
|
||||
|
||||
async def _refresh_problems(self) -> None:
|
||||
"""Refresh linter problems."""
|
||||
problems = await self.problems_controller.run_all()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_problems(problems)
|
||||
|
||||
async def _refresh_todos(self) -> None:
|
||||
"""Refresh TODOs."""
|
||||
code_todos, project_todos = await self.todos_controller.scan()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_todos(code_todos, project_todos)
|
||||
|
||||
async def _refresh_jira(self) -> None:
|
||||
"""Refresh Jira content."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.set_jira_loading()
|
||||
content = await self.jira_controller.get_content()
|
||||
if content:
|
||||
context.update_jira(content)
|
||||
else:
|
||||
context.set_jira_error("Failed to load Jira content")
|
||||
|
||||
# Action methods
|
||||
def action_toggle_sidebar(self) -> None:
|
||||
"""Toggle sidebar visibility."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = not sidebar.visible
|
||||
self._settings_service.set("sidebar_visible", sidebar.visible)
|
||||
|
||||
def action_toggle_context(self) -> None:
|
||||
"""Toggle context panel visibility."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = not context.visible
|
||||
self._settings_service.set("context_visible", context.visible)
|
||||
|
||||
def action_toggle_terminal(self) -> None:
|
||||
"""Toggle terminal (shows workspace with terminal tab)."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if self.workspace_visible and workspace.get_active_tab_type() == "terminal":
|
||||
self.workspace_visible = False
|
||||
else:
|
||||
workspace.show_terminal()
|
||||
self.workspace_visible = True
|
||||
|
||||
def action_toggle_compact(self) -> None:
|
||||
"""Toggle compact mode."""
|
||||
self.compact_mode = not self.compact_mode
|
||||
self._settings_service.set("compact_mode", self.compact_mode)
|
||||
|
||||
def action_toggle_fullscreen(self) -> None:
|
||||
"""Toggle fullscreen for focused panel."""
|
||||
# TODO: Implement fullscreen toggle
|
||||
pass
|
||||
|
||||
def action_escape(self) -> None:
|
||||
"""Handle escape key."""
|
||||
if self.fullscreen_panel:
|
||||
self.fullscreen_panel = None
|
||||
elif self.workspace_visible:
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if not workspace.has_unsaved_changes():
|
||||
self.workspace_visible = False
|
||||
|
||||
def action_focus_claude(self) -> None:
|
||||
"""Focus Claude panel."""
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.focus_terminal()
|
||||
|
||||
def action_focus_editor(self) -> None:
|
||||
"""Focus editor."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.focus_last_editor()
|
||||
|
||||
def action_focus_terminal(self) -> None:
|
||||
"""Focus terminal."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.show_terminal()
|
||||
|
||||
def action_focus_sidebar(self) -> None:
|
||||
"""Focus sidebar."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = True
|
||||
sidebar.focus()
|
||||
|
||||
def action_close_tab(self) -> None:
|
||||
"""Close current tab in workspace."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.close_tab()
|
||||
|
||||
def action_open_git(self) -> None:
|
||||
"""Open git panel."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.visible = True
|
||||
sidebar.focus_tab("sidebar-git")
|
||||
|
||||
def action_open_problems(self) -> None:
|
||||
"""Open problems panel."""
|
||||
context = self.query_one(ContextPanel)
|
||||
context.visible = True
|
||||
context.focus_problems()
|
||||
|
||||
def action_next_problem(self) -> None:
|
||||
"""Go to next problem."""
|
||||
# TODO: Implement problem navigation
|
||||
pass
|
||||
|
||||
def action_prev_problem(self) -> None:
|
||||
"""Go to previous problem."""
|
||||
# TODO: Implement problem navigation
|
||||
pass
|
||||
|
||||
def action_save_file(self) -> None:
|
||||
"""Save current file in editor."""
|
||||
try:
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if workspace.has_unsaved_changes():
|
||||
workspace.save_active_editor()
|
||||
else:
|
||||
self.notify("No unsaved changes", severity="warning")
|
||||
except Exception as e:
|
||||
self.notify(f"Save failed: {e}", severity="error")
|
||||
|
||||
def action_undo(self) -> None:
|
||||
"""Undo last action in focused widget."""
|
||||
# Undo is handled by the focused widget (TextArea has built-in undo)
|
||||
# This action provides feedback if no undo is available
|
||||
focused = self.focused
|
||||
if focused and hasattr(focused, "undo"):
|
||||
focused.undo()
|
||||
else:
|
||||
self.notify("Undo not available", severity="warning")
|
||||
|
||||
def action_quick_open(self) -> None:
|
||||
"""Quick file open."""
|
||||
# TODO: Implement quick open
|
||||
pass
|
||||
|
||||
async def action_select_theme(self) -> None:
|
||||
"""Open theme selector."""
|
||||
from clide.themes.registry import get_all_themes
|
||||
|
||||
# Get all available themes
|
||||
themes = get_all_themes()
|
||||
theme_names = [t.name for t in themes]
|
||||
|
||||
# Use Textual's built-in selection if available, otherwise cycle
|
||||
# For now, simple cycle through themes
|
||||
current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0
|
||||
next_idx = (current_idx + 1) % len(theme_names)
|
||||
self.set_theme(theme_names[next_idx])
|
||||
|
||||
# Event handlers for panel messages
|
||||
async def on_sidebar_panel_file_selected(
|
||||
self,
|
||||
event: SidebarPanel.FileSelected,
|
||||
) -> None:
|
||||
"""Handle file selection from sidebar."""
|
||||
self.current_file = event.path
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(event.path)
|
||||
|
||||
async def on_sidebar_panel_git_file_selected(
|
||||
self,
|
||||
event: SidebarPanel.GitFileSelected,
|
||||
) -> None:
|
||||
"""Handle git file selection - show diff."""
|
||||
diff = await self.diff_controller.get_file_diff(
|
||||
str(event.path),
|
||||
staged=event.staged,
|
||||
)
|
||||
if diff:
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.show_diff(diff)
|
||||
|
||||
async def on_sidebar_panel_branch_changed(
|
||||
self,
|
||||
event: SidebarPanel.BranchChanged,
|
||||
) -> None:
|
||||
"""Handle branch change."""
|
||||
success = await self.git_controller.checkout_branch(event.branch)
|
||||
if success:
|
||||
await self._refresh_git()
|
||||
|
||||
async def on_context_panel_problem_clicked(
|
||||
self,
|
||||
event: ContextPanel.ProblemClicked,
|
||||
) -> None:
|
||||
"""Handle problem click - open file at line."""
|
||||
problem = event.problem
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(problem.file_path, line=problem.line)
|
||||
|
||||
async def on_context_panel_todo_clicked(
|
||||
self,
|
||||
event: ContextPanel.TodoClicked,
|
||||
) -> None:
|
||||
"""Handle TODO click - open file at line."""
|
||||
item = event.item
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(item.file_path, line=item.line)
|
||||
|
||||
async def on_context_panel_project_todo_clicked(
|
||||
self,
|
||||
event: ContextPanel.ProjectTodoClicked,
|
||||
) -> None:
|
||||
"""Handle project TODO click - open TODO.md at line."""
|
||||
item = event.item
|
||||
todo_path = self.workdir / "TODO.md"
|
||||
if todo_path.exists():
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(todo_path, line=item.line)
|
||||
|
||||
async def on_context_panel_todo_md_created(
|
||||
self,
|
||||
event: ContextPanel.TodoMdCreated,
|
||||
) -> None:
|
||||
"""Handle TODO.md creation - refresh TODOs and open file."""
|
||||
# Refresh TODOs to pick up the new file
|
||||
await self._refresh_todos()
|
||||
# Open the new file in editor
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(event.path)
|
||||
self.notify("Created TODO.md")
|
||||
|
||||
async def on_context_panel_jira_refresh_requested(
|
||||
self,
|
||||
_event: ContextPanel.JiraRefreshRequested,
|
||||
) -> None:
|
||||
"""Handle Jira refresh request."""
|
||||
await self._refresh_jira()
|
||||
|
||||
async def on_workspace_panel_file_saved(
|
||||
self,
|
||||
event: WorkspacePanel.FileSaved,
|
||||
) -> None:
|
||||
"""Handle file save - refresh problems and git."""
|
||||
self.notify(f"Saved: {event.path.name}", severity="information")
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
|
||||
async def on_workspace_panel_diff_accepted(
|
||||
self,
|
||||
event: WorkspacePanel.DiffAccepted,
|
||||
) -> None:
|
||||
"""Handle diff accept."""
|
||||
await self.diff_controller.accept_proposal(event.file_path)
|
||||
await self._refresh_git()
|
||||
await self._refresh_problems()
|
||||
|
||||
async def on_workspace_panel_diff_rejected(
|
||||
self,
|
||||
event: WorkspacePanel.DiffRejected,
|
||||
) -> None:
|
||||
"""Handle diff reject."""
|
||||
await self.diff_controller.reject_proposal(event.file_path)
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.clear_diff()
|
||||
|
||||
def on_claude_panel_claude_started(
|
||||
self,
|
||||
_event: ClaudePanel.ClaudeStarted,
|
||||
) -> None:
|
||||
"""Handle Claude Code started."""
|
||||
self.notify("Claude Code started", severity="information")
|
||||
|
||||
def on_claude_panel_claude_exited(
|
||||
self,
|
||||
event: ClaudePanel.ClaudeExited,
|
||||
) -> None:
|
||||
"""Handle Claude Code exited."""
|
||||
if event.return_code != 0:
|
||||
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
|
||||
|
||||
def on_workspace_panel_maximize_requested(
|
||||
self,
|
||||
_event: WorkspacePanel.MaximizeRequested,
|
||||
) -> None:
|
||||
"""Handle workspace maximize request - hide sidebars."""
|
||||
# Hide sidebars when workspace is maximized
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
context = self.query_one(ContextPanel)
|
||||
sidebar.display = False
|
||||
context.display = False
|
||||
|
||||
# Hide Claude panel
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.display = False
|
||||
|
||||
def on_workspace_panel_restore_requested(
|
||||
self,
|
||||
_event: WorkspacePanel.RestoreRequested,
|
||||
) -> None:
|
||||
"""Handle workspace restore request - show sidebars."""
|
||||
# Restore sidebars based on saved visibility settings
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
context = self.query_one(ContextPanel)
|
||||
sidebar.display = self._user_settings.sidebar_visible
|
||||
context.display = self._user_settings.context_visible
|
||||
|
||||
# Show Claude panel
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.display = True
|
||||
|
||||
def on_workspace_panel_close_requested(
|
||||
self,
|
||||
_event: WorkspacePanel.CloseRequested,
|
||||
) -> None:
|
||||
"""Handle workspace close request."""
|
||||
self.workspace_visible = False
|
||||
|
||||
def on_sidebar_panel_claude_command_requested(
|
||||
self,
|
||||
event: SidebarPanel.ClaudeCommandRequested,
|
||||
) -> None:
|
||||
"""Handle Claude command request from git panel.
|
||||
|
||||
Sends skill commands (e.g., /commit) to Claude terminal.
|
||||
Ensures the specific skill is installed before sending.
|
||||
"""
|
||||
from clide.services.skill_installer import get_skill_installer
|
||||
|
||||
# Extract skill name from command (e.g., "/commit" -> "commit")
|
||||
skill_name = event.command.lstrip("/").split()[0]
|
||||
|
||||
installer = get_skill_installer(project_dir=self.workdir)
|
||||
|
||||
# Quick check if already installed
|
||||
if installer.is_installed(skill_name):
|
||||
self._send_claude_command(event.command)
|
||||
return
|
||||
|
||||
# Need to install - show notification and do in background
|
||||
self.notify(f"Installing {skill_name} skill...", timeout=10)
|
||||
self._install_skill_and_run(skill_name, event.command)
|
||||
|
||||
@work(thread=True)
|
||||
def _install_skill_and_run(self, skill_name: str, command: str) -> tuple[str, str]:
|
||||
"""Install skill in background thread and return command to run."""
|
||||
self.git_controller._ensure_skill(skill_name)
|
||||
return (skill_name, command)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker completion."""
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Check if this was a skill installation worker
|
||||
if event.worker.name == "_install_skill_and_run":
|
||||
result = event.worker.result
|
||||
if result:
|
||||
skill_name, command = result
|
||||
self._send_claude_command(command)
|
||||
self.notify(f"{skill_name} skill installed!", severity="information", timeout=3)
|
||||
|
||||
def _send_claude_command(self, command: str) -> None:
|
||||
"""Send a command to Claude terminal."""
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.send_input(command)
|
||||
self.action_focus_claude()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Typer CLI entry point for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from clide import __version__
|
||||
|
||||
app = typer.Typer(
|
||||
name="clide",
|
||||
help="A TUI CLI IDE for Claude Code CLI",
|
||||
add_completion=True,
|
||||
no_args_is_help=False,
|
||||
)
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Print version and exit."""
|
||||
if value:
|
||||
typer.echo(f"clide {__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
_version: Annotated[
|
||||
bool | None,
|
||||
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
|
||||
] = None,
|
||||
workdir: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--workdir", "-w", help="Working directory to open"),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Launch Clide TUI application."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
from clide.app import ClideApp
|
||||
|
||||
app_instance = ClideApp(workdir=workdir)
|
||||
app_instance.run()
|
||||
|
||||
|
||||
@app.command()
|
||||
def config() -> None:
|
||||
"""Open configuration editor."""
|
||||
typer.echo("Configuration editor not yet implemented")
|
||||
|
||||
|
||||
@app.command()
|
||||
def extensions() -> None:
|
||||
"""List installed extensions."""
|
||||
typer.echo("Extension manager not yet implemented")
|
||||
|
||||
|
||||
@app.command()
|
||||
def update(
|
||||
check_only: Annotated[
|
||||
bool,
|
||||
typer.Option("--check", "-c", help="Only check for updates, don't install"),
|
||||
] = False,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option("--force", "-f", help="Force update even if already on latest"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Check for and install updates.
|
||||
|
||||
Updates are downloaded from git.schweitz.net releases.
|
||||
User settings in ~/.clide/ are preserved across updates.
|
||||
"""
|
||||
_ = force # TODO: Implement force update functionality
|
||||
from clide.services.update_service import check_for_updates, perform_update
|
||||
|
||||
typer.echo(f"Current version: {__version__}")
|
||||
typer.echo("Checking for updates...")
|
||||
|
||||
if check_only:
|
||||
result = check_for_updates()
|
||||
if result.error:
|
||||
typer.echo(f"Error: {result.error}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if result.update_available:
|
||||
typer.echo(f"Update available: {result.latest_version}")
|
||||
if result.release_info and result.release_info.release_notes:
|
||||
typer.echo("\nRelease notes:")
|
||||
typer.echo(result.release_info.release_notes[:500])
|
||||
else:
|
||||
typer.echo("Already running the latest version.")
|
||||
return
|
||||
|
||||
# Perform update with progress indication
|
||||
def progress_callback(downloaded: int, total: int) -> None:
|
||||
if total > 0:
|
||||
pct = (downloaded / total) * 100
|
||||
typer.echo(f"\rDownloading: {pct:.1f}%", nl=False)
|
||||
|
||||
success, message = perform_update(progress_callback)
|
||||
typer.echo("") # Newline after progress
|
||||
typer.echo(message)
|
||||
|
||||
if not success:
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Domain controllers for Clide."""
|
||||
|
||||
from clide.controllers.base import ControllerMixin, controller
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
|
||||
__all__ = [
|
||||
"controller",
|
||||
"ControllerMixin",
|
||||
"GitController",
|
||||
"EditorController",
|
||||
"DiffController",
|
||||
"ProblemsController",
|
||||
"TodosController",
|
||||
"JiraController",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Base controller utilities using decorator pattern."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
|
||||
def controller[T](cls: type[T]) -> type[T]:
|
||||
"""Decorator to add controller capabilities to a class.
|
||||
|
||||
Adds:
|
||||
- _app attribute for parent application reference
|
||||
- set_app() method to set the application
|
||||
- post_message() method to emit messages
|
||||
- initialize() and shutdown() lifecycle hooks (if not defined)
|
||||
|
||||
Usage:
|
||||
@controller
|
||||
class GitController:
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
...
|
||||
"""
|
||||
original_init = cls.__init__
|
||||
|
||||
@wraps(original_init)
|
||||
def new_init(self, *args, **kwargs):
|
||||
self._app = None
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
cls.__init__ = new_init
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
|
||||
# Only add methods if they don't exist
|
||||
if not hasattr(cls, "set_app"):
|
||||
cls.set_app = set_app
|
||||
if not hasattr(cls, "post_message"):
|
||||
cls.post_message = post_message
|
||||
if not hasattr(cls, "initialize"):
|
||||
cls.initialize = initialize
|
||||
if not hasattr(cls, "shutdown"):
|
||||
cls.shutdown = shutdown
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
class ControllerMixin:
|
||||
"""Mixin alternative for controller capabilities.
|
||||
|
||||
Use this if you prefer inheritance over decorators.
|
||||
|
||||
Usage:
|
||||
class GitController(ControllerMixin):
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
"""
|
||||
|
||||
_app: "App[object] | None" = None
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def app(self) -> "App[object] | None":
|
||||
"""Get the parent application."""
|
||||
return self._app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Diff controller for viewing and managing diffs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
|
||||
@controller
|
||||
class DiffController:
|
||||
"""Controller for diff viewing and Claude-proposed changes."""
|
||||
|
||||
class DiffLoaded(Message):
|
||||
"""Emitted when a diff is loaded."""
|
||||
|
||||
def __init__(self, diff: DiffContent) -> None:
|
||||
self.diff = diff
|
||||
super().__init__()
|
||||
|
||||
class HunkAccepted(Message):
|
||||
"""Emitted when a hunk is accepted."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class HunkRejected(Message):
|
||||
"""Emitted when a hunk is rejected."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class AllChangesAccepted(Message):
|
||||
"""Emitted when all changes are accepted."""
|
||||
pass
|
||||
|
||||
class AllChangesRejected(Message):
|
||||
"""Emitted when all changes are rejected."""
|
||||
pass
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._git_service = GitService(repo_path)
|
||||
self._state = DiffViewState()
|
||||
|
||||
@property
|
||||
def state(self) -> DiffViewState:
|
||||
"""Get diff view state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def diff(self) -> DiffContent | None:
|
||||
"""Get current diff content."""
|
||||
return self._state.diff
|
||||
|
||||
@property
|
||||
def is_proposal(self) -> bool:
|
||||
"""Check if current diff is a Claude proposal."""
|
||||
return self._state.is_proposal
|
||||
|
||||
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Load diff from git.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to load staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None if no diff
|
||||
"""
|
||||
diff_text = await self._git_service.get_diff(path, staged)
|
||||
if not diff_text:
|
||||
return None
|
||||
|
||||
diff = self._parse_diff(path, diff_text)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
|
||||
"""Load a Claude-proposed change as a diff.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
old_content: Original content
|
||||
new_content: Proposed content
|
||||
|
||||
Returns:
|
||||
DiffContent of the proposal
|
||||
"""
|
||||
diff = self._create_diff_from_content(path, old_content, new_content)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = True
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def accept_hunk(self, index: int) -> None:
|
||||
"""Accept a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.accepted_hunks.add(index)
|
||||
self._state.rejected_hunks.discard(index)
|
||||
|
||||
def reject_hunk(self, index: int) -> None:
|
||||
"""Reject a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.rejected_hunks.add(index)
|
||||
self._state.accepted_hunks.discard(index)
|
||||
|
||||
def accept_all(self) -> None:
|
||||
"""Accept all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.accepted_hunks.add(i)
|
||||
self._state.rejected_hunks.clear()
|
||||
|
||||
def reject_all(self) -> None:
|
||||
"""Reject all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.rejected_hunks.add(i)
|
||||
self._state.accepted_hunks.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear current diff."""
|
||||
self._state.diff = None
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
def toggle_side_by_side(self) -> bool:
|
||||
"""Toggle side-by-side view.
|
||||
|
||||
Returns:
|
||||
New side_by_side value
|
||||
"""
|
||||
self._state.side_by_side = not self._state.side_by_side
|
||||
return self._state.side_by_side
|
||||
|
||||
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
|
||||
"""Parse git diff output into DiffContent."""
|
||||
hunks: list[DiffHunk] = []
|
||||
current_hunk_lines: list[DiffLine] = []
|
||||
current_header = ""
|
||||
old_start = old_count = new_start = new_count = 0
|
||||
|
||||
for line in diff_text.split("\n"):
|
||||
if line.startswith("@@"):
|
||||
# Save previous hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
current_hunk_lines = []
|
||||
|
||||
# Parse hunk header
|
||||
current_header = line
|
||||
# Format: @@ -old_start,old_count +new_start,new_count @@
|
||||
import re
|
||||
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
|
||||
if match:
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2)) if match.group(2) else 1
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4)) if match.group(4) else 1
|
||||
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line[1:],
|
||||
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
|
||||
))
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line[1:],
|
||||
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
|
||||
))
|
||||
elif line.startswith(" "):
|
||||
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
|
||||
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line[1:],
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
|
||||
# Save last hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=tuple(hunks),
|
||||
)
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Get diff for a file (alias for load_git_diff).
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None
|
||||
"""
|
||||
return await self.load_git_diff(path, staged)
|
||||
|
||||
async def accept_proposal(self, _file_path: str) -> bool:
|
||||
"""Accept a proposed change and apply it.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._state.diff or not self._state.is_proposal:
|
||||
return False
|
||||
|
||||
self.accept_all()
|
||||
# TODO: Apply the changes to the file
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
async def reject_proposal(self, _file_path: str) -> bool:
|
||||
"""Reject a proposed change.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
self.reject_all()
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
|
||||
"""Create diff from old and new content."""
|
||||
import difflib
|
||||
|
||||
old_lines = old.splitlines(keepends=True)
|
||||
new_lines = new.splitlines(keepends=True)
|
||||
|
||||
diff_lines: list[DiffLine] = []
|
||||
old_num = new_num = 1
|
||||
|
||||
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
|
||||
if tag == "equal":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
old_num += 1
|
||||
new_num += 1
|
||||
elif tag == "delete":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
elif tag == "insert":
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
elif tag == "replace":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1 +1 @@",
|
||||
old_start=1,
|
||||
old_count=len(old_lines),
|
||||
new_start=1,
|
||||
new_count=len(new_lines),
|
||||
lines=tuple(diff_lines),
|
||||
)
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=(hunk,),
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Editor controller for managing open files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer
|
||||
|
||||
|
||||
@controller
|
||||
class EditorController:
|
||||
"""Controller for editor state and file operations."""
|
||||
|
||||
class FileOpened(Message):
|
||||
"""Emitted when a file is opened."""
|
||||
|
||||
def __init__(self, buffer: FileBuffer) -> None:
|
||||
self.buffer = buffer
|
||||
super().__init__()
|
||||
|
||||
class FileClosed(Message):
|
||||
"""Emitted when a file is closed."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when a file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileModified(Message):
|
||||
"""Emitted when file content changes."""
|
||||
|
||||
def __init__(self, path: Path, is_modified: bool) -> None:
|
||||
self.path = path
|
||||
self.is_modified = is_modified
|
||||
super().__init__()
|
||||
|
||||
class ActiveBufferChanged(Message):
|
||||
"""Emitted when active buffer changes."""
|
||||
|
||||
def __init__(self, buffer: FileBuffer | None) -> None:
|
||||
self.buffer = buffer
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path | None = None) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._state = EditorState()
|
||||
|
||||
@property
|
||||
def state(self) -> EditorState:
|
||||
"""Get editor state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def active_buffer(self) -> FileBuffer | None:
|
||||
"""Get currently active buffer."""
|
||||
return self._state.active_buffer
|
||||
|
||||
@property
|
||||
def open_files(self) -> list[FileBuffer]:
|
||||
"""Get list of open file buffers."""
|
||||
return self._state.buffers
|
||||
|
||||
@property
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if any buffer has unsaved changes."""
|
||||
return any(b.is_modified for b in self._state.buffers)
|
||||
|
||||
async def open_file(self, path: Path, line: int | None = None) -> FileBuffer:
|
||||
"""Open a file in the editor.
|
||||
|
||||
Args:
|
||||
path: File path to open
|
||||
line: Optional line number to jump to
|
||||
|
||||
Returns:
|
||||
FileBuffer for the opened file
|
||||
"""
|
||||
# Check if already open
|
||||
existing = self._state.get_buffer_by_path(path)
|
||||
if existing:
|
||||
self._set_active_buffer(existing)
|
||||
if line:
|
||||
existing.cursor = CursorPosition(line=line - 1, column=0)
|
||||
return existing
|
||||
|
||||
# Read file content
|
||||
content = await self._service.read_file(path)
|
||||
language = await self._service.get_language(path)
|
||||
|
||||
cursor = CursorPosition(line=line - 1 if line else 0, column=0)
|
||||
|
||||
buffer = FileBuffer(
|
||||
path=path,
|
||||
content=content,
|
||||
language=language,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
self._state.buffers.append(buffer)
|
||||
self._set_active_buffer(buffer)
|
||||
|
||||
# Add to recent files
|
||||
if path not in self._state.recent_files:
|
||||
self._state.recent_files.insert(0, path)
|
||||
self._state.recent_files = self._state.recent_files[:20]
|
||||
|
||||
return buffer
|
||||
|
||||
async def close_file(self, path: Path) -> bool:
|
||||
"""Close a file buffer.
|
||||
|
||||
Args:
|
||||
path: File path to close
|
||||
|
||||
Returns:
|
||||
True if closed (may be False if unsaved and user cancels)
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if not buffer:
|
||||
return True
|
||||
|
||||
# Remove from buffers
|
||||
self._state.buffers.remove(buffer)
|
||||
|
||||
# Update active buffer
|
||||
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(
|
||||
self._state.buffers
|
||||
):
|
||||
self._state.active_buffer_index = (
|
||||
len(self._state.buffers) - 1 if self._state.buffers else None
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def save_file(self, path: Path | None = None) -> bool:
|
||||
"""Save a file.
|
||||
|
||||
Args:
|
||||
path: File path (defaults to active buffer)
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path) if path else self.active_buffer
|
||||
|
||||
if not buffer:
|
||||
return False
|
||||
|
||||
await self._service.write_file(buffer.path, buffer.content)
|
||||
buffer.is_modified = False
|
||||
|
||||
return True
|
||||
|
||||
async def save_all(self) -> int:
|
||||
"""Save all modified buffers.
|
||||
|
||||
Returns:
|
||||
Number of files saved
|
||||
"""
|
||||
count = 0
|
||||
for buffer in self._state.buffers:
|
||||
if buffer.is_modified:
|
||||
await self._service.write_file(buffer.path, buffer.content)
|
||||
buffer.is_modified = False
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def update_content(self, path: Path, content: str) -> None:
|
||||
"""Update buffer content.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
content: New content
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if buffer:
|
||||
buffer.content = content
|
||||
buffer.is_modified = True
|
||||
|
||||
def update_cursor(self, path: Path, line: int, column: int) -> None:
|
||||
"""Update cursor position.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
line: Line number (0-indexed)
|
||||
column: Column number (0-indexed)
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if buffer:
|
||||
buffer.cursor = CursorPosition(line=line, column=column)
|
||||
|
||||
def set_active_by_index(self, index: int) -> None:
|
||||
"""Set active buffer by index.
|
||||
|
||||
Args:
|
||||
index: Buffer index
|
||||
"""
|
||||
if 0 <= index < len(self._state.buffers):
|
||||
self._state.active_buffer_index = index
|
||||
|
||||
def _set_active_buffer(self, buffer: FileBuffer) -> None:
|
||||
"""Set active buffer."""
|
||||
try:
|
||||
index = self._state.buffers.index(buffer)
|
||||
self._state.active_buffer_index = index
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Git controller for managing git operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.git import GitBranch, GitCommit, GitStatus
|
||||
from clide.services.skill_installer import get_skill_installer
|
||||
|
||||
# Git operations that can be delegated to Claude via skills
|
||||
GitSkillCommand = Literal["commit", "stash", "pull", "push", "branch"]
|
||||
|
||||
|
||||
@controller
|
||||
class GitController:
|
||||
"""Controller for git operations."""
|
||||
|
||||
class StatusUpdated(Message):
|
||||
"""Emitted when git status changes."""
|
||||
|
||||
def __init__(self, status: GitStatus) -> None:
|
||||
self.status = status
|
||||
super().__init__()
|
||||
|
||||
class BranchesUpdated(Message):
|
||||
"""Emitted when branches list changes."""
|
||||
|
||||
def __init__(self, branches: list[GitBranch]) -> None:
|
||||
self.branches = branches
|
||||
super().__init__()
|
||||
|
||||
class LogUpdated(Message):
|
||||
"""Emitted when commit log is refreshed."""
|
||||
|
||||
def __init__(self, commits: list[GitCommit]) -> None:
|
||||
self.commits = commits
|
||||
super().__init__()
|
||||
|
||||
class FileStaged(Message):
|
||||
"""Emitted when a file is staged."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileUnstaged(Message):
|
||||
"""Emitted when a file is unstaged."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._service = GitService(repo_path)
|
||||
self._status: GitStatus | None = None
|
||||
self._branches: list[GitBranch] = []
|
||||
self._commits: list[GitCommit] = []
|
||||
|
||||
@property
|
||||
def status(self) -> GitStatus | None:
|
||||
"""Get current git status."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def branches(self) -> list[GitBranch]:
|
||||
"""Get list of branches."""
|
||||
return self._branches
|
||||
|
||||
@property
|
||||
def commits(self) -> list[GitCommit]:
|
||||
"""Get commit log."""
|
||||
return self._commits
|
||||
|
||||
@property
|
||||
def current_branch(self) -> str:
|
||||
"""Get current branch name."""
|
||||
return self._status.branch if self._status else "unknown"
|
||||
|
||||
async def refresh_status(self) -> GitStatus:
|
||||
"""Refresh git status.
|
||||
|
||||
Returns:
|
||||
Updated GitStatus
|
||||
"""
|
||||
self._status = await self._service.get_status()
|
||||
return self._status
|
||||
|
||||
async def get_status(self) -> GitStatus | None:
|
||||
"""Get git status (refreshes if needed)."""
|
||||
return await self.refresh_status()
|
||||
|
||||
async def refresh_branches(self) -> list[GitBranch]:
|
||||
"""Refresh branches list.
|
||||
|
||||
Returns:
|
||||
Updated list of branches
|
||||
"""
|
||||
self._branches = await self._service.get_branches()
|
||||
return self._branches
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""Get branches list (refreshes if needed)."""
|
||||
return await self.refresh_branches()
|
||||
|
||||
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
|
||||
"""Refresh commit log.
|
||||
|
||||
Args:
|
||||
max_count: Maximum commits to fetch
|
||||
|
||||
Returns:
|
||||
Updated list of commits
|
||||
"""
|
||||
self._commits = await self._service.get_log(max_count)
|
||||
return self._commits
|
||||
|
||||
async def get_log(self, limit: int = 50) -> list[GitCommit]:
|
||||
"""Get commit log (refreshes if needed)."""
|
||||
return await self.refresh_log(limit)
|
||||
|
||||
async def stage_file(self, path: str) -> bool:
|
||||
"""Stage a file.
|
||||
|
||||
Args:
|
||||
path: File path to stage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.stage_file(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def unstage_file(self, path: str) -> bool:
|
||||
"""Unstage a file.
|
||||
|
||||
Args:
|
||||
path: File path to unstage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.unstage_file(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def discard_changes(self, path: str) -> bool:
|
||||
"""Discard changes to a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.discard_changes(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def checkout_branch(self, branch: str) -> bool:
|
||||
"""Checkout a branch.
|
||||
|
||||
Args:
|
||||
branch: Branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.checkout_branch(branch)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
await self.refresh_branches()
|
||||
return success
|
||||
|
||||
async def create_branch(self, name: str) -> bool:
|
||||
"""Create and checkout a new branch.
|
||||
|
||||
Args:
|
||||
name: New branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.create_branch(name)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
await self.refresh_branches()
|
||||
return success
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> str:
|
||||
"""Get diff for a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
Diff string
|
||||
"""
|
||||
return await self._service.get_diff(path, staged)
|
||||
|
||||
# =========================================================================
|
||||
# Claude Skill Integration
|
||||
# =========================================================================
|
||||
|
||||
class ClaudeCommandRequested(Message):
|
||||
"""Emitted when a git command should be sent to Claude."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
def _ensure_skill(self, skill_name: str) -> bool:
|
||||
"""Ensure a specific skill is installed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name (e.g., "commit", "stash").
|
||||
|
||||
Returns:
|
||||
True if skill is available.
|
||||
"""
|
||||
installer = get_skill_installer()
|
||||
|
||||
# Check if already installed
|
||||
if installer.is_installed(skill_name):
|
||||
return True
|
||||
|
||||
# Try to install from template (project scope by default)
|
||||
try:
|
||||
installer.install(skill_name, scope="project")
|
||||
return True
|
||||
except ValueError:
|
||||
# Template not found
|
||||
return False
|
||||
except FileExistsError:
|
||||
# Already exists (race condition)
|
||||
return True
|
||||
|
||||
def _ensure_git_skill(self) -> bool:
|
||||
"""Ensure all git skills are installed (legacy compatibility).
|
||||
|
||||
Returns:
|
||||
True if commit skill is available.
|
||||
"""
|
||||
return self._ensure_skill("commit")
|
||||
|
||||
def request_claude_commit(self) -> bool:
|
||||
"""Request Claude to handle the commit workflow.
|
||||
|
||||
Emits ClaudeCommandRequested with /commit command.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
# The app will handle this message and send to Claude terminal
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_stash(self) -> bool:
|
||||
"""Request Claude to handle stashing changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_pull(self) -> bool:
|
||||
"""Request Claude to handle pulling changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_push(self) -> bool:
|
||||
"""Request Claude to handle pushing changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def get_claude_command(self, action: GitSkillCommand) -> str:
|
||||
"""Get the Claude command string for a git action.
|
||||
|
||||
Args:
|
||||
action: The git action to perform.
|
||||
|
||||
Returns:
|
||||
The command string to send to Claude (e.g., "/commit").
|
||||
"""
|
||||
self._ensure_git_skill()
|
||||
return f"/{action}"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Jira controller for Jira CLI integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
@controller
|
||||
class JiraController:
|
||||
"""Controller for Jira CLI integration."""
|
||||
|
||||
class JiraOutputUpdated(Message):
|
||||
"""Emitted when Jira output is updated."""
|
||||
|
||||
def __init__(self, output: str) -> None:
|
||||
self.output = output
|
||||
super().__init__()
|
||||
|
||||
class JiraError(Message):
|
||||
"""Emitted when Jira command fails."""
|
||||
|
||||
def __init__(self, error: str) -> None:
|
||||
self.error = error
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: Path | None = None,
|
||||
jira_cli_path: str = "jira",
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._jira_cli = jira_cli_path
|
||||
self._enabled = enabled
|
||||
self._last_output: str = ""
|
||||
self._last_error: str = ""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if Jira integration is enabled."""
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def last_output(self) -> str:
|
||||
"""Get last Jira output."""
|
||||
return self._last_output
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
"""Get last error message."""
|
||||
return self._last_error
|
||||
|
||||
def enable(self) -> None:
|
||||
"""Enable Jira integration."""
|
||||
self._enabled = True
|
||||
|
||||
def disable(self) -> None:
|
||||
"""Disable Jira integration."""
|
||||
self._enabled = False
|
||||
|
||||
async def run_command(self, *args: str) -> str:
|
||||
"""Run a Jira CLI command.
|
||||
|
||||
Args:
|
||||
*args: Command arguments
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
if not self._enabled:
|
||||
return "Jira integration is disabled"
|
||||
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, *args)
|
||||
|
||||
if result.success:
|
||||
self._last_output = result.stdout
|
||||
self._last_error = ""
|
||||
return result.stdout
|
||||
else:
|
||||
self._last_error = result.stderr
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
async def list_issues(self, project: str | None = None) -> str:
|
||||
"""List Jira issues.
|
||||
|
||||
Args:
|
||||
project: Optional project key
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
args = ["issue", "list"]
|
||||
if project:
|
||||
args.extend(["--project", project])
|
||||
|
||||
return await self.run_command(*args)
|
||||
|
||||
async def get_issue(self, issue_key: str) -> str:
|
||||
"""Get a specific issue.
|
||||
|
||||
Args:
|
||||
issue_key: Issue key (e.g., PROJ-123)
|
||||
|
||||
Returns:
|
||||
Issue details
|
||||
"""
|
||||
return await self.run_command("issue", "view", issue_key)
|
||||
|
||||
async def get_my_issues(self) -> str:
|
||||
"""Get issues assigned to current user.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("issue", "list", "--assignee", "@me")
|
||||
|
||||
async def get_sprint_issues(self) -> str:
|
||||
"""Get issues in current sprint.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("sprint", "list", "--current")
|
||||
|
||||
async def refresh(self) -> str:
|
||||
"""Refresh Jira data (get my issues).
|
||||
|
||||
Returns:
|
||||
Updated output
|
||||
"""
|
||||
return await self.get_my_issues()
|
||||
|
||||
async def check_available(self) -> bool:
|
||||
"""Check if Jira CLI is available.
|
||||
|
||||
Returns:
|
||||
True if available
|
||||
"""
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, "--version")
|
||||
return result.success
|
||||
|
||||
async def get_content(self) -> str | None:
|
||||
"""Get Jira content for display.
|
||||
|
||||
Returns:
|
||||
Markdown content or None
|
||||
"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
try:
|
||||
return await self.get_my_issues()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Problems controller for linter integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
|
||||
from clide.services.linter_service import LinterService
|
||||
|
||||
|
||||
@controller
|
||||
class ProblemsController:
|
||||
"""Controller for problems/diagnostics from linters."""
|
||||
|
||||
class ProblemsUpdated(Message):
|
||||
"""Emitted when problems list is updated."""
|
||||
|
||||
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
|
||||
self.problems = problems
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class ProblemSelected(Message):
|
||||
"""Emitted when a problem is selected."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
|
||||
self._service = LinterService(project_path)
|
||||
self._linters = linters or ["ruff"]
|
||||
self._state = ProblemsState()
|
||||
|
||||
@property
|
||||
def state(self) -> ProblemsState:
|
||||
"""Get problems state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def problems(self) -> list[Problem]:
|
||||
"""Get list of problems."""
|
||||
return self._state.problems
|
||||
|
||||
@property
|
||||
def summary(self) -> ProblemsSummary:
|
||||
"""Get problems summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Get error count."""
|
||||
return self._state.summary.errors
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
"""Get warning count."""
|
||||
return self._state.summary.warnings
|
||||
|
||||
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Refresh problems from all linters.
|
||||
|
||||
Returns:
|
||||
Tuple of (problems, summary)
|
||||
"""
|
||||
problems, summary = await self._service.run_all(self._linters)
|
||||
|
||||
self._state.problems = problems
|
||||
self._state.summary = summary
|
||||
|
||||
return problems, summary
|
||||
|
||||
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
|
||||
"""Filter problems by severity.
|
||||
|
||||
Args:
|
||||
severity: Severity to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_severity = severity
|
||||
|
||||
if severity is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.severity == severity]
|
||||
|
||||
def filter_by_source(self, source: str | None) -> list[Problem]:
|
||||
"""Filter problems by source linter.
|
||||
|
||||
Args:
|
||||
source: Source to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_source = source
|
||||
|
||||
if source is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.source == source]
|
||||
|
||||
def get_problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of problems for that file
|
||||
"""
|
||||
return self._state.problems_for_file(path)
|
||||
|
||||
def select_problem(self, index: int) -> Problem | None:
|
||||
"""Select a problem by index.
|
||||
|
||||
Args:
|
||||
index: Problem index
|
||||
|
||||
Returns:
|
||||
Selected problem or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.problems):
|
||||
self._state.selected_index = index
|
||||
return self._state.problems[index]
|
||||
return None
|
||||
|
||||
def next_problem(self) -> Problem | None:
|
||||
"""Select next problem.
|
||||
|
||||
Returns:
|
||||
Next problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = 0
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index + 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def prev_problem(self) -> Problem | None:
|
||||
"""Select previous problem.
|
||||
|
||||
Returns:
|
||||
Previous problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = len(self._state.problems) - 1
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index - 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all problems."""
|
||||
self._state.problems = []
|
||||
self._state.summary = ProblemsSummary()
|
||||
self._state.selected_index = None
|
||||
|
||||
async def run_all(self) -> list[Problem]:
|
||||
"""Run all linters and return problems.
|
||||
|
||||
Returns:
|
||||
List of problems
|
||||
"""
|
||||
problems, _ = await self.refresh()
|
||||
return problems
|
||||
@@ -0,0 +1,163 @@
|
||||
"""TODOs controller for tracking TODO comments."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.todos import (
|
||||
ProjectTodoItem,
|
||||
TodoItem,
|
||||
TodosState,
|
||||
TodosSummary,
|
||||
TodoType,
|
||||
)
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
|
||||
@controller
|
||||
class TodosController:
|
||||
"""Controller for TODO/FIXME comment tracking."""
|
||||
|
||||
class TodosUpdated(Message):
|
||||
"""Emitted when TODOs list is updated."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list[TodoItem],
|
||||
project_items: list[ProjectTodoItem],
|
||||
summary: TodosSummary,
|
||||
) -> None:
|
||||
self.items = items
|
||||
self.project_items = project_items
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class TodoSelected(Message):
|
||||
"""Emitted when a TODO is selected."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self._scanner = TodoScanner(project_path)
|
||||
self._state = TodosState()
|
||||
|
||||
@property
|
||||
def state(self) -> TodosState:
|
||||
"""Get TODOs state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def items(self) -> list[TodoItem]:
|
||||
"""Get list of code TODO items."""
|
||||
return self._state.items
|
||||
|
||||
@property
|
||||
def project_items(self) -> list[ProjectTodoItem]:
|
||||
"""Get list of project TODO items from TODO.md."""
|
||||
return self._state.project_items
|
||||
|
||||
@property
|
||||
def summary(self) -> TodosSummary:
|
||||
"""Get TODOs summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def total_count(self) -> int:
|
||||
"""Get total code TODO count."""
|
||||
return self._state.summary.total
|
||||
|
||||
@property
|
||||
def project_count(self) -> int:
|
||||
"""Get total project TODO count."""
|
||||
return self._state.summary.project_total
|
||||
|
||||
async def refresh(
|
||||
self,
|
||||
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
|
||||
"""Refresh TODOs from project.
|
||||
|
||||
Returns:
|
||||
Tuple of (code items, project items, summary)
|
||||
"""
|
||||
items, project_items, summary = await self._scanner.scan()
|
||||
|
||||
self._state.items = items
|
||||
self._state.project_items = project_items
|
||||
self._state.summary = summary
|
||||
|
||||
return items, project_items, summary
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
|
||||
"""Filter TODOs by type.
|
||||
|
||||
Args:
|
||||
todo_type: Type to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of TODOs
|
||||
"""
|
||||
self._state.filter_type = todo_type
|
||||
|
||||
if todo_type is None:
|
||||
return self._state.items
|
||||
|
||||
return self._state.items_by_type(todo_type)
|
||||
|
||||
def get_items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODOs for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of TODOs for that file
|
||||
"""
|
||||
return self._state.items_for_file(path)
|
||||
|
||||
def select_item(self, index: int) -> TodoItem | None:
|
||||
"""Select a TODO by index.
|
||||
|
||||
Args:
|
||||
index: Item index
|
||||
|
||||
Returns:
|
||||
Selected item or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.items):
|
||||
self._state.selected_index = index
|
||||
return self._state.items[index]
|
||||
return None
|
||||
|
||||
def toggle_group_by_file(self) -> bool:
|
||||
"""Toggle grouping by file.
|
||||
|
||||
Returns:
|
||||
New group_by_file value
|
||||
"""
|
||||
self._state.group_by_file = not self._state.group_by_file
|
||||
return self._state.group_by_file
|
||||
|
||||
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
|
||||
"""Get TODOs grouped by file.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to TODO lists
|
||||
"""
|
||||
grouped: dict[Path, list[TodoItem]] = {}
|
||||
for item in self._state.items:
|
||||
if item.file_path not in grouped:
|
||||
grouped[item.file_path] = []
|
||||
grouped[item.file_path].append(item)
|
||||
return grouped
|
||||
|
||||
async def scan(self) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
|
||||
"""Scan for TODOs and return items.
|
||||
|
||||
Returns:
|
||||
Tuple of (code items, project items)
|
||||
"""
|
||||
items, project_items, _ = await self.refresh()
|
||||
return items, project_items
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Extension system for Clide using pluggy."""
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
|
||||
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Built-in extensions for Clide."""
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Pluggy hook specifications for Clide extensions."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
from clide.services.file_watcher import FileEvent
|
||||
|
||||
hookspec = pluggy.HookspecMarker("clide")
|
||||
hookimpl = pluggy.HookimplMarker("clide")
|
||||
|
||||
|
||||
class ClideHookSpec:
|
||||
"""Hook specifications for Clide extensions.
|
||||
|
||||
Extensions implement these hooks to extend functionality.
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_register_panel(self) -> dict[str, Any] | None:
|
||||
"""Register a custom panel for the UI.
|
||||
|
||||
Returns:
|
||||
Dictionary with panel configuration:
|
||||
- name: Panel identifier
|
||||
- widget: Widget class to instantiate
|
||||
- position: "left", "right", or "bottom"
|
||||
- keybinding: Optional keyboard shortcut
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_register_commands(self) -> list[dict[str, Any]] | None:
|
||||
"""Register custom commands for the command palette.
|
||||
|
||||
Returns:
|
||||
List of command dictionaries:
|
||||
- name: Command display name
|
||||
- callback: Async callable to execute
|
||||
- description: Help text
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_app_startup(self, app: "App[object]") -> None:
|
||||
"""Called when the application starts.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_app_shutdown(self, app: "App[object]") -> None:
|
||||
"""Called when the application is shutting down.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_open(self, path: str) -> None:
|
||||
"""Called when a file is opened in the file browser.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the opened file
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_modify_widget(self, widget: "Widget") -> "Widget":
|
||||
"""Modify a widget before it's mounted.
|
||||
|
||||
Args:
|
||||
widget: The widget about to be mounted
|
||||
|
||||
Returns:
|
||||
The modified (or original) widget
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_changed(self, event: "FileEvent") -> None:
|
||||
"""Called when a file is created, modified, deleted, or moved.
|
||||
|
||||
Extensions can use this to:
|
||||
- Refresh TODO scanning
|
||||
- Re-run linters
|
||||
- Update Jira issue links
|
||||
- Trigger custom actions
|
||||
|
||||
Args:
|
||||
event: The file event with path, type, and timestamp
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_saved(self, path: Path) -> None:
|
||||
"""Called after a file is saved by the editor.
|
||||
|
||||
More specific than file_changed - only for user saves.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the saved file
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_claude_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Called when Claude Code performs an action.
|
||||
|
||||
Extensions can use this to react to Claude's actions,
|
||||
such as opening files, making edits, or running commands.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "file_read", "file_edit", "tool_use")
|
||||
data: Event-specific data (e.g., {"path": "/path/to/file"})
|
||||
"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Extension manager for loading and managing Clide extensions."""
|
||||
|
||||
from importlib.metadata import entry_points
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
from clide.services.file_watcher import FileEvent
|
||||
|
||||
EXTENSION_NAMESPACE = "clide.extensions"
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
"""Manages loading and lifecycle of Clide extensions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pm = pluggy.PluginManager("clide")
|
||||
self._pm.add_hookspecs(ClideHookSpec)
|
||||
self._loaded: list[str] = []
|
||||
|
||||
@property
|
||||
def hook(self) -> pluggy.HookRelay:
|
||||
"""Access the hook relay for calling hooks."""
|
||||
return self._pm.hook
|
||||
|
||||
def load_extensions(self) -> None:
|
||||
"""Load all extensions from entry points."""
|
||||
eps = entry_points(group=EXTENSION_NAMESPACE)
|
||||
for ep in eps:
|
||||
try:
|
||||
plugin = ep.load()
|
||||
self._pm.register(plugin, name=ep.name)
|
||||
self._loaded.append(ep.name)
|
||||
except Exception as e:
|
||||
# Log but don't crash on extension load failure
|
||||
print(f"Failed to load extension {ep.name}: {e}")
|
||||
|
||||
def register_plugin(self, plugin: object, name: str) -> None:
|
||||
"""Manually register a plugin instance.
|
||||
|
||||
Args:
|
||||
plugin: Plugin object with hookimpl methods
|
||||
name: Unique name for the plugin
|
||||
"""
|
||||
self._pm.register(plugin, name=name)
|
||||
self._loaded.append(name)
|
||||
|
||||
def unregister_plugin(self, name: str) -> None:
|
||||
"""Unregister a plugin by name.
|
||||
|
||||
Args:
|
||||
name: Name of the plugin to unregister
|
||||
"""
|
||||
plugin = self._pm.get_plugin(name)
|
||||
if plugin:
|
||||
self._pm.unregister(plugin)
|
||||
self._loaded.remove(name)
|
||||
|
||||
def list_extensions(self) -> list[str]:
|
||||
"""Get list of loaded extension names."""
|
||||
return self._loaded.copy()
|
||||
|
||||
async def trigger_app_startup(self, app: "App[object]") -> None:
|
||||
"""Trigger startup hooks for all extensions.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
self.hook.clide_on_app_startup(app=app)
|
||||
|
||||
async def trigger_app_shutdown(self, app: "App[object]") -> None:
|
||||
"""Trigger shutdown hooks for all extensions.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
self.hook.clide_on_app_shutdown(app=app)
|
||||
|
||||
def trigger_file_changed(self, event: "FileEvent") -> None:
|
||||
"""Trigger file change hooks for all extensions.
|
||||
|
||||
Args:
|
||||
event: The file event with path, type, and timestamp
|
||||
"""
|
||||
self.hook.clide_on_file_changed(event=event)
|
||||
|
||||
def trigger_file_saved(self, path: Path) -> None:
|
||||
"""Trigger file saved hooks for all extensions.
|
||||
|
||||
Args:
|
||||
path: Path to the saved file
|
||||
"""
|
||||
self.hook.clide_on_file_saved(path=path)
|
||||
|
||||
def trigger_claude_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Trigger Claude event hooks for all extensions.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "file_read", "file_edit")
|
||||
data: Event-specific data
|
||||
"""
|
||||
self.hook.clide_on_claude_event(event_type=event_type, data=data)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared utility functions and helpers for Clide."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Pydantic models for Clide."""
|
||||
|
||||
from clide.models.config import ClideSettings, PanelConfig
|
||||
from clide.models.db import ConnectionLog, Project, Session, UserPreference
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
|
||||
from clide.models.git import (
|
||||
ChangeStatus,
|
||||
GitBranch,
|
||||
GitChange,
|
||||
GitCommit,
|
||||
GitGraph,
|
||||
GitStatus,
|
||||
)
|
||||
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
||||
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
|
||||
from clide.models.workspace import TAB_ICONS, TabInfo, TabType
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"ClideSettings",
|
||||
"PanelConfig",
|
||||
# Git
|
||||
"ChangeStatus",
|
||||
"GitBranch",
|
||||
"GitChange",
|
||||
"GitCommit",
|
||||
"GitGraph",
|
||||
"GitStatus",
|
||||
# Editor
|
||||
"CursorPosition",
|
||||
"EditorState",
|
||||
"FileBuffer",
|
||||
"Selection",
|
||||
# Diff
|
||||
"ChangeType",
|
||||
"DiffContent",
|
||||
"DiffHunk",
|
||||
"DiffLine",
|
||||
"DiffViewState",
|
||||
# Problems
|
||||
"Problem",
|
||||
"ProblemsSummary",
|
||||
"ProblemsState",
|
||||
"Severity",
|
||||
# Todos
|
||||
"TodoItem",
|
||||
"TodosSummary",
|
||||
"TodosState",
|
||||
"TodoType",
|
||||
# Theme
|
||||
"ThemeColors",
|
||||
"ThemeDefinition",
|
||||
"ThemeMetadata",
|
||||
# Workspace
|
||||
"TabInfo",
|
||||
"TabType",
|
||||
"TAB_ICONS",
|
||||
# Database (SQLModel)
|
||||
"Project",
|
||||
"Session",
|
||||
"UserPreference",
|
||||
"ConnectionLog",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Configuration models using Pydantic Settings."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ClideSettings(BaseSettings):
|
||||
"""Main application settings loaded from environment and config files."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".config/.env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Appearance
|
||||
theme: str = "summer-night" # Default to Summer Night
|
||||
|
||||
# Paths
|
||||
claude_path: str = "claude"
|
||||
default_workdir: Path = Path.cwd()
|
||||
jira_cli_path: str = "jira"
|
||||
|
||||
# Behavior
|
||||
auto_save: bool = True
|
||||
confirm_exit: bool = True
|
||||
|
||||
# Integrations
|
||||
jira_enabled: bool = False
|
||||
confluence_enabled: bool = False
|
||||
imagin_enabled: bool = False
|
||||
|
||||
# Linters
|
||||
linters: list[str] = ["ruff"]
|
||||
|
||||
|
||||
class PanelConfig(BaseModel):
|
||||
"""Panel visibility and layout configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
sidebar_visible: bool = True
|
||||
context_visible: bool = True
|
||||
workspace_visible: bool = False # Hidden by default
|
||||
sidebar_width_percent: int = 20
|
||||
context_width_percent: int = 25
|
||||
|
||||
|
||||
class KeybindingsConfig(BaseModel):
|
||||
"""Keybinding configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
# Global
|
||||
command_palette: str = "ctrl+shift+p"
|
||||
quick_open: str = "ctrl+p"
|
||||
toggle_sidebar: str = "ctrl+b"
|
||||
toggle_context: str = "ctrl+shift+b"
|
||||
toggle_terminal: str = "ctrl+`"
|
||||
toggle_compact: str = "ctrl+shift+c"
|
||||
fullscreen: str = "f11"
|
||||
|
||||
# Navigation
|
||||
focus_claude: str = "ctrl+1"
|
||||
focus_editor: str = "ctrl+2"
|
||||
focus_terminal: str = "ctrl+3"
|
||||
focus_sidebar: str = "ctrl+0"
|
||||
next_tab: str = "ctrl+tab"
|
||||
prev_tab: str = "ctrl+shift+tab"
|
||||
close_tab: str = "ctrl+w"
|
||||
|
||||
# Git
|
||||
git_panel: str = "ctrl+shift+g"
|
||||
stage_file: str = "ctrl+enter"
|
||||
unstage_file: str = "ctrl+backspace"
|
||||
|
||||
# Search
|
||||
find_in_file: str = "ctrl+f"
|
||||
find_in_project: str = "ctrl+shift+f"
|
||||
problems_panel: str = "ctrl+shift+m"
|
||||
next_problem: str = "f8"
|
||||
prev_problem: str = "shift+f8"
|
||||
|
||||
# Editor
|
||||
save: str = "ctrl+s"
|
||||
undo: str = "ctrl+z"
|
||||
redo: str = "ctrl+shift+z"
|
||||
go_to_line: str = "ctrl+g"
|
||||
|
||||
# Theme
|
||||
select_theme: str = "ctrl+k ctrl+t"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SQLModel table models for persistent storage.
|
||||
|
||||
These models serve both the standalone Clide TUI and the clide-web server.
|
||||
They ARE Pydantic models (SQLModel inherits from BaseModel).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Project(SQLModel, table=True):
|
||||
"""A project (git repo) that can be opened in Clide."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str = Field(unique=True, index=True)
|
||||
path: str
|
||||
theme: str = "summer-night"
|
||||
last_accessed: datetime | None = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class Session(SQLModel, table=True):
|
||||
"""A tmux session running a Clide instance (used by clide-web)."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_name: str = Field(index=True)
|
||||
tmux_session: str = Field(unique=True)
|
||||
pid: int | None = None
|
||||
status: str = "active"
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
last_activity: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class UserPreference(SQLModel, table=True):
|
||||
"""Key-value user preferences persisted across restarts."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
key: str = Field(unique=True, index=True)
|
||||
value: str
|
||||
|
||||
|
||||
class ConnectionLog(SQLModel, table=True):
|
||||
"""Audit log of browser connections (used by clide-web)."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_name: str
|
||||
client_ip: str
|
||||
connected_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
disconnected_at: datetime | None = None
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Diff-related Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ChangeType(str, Enum):
|
||||
"""Type of change in a diff line."""
|
||||
|
||||
ADDED = "added"
|
||||
REMOVED = "removed"
|
||||
CONTEXT = "context"
|
||||
HEADER = "header"
|
||||
|
||||
|
||||
class DiffLine(BaseModel):
|
||||
"""A single line in a diff."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
change_type: ChangeType
|
||||
content: str
|
||||
old_line_num: int | None = None
|
||||
new_line_num: int | None = None
|
||||
|
||||
|
||||
class DiffHunk(BaseModel):
|
||||
"""A hunk (section) of a diff."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
header: str
|
||||
old_start: int
|
||||
old_count: int
|
||||
new_start: int
|
||||
new_count: int
|
||||
lines: tuple[DiffLine, ...]
|
||||
|
||||
|
||||
class DiffContent(BaseModel):
|
||||
"""Complete diff for a file."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: str
|
||||
old_path: str | None = None # For renames
|
||||
hunks: tuple[DiffHunk, ...]
|
||||
is_binary: bool = False
|
||||
is_new_file: bool = False
|
||||
is_deleted: bool = False
|
||||
|
||||
|
||||
class DiffViewState(BaseModel):
|
||||
"""State of the diff viewer."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
diff: DiffContent | None = None
|
||||
scroll_offset: int = 0
|
||||
selected_hunk_index: int | None = None
|
||||
side_by_side: bool = True
|
||||
# For Claude-proposed changes
|
||||
is_proposal: bool = False
|
||||
accepted_hunks: set[int] = set()
|
||||
rejected_hunks: set[int] = set()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Editor-related Pydantic models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CursorPosition(BaseModel):
|
||||
"""Cursor position in editor."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
line: int
|
||||
column: int
|
||||
|
||||
|
||||
class Selection(BaseModel):
|
||||
"""Text selection range."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
start: CursorPosition
|
||||
end: CursorPosition
|
||||
|
||||
|
||||
class FileBuffer(BaseModel):
|
||||
"""A file buffer in the editor."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
path: Path
|
||||
content: str
|
||||
language: str | None = None
|
||||
is_modified: bool = False
|
||||
cursor: CursorPosition = CursorPosition(line=0, column=0)
|
||||
selection: Selection | None = None
|
||||
scroll_offset: int = 0
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Get the filename from path."""
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get display name with modification indicator."""
|
||||
prefix = "● " if self.is_modified else ""
|
||||
return f"{prefix}{self.filename}"
|
||||
|
||||
|
||||
class EditorState(BaseModel):
|
||||
"""State of the editor panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
buffers: list[FileBuffer] = []
|
||||
active_buffer_index: int | None = None
|
||||
recent_files: list[Path] = []
|
||||
|
||||
@property
|
||||
def active_buffer(self) -> FileBuffer | None:
|
||||
"""Get currently active buffer."""
|
||||
if self.active_buffer_index is not None and self.buffers:
|
||||
return self.buffers[self.active_buffer_index]
|
||||
return None
|
||||
|
||||
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
|
||||
"""Find a buffer by its file path."""
|
||||
for buffer in self.buffers:
|
||||
if buffer.path == path:
|
||||
return buffer
|
||||
return None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Git-related Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ChangeStatus(str, Enum):
|
||||
"""Git file change status."""
|
||||
|
||||
ADDED = "added"
|
||||
MODIFIED = "modified"
|
||||
DELETED = "deleted"
|
||||
RENAMED = "renamed"
|
||||
COPIED = "copied"
|
||||
UNTRACKED = "untracked"
|
||||
IGNORED = "ignored"
|
||||
UNMERGED = "unmerged"
|
||||
|
||||
|
||||
class GitChange(BaseModel):
|
||||
"""A single file change in git."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
path: str
|
||||
status: ChangeStatus
|
||||
staged: bool
|
||||
old_path: str | None = None # For renames
|
||||
|
||||
|
||||
class GitStatus(BaseModel):
|
||||
"""Current git repository status."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
branch: str
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
staged: tuple[GitChange, ...]
|
||||
unstaged: tuple[GitChange, ...]
|
||||
untracked: tuple[str, ...] = ()
|
||||
has_conflicts: bool = False
|
||||
|
||||
|
||||
class GitBranch(BaseModel):
|
||||
"""Git branch information."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
name: str
|
||||
is_current: bool = False
|
||||
is_remote: bool = False
|
||||
tracking: str | None = None
|
||||
commit_hash: str | None = None
|
||||
commit_message: str | None = None
|
||||
|
||||
|
||||
class GitCommit(BaseModel):
|
||||
"""Git commit information for graph view."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
hash: str
|
||||
short_hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
is_merge: bool = False
|
||||
refs: tuple[str, ...] = () # branch names, tags
|
||||
parents: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class GitGraph(BaseModel):
|
||||
"""Git log graph data."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
commits: tuple[GitCommit, ...]
|
||||
branches: tuple[GitBranch, ...]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Problems (linter errors) Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
"""Problem severity level."""
|
||||
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
HINT = "hint"
|
||||
|
||||
|
||||
class Problem(BaseModel):
|
||||
"""A single linter problem/diagnostic."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: Path
|
||||
line: int
|
||||
column: int
|
||||
end_line: int | None = None
|
||||
end_column: int | None = None
|
||||
severity: Severity
|
||||
message: str
|
||||
source: str # e.g., "ruff", "mypy", "eslint"
|
||||
code: str | None = None # e.g., "E501", "W0612"
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Human-readable location string."""
|
||||
return f"{self.file_path}:{self.line}:{self.column}"
|
||||
|
||||
@property
|
||||
def severity_icon(self) -> str:
|
||||
"""Icon for severity level."""
|
||||
icons = {
|
||||
Severity.ERROR: "✖",
|
||||
Severity.WARNING: "⚠",
|
||||
Severity.INFO: "ℹ",
|
||||
Severity.HINT: "💡",
|
||||
}
|
||||
return icons[self.severity]
|
||||
|
||||
|
||||
class ProblemsSummary(BaseModel):
|
||||
"""Summary of problems in the workspace."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
errors: int = 0
|
||||
warnings: int = 0
|
||||
infos: int = 0
|
||||
hints: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of problems."""
|
||||
return self.errors + self.warnings + self.infos + self.hints
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
if self.errors:
|
||||
return f"⚠ {self.errors}"
|
||||
if self.warnings:
|
||||
return f"⚠ {self.warnings}"
|
||||
return f"✓ {self.total}"
|
||||
|
||||
|
||||
class ProblemsState(BaseModel):
|
||||
"""State of the problems panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
problems: list[Problem] = []
|
||||
summary: ProblemsSummary = ProblemsSummary()
|
||||
filter_severity: Severity | None = None
|
||||
filter_source: str | None = None
|
||||
selected_index: int | None = None
|
||||
|
||||
def problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file."""
|
||||
return [p for p in self.problems if p.file_path == path]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Theme-related Pydantic models."""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.theme import Theme
|
||||
|
||||
|
||||
def validate_hex_color(value: str) -> str:
|
||||
"""Validate hex color format."""
|
||||
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
|
||||
raise ValueError(f"Invalid hex color: {value}")
|
||||
return value.lower()
|
||||
|
||||
|
||||
class ThemeColors(BaseModel):
|
||||
"""Color definitions for a theme."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
# Core colors
|
||||
primary: str
|
||||
secondary: str
|
||||
accent: str
|
||||
|
||||
# Backgrounds
|
||||
background: str
|
||||
surface: str
|
||||
panel: str
|
||||
|
||||
# Text
|
||||
foreground: str
|
||||
|
||||
# Status
|
||||
success: str
|
||||
warning: str
|
||||
error: str
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def validate_colors(cls, v: str) -> str:
|
||||
"""Validate all color fields are valid hex."""
|
||||
return validate_hex_color(v)
|
||||
|
||||
|
||||
class ThemeDefinition(BaseModel):
|
||||
"""Complete theme definition."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str # e.g., "summer-night"
|
||||
display_name: str # e.g., "Summer Night"
|
||||
dark: bool # True for dark themes
|
||||
colors: ThemeColors
|
||||
|
||||
def to_textual_colors(self) -> dict[str, str]:
|
||||
"""Convert to Textual theme color dict."""
|
||||
return {
|
||||
"primary": self.colors.primary,
|
||||
"secondary": self.colors.secondary,
|
||||
"accent": self.colors.accent,
|
||||
"background": self.colors.background,
|
||||
"surface": self.colors.surface,
|
||||
"panel": self.colors.panel,
|
||||
"foreground": self.colors.foreground,
|
||||
"success": self.colors.success,
|
||||
"warning": self.colors.warning,
|
||||
"error": self.colors.error,
|
||||
}
|
||||
|
||||
def to_textual_theme(self) -> "Theme":
|
||||
"""Convert to a Textual Theme object."""
|
||||
from textual.theme import Theme
|
||||
|
||||
return Theme(
|
||||
name=self.name,
|
||||
primary=self.colors.primary,
|
||||
secondary=self.colors.secondary,
|
||||
accent=self.colors.accent,
|
||||
background=self.colors.background,
|
||||
surface=self.colors.surface,
|
||||
panel=self.colors.panel,
|
||||
foreground=self.colors.foreground,
|
||||
success=self.colors.success,
|
||||
warning=self.colors.warning,
|
||||
error=self.colors.error,
|
||||
dark=self.dark,
|
||||
)
|
||||
|
||||
|
||||
class ThemeMetadata(BaseModel):
|
||||
"""Theme metadata for listing themes."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
dark: bool
|
||||
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""TODO comments Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TodoType(str, Enum):
|
||||
"""Type of TODO comment."""
|
||||
|
||||
TODO = "TODO"
|
||||
FIXME = "FIXME"
|
||||
HACK = "HACK"
|
||||
XXX = "XXX"
|
||||
NOTE = "NOTE"
|
||||
BUG = "BUG"
|
||||
OPTIMIZE = "OPTIMIZE"
|
||||
REVIEW = "REVIEW"
|
||||
|
||||
|
||||
class ProjectTodoItem(BaseModel):
|
||||
"""A TODO item from TODO.md file."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
text: str
|
||||
section: str # Top-level section (## heading)
|
||||
subsection: str | None = None # Optional subsection (### heading)
|
||||
line: int # Line number in TODO.md
|
||||
checked: bool = False # Whether the checkbox is checked
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
"""Get full category path."""
|
||||
if self.subsection:
|
||||
return f"{self.section} › {self.subsection}"
|
||||
return self.section
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Icon for display."""
|
||||
return "☑" if self.checked else "☐"
|
||||
|
||||
|
||||
class TodoItem(BaseModel):
|
||||
"""A single TODO comment found in code."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: Path
|
||||
line: int
|
||||
column: int
|
||||
todo_type: TodoType
|
||||
text: str
|
||||
context_line: str # The full line containing the TODO
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Human-readable location string."""
|
||||
return f"{self.file_path}:{self.line}"
|
||||
|
||||
@property
|
||||
def type_icon(self) -> str:
|
||||
"""Icon for TODO type."""
|
||||
icons = {
|
||||
TodoType.TODO: "☐",
|
||||
TodoType.FIXME: "🔧",
|
||||
TodoType.HACK: "⚡",
|
||||
TodoType.XXX: "❗",
|
||||
TodoType.NOTE: "📝",
|
||||
TodoType.BUG: "🐛",
|
||||
TodoType.OPTIMIZE: "⚡",
|
||||
TodoType.REVIEW: "👀",
|
||||
}
|
||||
return icons[self.todo_type]
|
||||
|
||||
|
||||
class TodosSummary(BaseModel):
|
||||
"""Summary of TODOs in the workspace."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
todo_count: int = 0
|
||||
fixme_count: int = 0
|
||||
hack_count: int = 0
|
||||
other_count: int = 0
|
||||
project_todo_count: int = 0 # Count from TODO.md
|
||||
project_done_count: int = 0 # Checked items in TODO.md
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of code TODOs."""
|
||||
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
|
||||
|
||||
@property
|
||||
def project_total(self) -> int:
|
||||
"""Total number of project TODOs."""
|
||||
return self.project_todo_count + self.project_done_count
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
return f"✓{self.total}"
|
||||
|
||||
|
||||
class TodosState(BaseModel):
|
||||
"""State of the TODOs panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
items: list[TodoItem] = []
|
||||
project_items: list[ProjectTodoItem] = [] # Items from TODO.md
|
||||
summary: TodosSummary = TodosSummary()
|
||||
filter_type: TodoType | None = None
|
||||
selected_index: int | None = None
|
||||
group_by_file: bool = True
|
||||
show_completed_project_todos: bool = False # Toggle for checked items
|
||||
|
||||
def items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODO items for a specific file."""
|
||||
return [item for item in self.items if item.file_path == path]
|
||||
|
||||
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Get TODO items of a specific type."""
|
||||
return [item for item in self.items if item.todo_type == todo_type]
|
||||
|
||||
def project_items_by_section(self, section: str) -> list[ProjectTodoItem]:
|
||||
"""Get project TODO items for a specific section."""
|
||||
return [item for item in self.project_items if item.section == section]
|
||||
|
||||
def get_project_sections(self) -> list[str]:
|
||||
"""Get unique sections from project TODOs."""
|
||||
sections: list[str] = []
|
||||
for item in self.project_items:
|
||||
if item.section not in sections:
|
||||
sections.append(item.section)
|
||||
return sections
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Workspace tab models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TabType(str, Enum):
|
||||
"""Types of workspace tabs."""
|
||||
|
||||
EDITOR = "editor"
|
||||
TERMINAL = "terminal"
|
||||
DIFF = "diff"
|
||||
|
||||
|
||||
# Nerd Font icons for each tab type
|
||||
TAB_ICONS: dict[TabType, str] = {
|
||||
TabType.EDITOR: "\uf15c", # nf-fa-file_text_o
|
||||
TabType.TERMINAL: "\uf120", # nf-fa-terminal
|
||||
TabType.DIFF: "\uf440", # nf-oct-diff
|
||||
}
|
||||
|
||||
|
||||
class TabInfo(BaseModel):
|
||||
"""Metadata for a workspace tab."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
tab_id: str
|
||||
tab_type: TabType
|
||||
label: str
|
||||
file_path: Path | None = None
|
||||
is_proposal: bool = False
|
||||
diff_file_path: str | None = None
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Business logic services for Clide."""
|
||||
|
||||
from clide.services.git_service import GitService
|
||||
from clide.services.linter_service import LinterService
|
||||
from clide.services.process_service import ProcessService
|
||||
from clide.services.settings_service import SettingsService, UserSettings, get_settings_service
|
||||
from clide.services.skill_installer import SkillInstaller, get_skill_installer
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
__all__ = [
|
||||
"GitService",
|
||||
"LinterService",
|
||||
"ProcessService",
|
||||
"SettingsService",
|
||||
"SkillInstaller",
|
||||
"TodoScanner",
|
||||
"UserSettings",
|
||||
"get_settings_service",
|
||||
"get_skill_installer",
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Claude Code event detection and parsing.
|
||||
|
||||
This module provides event infrastructure for detecting Claude Code actions
|
||||
from terminal output, enabling tight IDE integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
# Event Types
|
||||
# -----------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaudeEvent:
|
||||
"""Base class for Claude Code events."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileReadEvent(ClaudeEvent):
|
||||
"""Emitted when Claude reads a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEditEvent(ClaudeEvent):
|
||||
"""Emitted when Claude edits a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileWriteEvent(ClaudeEvent):
|
||||
"""Emitted when Claude creates/writes a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobEvent(ClaudeEvent):
|
||||
"""Emitted when Claude searches for files."""
|
||||
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GrepEvent(ClaudeEvent):
|
||||
"""Emitted when Claude searches file contents."""
|
||||
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStartEvent(ClaudeEvent):
|
||||
"""Emitted when Claude starts using a tool."""
|
||||
|
||||
tool_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolEndEvent(ClaudeEvent):
|
||||
"""Emitted when Claude finishes using a tool."""
|
||||
|
||||
tool_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffProposedEvent(ClaudeEvent):
|
||||
"""Emitted when Claude proposes a diff."""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
# Textual Messages
|
||||
# ----------------
|
||||
|
||||
|
||||
class ClaudeEventMessage(Message):
|
||||
"""Textual message wrapper for Claude events."""
|
||||
|
||||
def __init__(self, event: ClaudeEvent) -> None:
|
||||
self.event = event
|
||||
super().__init__()
|
||||
|
||||
|
||||
# Pattern Matching
|
||||
# ----------------
|
||||
|
||||
# Patterns for detecting Claude Code output
|
||||
PATTERNS = {
|
||||
# Tool invocations - Claude Code shows these with bullet points
|
||||
"tool_read": re.compile(r"● Read\(([^)]+)\)"),
|
||||
"tool_edit": re.compile(r"● Edit\(([^)]+)\)"),
|
||||
"tool_write": re.compile(r"● Write\(([^)]+)\)"),
|
||||
"tool_glob": re.compile(r"● Glob\(([^)]+)\)"),
|
||||
"tool_grep": re.compile(r"● Grep\(([^)]+)\)"),
|
||||
# Generic tool pattern
|
||||
"tool_start": re.compile(r"● (\w+)\("),
|
||||
"tool_end": re.compile(r"└─"),
|
||||
# Diff headers
|
||||
"diff_header": re.compile(r"^@@\s*-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*@@", re.MULTILINE),
|
||||
"diff_file": re.compile(r"^(?:---|\+\+\+)\s+([^\s]+)", re.MULTILINE),
|
||||
}
|
||||
|
||||
|
||||
class ClaudeEventParser:
|
||||
"""Parses Claude Code terminal output to detect events.
|
||||
|
||||
This parser is designed to work with raw terminal data fed
|
||||
through the pyte event callback.
|
||||
"""
|
||||
|
||||
def __init__(self, callback: Callable[[ClaudeEvent], None] | None = None) -> None:
|
||||
"""Initialize the event parser.
|
||||
|
||||
Args:
|
||||
callback: Optional callback invoked for each detected event.
|
||||
"""
|
||||
self._callback = callback
|
||||
self._buffer = ""
|
||||
self._current_tool: str | None = None
|
||||
|
||||
def set_callback(self, callback: Callable[[ClaudeEvent], None] | None) -> None:
|
||||
"""Set the event callback."""
|
||||
self._callback = callback
|
||||
|
||||
def feed(self, data: str) -> list[ClaudeEvent]:
|
||||
"""Feed terminal data and return detected events.
|
||||
|
||||
Args:
|
||||
data: Raw terminal data from Claude Code.
|
||||
|
||||
Returns:
|
||||
List of detected events.
|
||||
"""
|
||||
events: list[ClaudeEvent] = []
|
||||
|
||||
# Add to buffer for multi-line matching
|
||||
self._buffer += data
|
||||
|
||||
# Limit buffer size to prevent memory issues
|
||||
if len(self._buffer) > 10000:
|
||||
self._buffer = self._buffer[-5000:]
|
||||
|
||||
# Check for tool invocations
|
||||
for match in PATTERNS["tool_read"].finditer(data):
|
||||
path = Path(match.group(1).strip())
|
||||
events.append(FileReadEvent(path=path))
|
||||
|
||||
for match in PATTERNS["tool_edit"].finditer(data):
|
||||
path = Path(match.group(1).strip())
|
||||
events.append(FileEditEvent(path=path))
|
||||
|
||||
for match in PATTERNS["tool_write"].finditer(data):
|
||||
path = Path(match.group(1).strip())
|
||||
events.append(FileWriteEvent(path=path))
|
||||
|
||||
for match in PATTERNS["tool_glob"].finditer(data):
|
||||
pattern = match.group(1).strip()
|
||||
events.append(GlobEvent(pattern=pattern))
|
||||
|
||||
for match in PATTERNS["tool_grep"].finditer(data):
|
||||
pattern = match.group(1).strip()
|
||||
events.append(GrepEvent(pattern=pattern))
|
||||
|
||||
# Check for generic tool start/end
|
||||
for match in PATTERNS["tool_start"].finditer(data):
|
||||
tool_name = match.group(1)
|
||||
# Don't emit for tools we handle specifically
|
||||
if tool_name not in ("Read", "Edit", "Write", "Glob", "Grep"):
|
||||
events.append(ToolStartEvent(tool_name=tool_name))
|
||||
self._current_tool = tool_name
|
||||
|
||||
if PATTERNS["tool_end"].search(data) and self._current_tool:
|
||||
events.append(ToolEndEvent(tool_name=self._current_tool))
|
||||
self._current_tool = None
|
||||
|
||||
# Check for diff content
|
||||
if PATTERNS["diff_header"].search(self._buffer):
|
||||
# Extract diff content (simplified - real impl would be more sophisticated)
|
||||
events.append(DiffProposedEvent(content=self._buffer))
|
||||
# Clear buffer after detecting diff
|
||||
self._buffer = ""
|
||||
|
||||
# Invoke callback for each event
|
||||
if self._callback:
|
||||
for evt in events:
|
||||
try:
|
||||
self._callback(evt)
|
||||
except Exception:
|
||||
pass # Don't let callback errors propagate
|
||||
|
||||
return events
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset parser state."""
|
||||
self._buffer = ""
|
||||
self._current_tool = None
|
||||
|
||||
|
||||
# Global parser instance for convenience
|
||||
_event_parser: ClaudeEventParser | None = None
|
||||
|
||||
|
||||
def get_event_parser() -> ClaudeEventParser:
|
||||
"""Get the global event parser instance."""
|
||||
global _event_parser
|
||||
if _event_parser is None:
|
||||
_event_parser = ClaudeEventParser()
|
||||
return _event_parser
|
||||
|
||||
|
||||
def setup_event_parsing(callback: Callable[[ClaudeEvent], None]) -> None:
|
||||
"""Set up event parsing with the given callback.
|
||||
|
||||
This should be called during app initialization to wire up
|
||||
the event parser with the terminal stream.
|
||||
"""
|
||||
from clide.vendor import pyte
|
||||
|
||||
parser = get_event_parser()
|
||||
parser.set_callback(callback)
|
||||
|
||||
# Wire up to pyte's event callback
|
||||
# The parser.feed returns events but pyte expects None return
|
||||
def _feed_wrapper(data: str) -> None:
|
||||
parser.feed(data)
|
||||
|
||||
pyte.set_event_callback(_feed_wrapper)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""SQLite database engine and session management.
|
||||
|
||||
Used by both standalone Clide and clide-web. The database file
|
||||
defaults to ~/.clide/clide.db but is configurable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import SQLModel, create_engine
|
||||
|
||||
_engine = None
|
||||
|
||||
DEFAULT_DB_PATH = Path.home() / ".clide" / "clide.db"
|
||||
|
||||
|
||||
def get_engine(db_path: Path | None = None):
|
||||
"""Create or return the SQLAlchemy engine."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
path = db_path or DEFAULT_DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{path}",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def init_db(db_path: Path | None = None) -> None:
|
||||
"""Create all tables if they don't exist."""
|
||||
# Import models so SQLModel registers them
|
||||
import clide.models.db # noqa: F401
|
||||
|
||||
engine = get_engine(db_path)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[DBSession, None, None]:
|
||||
"""Yield a database session. Usable as a FastAPI dependency or context manager."""
|
||||
if _engine is None:
|
||||
raise RuntimeError("Database not initialized — call init_db() first")
|
||||
with DBSession(_engine) as session:
|
||||
yield session
|
||||
@@ -0,0 +1,204 @@
|
||||
"""File operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Language extension mapping for syntax highlighting
|
||||
# Maps file extensions to tree-sitter language identifiers
|
||||
LANGUAGE_MAP: dict[str, str] = {
|
||||
# Python
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".pyw": "python",
|
||||
# JavaScript/TypeScript
|
||||
".js": "javascript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
# Web
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "css",
|
||||
".sass": "css",
|
||||
".less": "css",
|
||||
# Dart/Flutter
|
||||
".dart": "dart",
|
||||
# Data formats
|
||||
".json": "json",
|
||||
".jsonc": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".xml": "xml",
|
||||
# Markdown
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
# Shell
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".fish": "bash",
|
||||
# SQL
|
||||
".sql": "sql",
|
||||
# Other languages
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".hpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".vue": "vue",
|
||||
".svelte": "svelte",
|
||||
".lua": "lua",
|
||||
".r": "r",
|
||||
".R": "r",
|
||||
".swift": "swift",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".scala": "scala",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
}
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for file I/O operations."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
|
||||
# Static methods for simple sync operations (used by EditorPane)
|
||||
@staticmethod
|
||||
def read_file(path: Path) -> str:
|
||||
"""Read file contents synchronously.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
|
||||
Returns:
|
||||
File contents as string
|
||||
"""
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def write_file(path: Path, content: str) -> bool:
|
||||
"""Write content to file synchronously.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def detect_language(path: Path) -> str | None:
|
||||
"""Detect language from file extension.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
Language identifier for tree-sitter or None
|
||||
"""
|
||||
return LANGUAGE_MAP.get(path.suffix.lower())
|
||||
|
||||
# Instance methods for async operations
|
||||
async def read_file_async(self, path: Path) -> str:
|
||||
"""Read file contents asynchronously.
|
||||
|
||||
Args:
|
||||
path: Path to file (relative or absolute)
|
||||
|
||||
Returns:
|
||||
File contents as string
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
|
||||
async def write_file_async(self, path: Path, content: str) -> None:
|
||||
"""Write content to file asynchronously.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
content: Content to write
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
async def file_exists(self, path: Path) -> bool:
|
||||
"""Check if file exists.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if file exists
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
return full_path.exists() and full_path.is_file()
|
||||
|
||||
async def get_language(self, path: Path) -> str | None:
|
||||
"""Detect language from file extension.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
Language identifier or None
|
||||
"""
|
||||
return LANGUAGE_MAP.get(path.suffix.lower())
|
||||
|
||||
def _resolve_path(self, path: Path) -> Path:
|
||||
"""Resolve path relative to project root.
|
||||
|
||||
Args:
|
||||
path: Path to resolve
|
||||
|
||||
Returns:
|
||||
Absolute path
|
||||
"""
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return self.project_path / path
|
||||
|
||||
def list_directory(self, path: Path | None = None) -> list[Path]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
path: Directory path (defaults to project root)
|
||||
|
||||
Returns:
|
||||
List of paths in directory
|
||||
"""
|
||||
dir_path = self._resolve_path(path) if path else self.project_path
|
||||
if not dir_path.is_dir():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for entry in sorted(dir_path.iterdir()):
|
||||
# Skip hidden files and common excludes
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if entry.name in ("__pycache__", "node_modules", ".git"):
|
||||
continue
|
||||
entries.append(entry)
|
||||
|
||||
# Sort: directories first, then files
|
||||
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
return entries
|
||||
@@ -0,0 +1,287 @@
|
||||
"""File system watching service for real-time sync.
|
||||
|
||||
This module provides file system monitoring capabilities for the Clide IDE,
|
||||
enabling reactive updates when files change on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from textual.message import Message
|
||||
|
||||
try:
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirDeletedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileDeletedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
)
|
||||
from watchdog.events import (
|
||||
FileSystemEventHandler as WatchdogHandler,
|
||||
)
|
||||
from watchdog.observers import Observer as WatchdogObserver
|
||||
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
WatchdogObserver = None # type: ignore[misc, assignment]
|
||||
WatchdogHandler = object # type: ignore[misc, assignment]
|
||||
FileCreatedEvent = None # type: ignore[misc, assignment]
|
||||
FileModifiedEvent = None # type: ignore[misc, assignment]
|
||||
FileDeletedEvent = None # type: ignore[misc, assignment]
|
||||
FileMovedEvent = None # type: ignore[misc, assignment]
|
||||
DirCreatedEvent = None # type: ignore[misc, assignment]
|
||||
DirModifiedEvent = None # type: ignore[misc, assignment]
|
||||
DirDeletedEvent = None # type: ignore[misc, assignment]
|
||||
DirMovedEvent = None # type: ignore[misc, assignment]
|
||||
|
||||
|
||||
class FileEvent(BaseModel):
|
||||
"""A file system event.
|
||||
|
||||
Attributes:
|
||||
path: The path to the file/directory that changed.
|
||||
event_type: The type of change that occurred.
|
||||
timestamp: When the event occurred.
|
||||
is_directory: Whether this is a directory event.
|
||||
old_path: For move events, the original path.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
path: Path
|
||||
event_type: Literal["created", "modified", "deleted", "moved"]
|
||||
timestamp: datetime
|
||||
is_directory: bool = False
|
||||
old_path: Path | None = None
|
||||
|
||||
|
||||
class FileEventMessage(Message):
|
||||
"""Textual message for file events."""
|
||||
|
||||
def __init__(self, event: FileEvent) -> None:
|
||||
self.event = event
|
||||
super().__init__()
|
||||
|
||||
|
||||
class FileWatcher:
|
||||
"""Watches a directory for file system changes.
|
||||
|
||||
Uses watchdog for efficient cross-platform file monitoring.
|
||||
Emits FileEvent objects to registered handlers.
|
||||
|
||||
Example:
|
||||
watcher = FileWatcher(Path.cwd())
|
||||
watcher.register_handler(my_handler)
|
||||
watcher.start()
|
||||
# ... later ...
|
||||
watcher.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, ignore_patterns: list[str] | None = None) -> None:
|
||||
"""Initialize the file watcher.
|
||||
|
||||
Args:
|
||||
root: The root directory to watch.
|
||||
ignore_patterns: Glob patterns to ignore (e.g., ["*.pyc", "__pycache__"]).
|
||||
"""
|
||||
self._root = root.resolve()
|
||||
self._ignore_patterns = ignore_patterns or [
|
||||
"*.pyc",
|
||||
"__pycache__",
|
||||
".git",
|
||||
".venv",
|
||||
"venv",
|
||||
"node_modules",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".pytest_cache",
|
||||
"*.egg-info",
|
||||
".clide",
|
||||
".claude",
|
||||
]
|
||||
self._handlers: list[Callable[[FileEvent], None]] = []
|
||||
self._observer: WatchdogObserver | None = None # type: ignore[valid-type]
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if watchdog is available."""
|
||||
return WATCHDOG_AVAILABLE
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the watcher is currently running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
"""Get the root directory being watched."""
|
||||
return self._root
|
||||
|
||||
def register_handler(self, handler: Callable[[FileEvent], None]) -> None:
|
||||
"""Register a handler for file events.
|
||||
|
||||
Args:
|
||||
handler: A callable that accepts a FileEvent.
|
||||
"""
|
||||
if handler not in self._handlers:
|
||||
self._handlers.append(handler)
|
||||
|
||||
def unregister_handler(self, handler: Callable[[FileEvent], None]) -> None:
|
||||
"""Unregister a handler.
|
||||
|
||||
Args:
|
||||
handler: The handler to remove.
|
||||
"""
|
||||
if handler in self._handlers:
|
||||
self._handlers.remove(handler)
|
||||
|
||||
def _should_ignore(self, path: Path) -> bool:
|
||||
"""Check if a path should be ignored based on patterns."""
|
||||
path_str = str(path)
|
||||
for pattern in self._ignore_patterns:
|
||||
# Simple pattern matching - could be enhanced with fnmatch
|
||||
if pattern.startswith("*"):
|
||||
if path_str.endswith(pattern[1:]):
|
||||
return True
|
||||
elif pattern in path_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _emit_event(self, event: FileEvent) -> None:
|
||||
"""Emit an event to all handlers."""
|
||||
if self._should_ignore(event.path):
|
||||
return
|
||||
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(event)
|
||||
except Exception:
|
||||
pass # Don't let handler errors affect other handlers
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start watching for file changes.
|
||||
|
||||
Returns:
|
||||
True if started successfully, False if watchdog is not available.
|
||||
"""
|
||||
if not WATCHDOG_AVAILABLE:
|
||||
return False
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
event_handler = _WatchdogHandler(self)
|
||||
self._observer = WatchdogObserver()
|
||||
self._observer.schedule(event_handler, str(self._root), recursive=True)
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop watching for file changes."""
|
||||
if self._observer is not None:
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=5)
|
||||
self._observer = None
|
||||
self._running = False
|
||||
|
||||
|
||||
class _WatchdogHandler(WatchdogHandler): # type: ignore[misc, valid-type]
|
||||
"""Internal handler for watchdog events."""
|
||||
|
||||
def __init__(self, watcher: FileWatcher) -> None:
|
||||
super().__init__()
|
||||
self._watcher = watcher
|
||||
|
||||
def _create_event(
|
||||
self,
|
||||
src_path: str | bytes,
|
||||
event_type: Literal["created", "modified", "deleted", "moved"],
|
||||
is_directory: bool,
|
||||
dest_path: str | bytes | None = None,
|
||||
) -> FileEvent:
|
||||
"""Create a FileEvent from watchdog event data."""
|
||||
# Watchdog can return bytes or str depending on platform
|
||||
src = src_path.decode() if isinstance(src_path, bytes) else src_path
|
||||
dest = dest_path.decode() if isinstance(dest_path, bytes) else dest_path
|
||||
return FileEvent(
|
||||
path=Path(dest if dest else src),
|
||||
event_type=event_type,
|
||||
timestamp=datetime.now(),
|
||||
is_directory=is_directory,
|
||||
old_path=Path(src) if dest else None,
|
||||
)
|
||||
|
||||
def on_created(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(event.src_path, "created", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_modified(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(event.src_path, "modified", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_deleted(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(event.src_path, "deleted", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_moved(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(
|
||||
event.src_path, "moved", event.is_directory, event.dest_path
|
||||
)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
|
||||
# Global watcher instance
|
||||
_file_watcher: FileWatcher | None = None
|
||||
|
||||
|
||||
def get_file_watcher(root: Path | None = None) -> FileWatcher:
|
||||
"""Get or create the global file watcher.
|
||||
|
||||
Args:
|
||||
root: The root directory to watch. Only used on first call.
|
||||
|
||||
Returns:
|
||||
The FileWatcher instance.
|
||||
"""
|
||||
global _file_watcher
|
||||
if _file_watcher is None:
|
||||
_file_watcher = FileWatcher(root or Path.cwd())
|
||||
return _file_watcher
|
||||
|
||||
|
||||
def setup_file_watching(
|
||||
root: Path,
|
||||
handlers: list[Callable[[FileEvent], None]] | None = None,
|
||||
) -> FileWatcher:
|
||||
"""Set up file watching with optional initial handlers.
|
||||
|
||||
Args:
|
||||
root: The root directory to watch.
|
||||
handlers: Optional list of handlers to register.
|
||||
|
||||
Returns:
|
||||
The configured FileWatcher.
|
||||
"""
|
||||
global _file_watcher
|
||||
_file_watcher = FileWatcher(root)
|
||||
|
||||
if handlers:
|
||||
for handler in handlers:
|
||||
_file_watcher.register_handler(handler)
|
||||
|
||||
_file_watcher.start()
|
||||
return _file_watcher
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Git operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.git import (
|
||||
ChangeStatus,
|
||||
GitBranch,
|
||||
GitChange,
|
||||
GitCommit,
|
||||
GitStatus,
|
||||
)
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class GitService:
|
||||
"""Service for git operations."""
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self.repo_path = repo_path
|
||||
self._process = ProcessService(cwd=repo_path)
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
"""Get current git status.
|
||||
|
||||
Returns:
|
||||
GitStatus with staged/unstaged changes
|
||||
"""
|
||||
# Get porcelain status
|
||||
result = await self._process.run("git", "status", "--porcelain", "-z")
|
||||
|
||||
staged: list[GitChange] = []
|
||||
unstaged: list[GitChange] = []
|
||||
untracked: list[str] = []
|
||||
|
||||
if result.success and result.stdout:
|
||||
entries = result.stdout.split("\0")
|
||||
for entry in entries:
|
||||
if not entry or len(entry) < 3:
|
||||
continue
|
||||
|
||||
index_status = entry[0]
|
||||
worktree_status = entry[1]
|
||||
path = entry[3:]
|
||||
|
||||
# Parse status
|
||||
if index_status == "?":
|
||||
# Untracked files go in both untracked list and unstaged
|
||||
untracked.append(path)
|
||||
unstaged.append(
|
||||
GitChange(
|
||||
path=path,
|
||||
status=ChangeStatus.UNTRACKED,
|
||||
staged=False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if index_status != " ":
|
||||
staged.append(
|
||||
GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(index_status),
|
||||
staged=True,
|
||||
)
|
||||
)
|
||||
if worktree_status != " ":
|
||||
unstaged.append(
|
||||
GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(worktree_status),
|
||||
staged=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Get current branch
|
||||
branch_result = await self._process.run("git", "branch", "--show-current")
|
||||
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
|
||||
|
||||
# Get ahead/behind
|
||||
ahead, behind = await self._get_ahead_behind(branch)
|
||||
|
||||
return GitStatus(
|
||||
branch=branch,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
staged=tuple(staged),
|
||||
unstaged=tuple(unstaged),
|
||||
untracked=tuple(untracked),
|
||||
)
|
||||
|
||||
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
|
||||
"""Get commits ahead/behind upstream."""
|
||||
result = await self._process.run(
|
||||
"git", "rev-list", "--left-right", "--count", f"{branch}...@{{upstream}}"
|
||||
)
|
||||
if result.success:
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]), int(parts[1])
|
||||
return 0, 0
|
||||
|
||||
def _parse_status(self, char: str) -> ChangeStatus:
|
||||
"""Parse git status character to ChangeStatus."""
|
||||
mapping = {
|
||||
"A": ChangeStatus.ADDED,
|
||||
"M": ChangeStatus.MODIFIED,
|
||||
"D": ChangeStatus.DELETED,
|
||||
"R": ChangeStatus.RENAMED,
|
||||
"C": ChangeStatus.COPIED,
|
||||
"?": ChangeStatus.UNTRACKED,
|
||||
"!": ChangeStatus.IGNORED,
|
||||
"U": ChangeStatus.UNMERGED,
|
||||
}
|
||||
return mapping.get(char, ChangeStatus.MODIFIED)
|
||||
|
||||
async def stage_file(self, path: str) -> bool:
|
||||
"""Stage a file.
|
||||
|
||||
Args:
|
||||
path: File path to stage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "add", path)
|
||||
return result.success
|
||||
|
||||
async def unstage_file(self, path: str) -> bool:
|
||||
"""Unstage a file.
|
||||
|
||||
Args:
|
||||
path: File path to unstage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "restore", "--staged", path)
|
||||
return result.success
|
||||
|
||||
async def discard_changes(self, path: str) -> bool:
|
||||
"""Discard changes to a file.
|
||||
|
||||
Args:
|
||||
path: File path to discard
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "restore", path)
|
||||
return result.success
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""Get list of branches.
|
||||
|
||||
Returns:
|
||||
List of GitBranch objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git",
|
||||
"branch",
|
||||
"-a",
|
||||
"--format",
|
||||
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)",
|
||||
)
|
||||
|
||||
branches: list[GitBranch] = []
|
||||
if result.success:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
is_current = line.startswith("*")
|
||||
parts = line[1:].split("|")
|
||||
if len(parts) >= 4:
|
||||
name = parts[0].strip()
|
||||
branches.append(
|
||||
GitBranch(
|
||||
name=name,
|
||||
is_current=is_current,
|
||||
is_remote=name.startswith("remotes/"),
|
||||
tracking=parts[1] or None,
|
||||
commit_hash=parts[2],
|
||||
commit_message=parts[3],
|
||||
)
|
||||
)
|
||||
|
||||
return branches
|
||||
|
||||
async def checkout_branch(self, branch: str) -> bool:
|
||||
"""Checkout a branch.
|
||||
|
||||
Args:
|
||||
branch: Branch name to checkout
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "checkout", branch)
|
||||
return result.success
|
||||
|
||||
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
|
||||
"""Create a new branch.
|
||||
|
||||
Args:
|
||||
name: New branch name
|
||||
start_point: Optional starting commit/branch
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
args = ["git", "checkout", "-b", name]
|
||||
if start_point:
|
||||
args.append(start_point)
|
||||
result = await self._process.run(*args)
|
||||
return result.success
|
||||
|
||||
async def get_diff(self, path: str, staged: bool = False) -> str:
|
||||
"""Get diff for a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
Diff output string
|
||||
"""
|
||||
args = ["git", "diff"]
|
||||
if staged:
|
||||
args.append("--cached")
|
||||
args.append("--")
|
||||
args.append(path)
|
||||
|
||||
result = await self._process.run(*args)
|
||||
return result.stdout if result.success else ""
|
||||
|
||||
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
|
||||
"""Get commit log.
|
||||
|
||||
Args:
|
||||
max_count: Maximum number of commits to return
|
||||
|
||||
Returns:
|
||||
List of GitCommit objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git",
|
||||
"log",
|
||||
f"--max-count={max_count}",
|
||||
"--format=%H|%h|%s|%an|%ar|%P|%D",
|
||||
"--all",
|
||||
)
|
||||
|
||||
commits: list[GitCommit] = []
|
||||
if result.success:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 7:
|
||||
parents = tuple(parts[5].split()) if parts[5] else ()
|
||||
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
|
||||
commits.append(
|
||||
GitCommit(
|
||||
hash=parts[0],
|
||||
short_hash=parts[1],
|
||||
message=parts[2],
|
||||
author=parts[3],
|
||||
date=parts[4],
|
||||
is_merge=len(parents) > 1,
|
||||
parents=parents,
|
||||
refs=refs,
|
||||
)
|
||||
)
|
||||
|
||||
return commits
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Linter integration service."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.problems import Problem, ProblemsSummary, Severity
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class LinterService:
|
||||
"""Service for running linters and parsing output."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
self._process = ProcessService(cwd=project_path)
|
||||
|
||||
async def run_ruff(self) -> list[Problem]:
|
||||
"""Run ruff linter.
|
||||
|
||||
Returns:
|
||||
List of problems found
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"ruff", "check", "--output-format=json", "."
|
||||
)
|
||||
|
||||
problems: list[Problem] = []
|
||||
if result.stdout:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
for item in data:
|
||||
severity = self._ruff_severity(item.get("code", ""))
|
||||
problems.append(Problem(
|
||||
file_path=Path(item["filename"]),
|
||||
line=item["location"]["row"],
|
||||
column=item["location"]["column"],
|
||||
end_line=item.get("end_location", {}).get("row"),
|
||||
end_column=item.get("end_location", {}).get("column"),
|
||||
severity=severity,
|
||||
message=item["message"],
|
||||
source="ruff",
|
||||
code=item.get("code"),
|
||||
))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return problems
|
||||
|
||||
def _ruff_severity(self, code: str) -> Severity:
|
||||
"""Map ruff code to severity."""
|
||||
if code.startswith("E") or code.startswith("F"):
|
||||
return Severity.ERROR
|
||||
if code.startswith("W"):
|
||||
return Severity.WARNING
|
||||
return Severity.INFO
|
||||
|
||||
async def run_mypy(self) -> list[Problem]:
|
||||
"""Run mypy type checker.
|
||||
|
||||
Returns:
|
||||
List of problems found
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"mypy", "--output=json", "."
|
||||
)
|
||||
|
||||
problems: list[Problem] = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
severity = self._mypy_severity(data.get("severity", "error"))
|
||||
problems.append(Problem(
|
||||
file_path=Path(data["file"]),
|
||||
line=data["line"],
|
||||
column=data.get("column", 1),
|
||||
severity=severity,
|
||||
message=data["message"],
|
||||
source="mypy",
|
||||
code=data.get("code"),
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
return problems
|
||||
|
||||
def _mypy_severity(self, severity: str) -> Severity:
|
||||
"""Map mypy severity to Severity enum."""
|
||||
mapping = {
|
||||
"error": Severity.ERROR,
|
||||
"warning": Severity.WARNING,
|
||||
"note": Severity.INFO,
|
||||
}
|
||||
return mapping.get(severity, Severity.ERROR)
|
||||
|
||||
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Run all configured linters.
|
||||
|
||||
Args:
|
||||
linters: List of linter names to run
|
||||
|
||||
Returns:
|
||||
Tuple of (problems list, summary)
|
||||
"""
|
||||
all_problems: list[Problem] = []
|
||||
|
||||
for linter in linters:
|
||||
if linter == "ruff":
|
||||
all_problems.extend(await self.run_ruff())
|
||||
elif linter == "mypy":
|
||||
all_problems.extend(await self.run_mypy())
|
||||
|
||||
# Create summary
|
||||
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
|
||||
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
|
||||
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
|
||||
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
|
||||
|
||||
summary = ProblemsSummary(
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos,
|
||||
hints=hints,
|
||||
)
|
||||
|
||||
return all_problems, summary
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Generic subprocess management service."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution."""
|
||||
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Check if command succeeded."""
|
||||
return self.returncode == 0
|
||||
|
||||
|
||||
class ProcessService:
|
||||
"""Service for running subprocess commands."""
|
||||
|
||||
def __init__(self, cwd: Path | None = None) -> None:
|
||||
self.cwd = cwd or Path.cwd()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*args: str,
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run a command asynchronously.
|
||||
|
||||
Args:
|
||||
*args: Command and arguments
|
||||
cwd: Working directory (defaults to service cwd)
|
||||
timeout: Timeout in seconds
|
||||
env: Environment variables to add
|
||||
|
||||
Returns:
|
||||
CommandResult with stdout, stderr, and returncode
|
||||
"""
|
||||
working_dir = cwd or self.cwd
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
returncode=process.returncode or 0,
|
||||
stdout=stdout.decode("utf-8", errors="replace"),
|
||||
stderr=stderr.decode("utf-8", errors="replace"),
|
||||
)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Command timed out",
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
|
||||
def run_sync(
|
||||
self,
|
||||
*args: str,
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
) -> CommandResult:
|
||||
"""Run a command synchronously (for use in threads).
|
||||
|
||||
Args:
|
||||
*args: Command and arguments
|
||||
cwd: Working directory
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
CommandResult with stdout, stderr, and returncode
|
||||
"""
|
||||
working_dir = cwd or self.cwd
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
cwd=working_dir,
|
||||
timeout=timeout,
|
||||
text=True,
|
||||
)
|
||||
return CommandResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Command timed out",
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Settings persistence service for user preferences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
"""User settings that persist across sessions.
|
||||
|
||||
Stored in ~/.clide/settings.json
|
||||
"""
|
||||
|
||||
# Appearance
|
||||
theme: str = "summer-night"
|
||||
|
||||
# Panel state
|
||||
sidebar_visible: bool = True
|
||||
context_visible: bool = True
|
||||
|
||||
# Window
|
||||
compact_mode: bool = False
|
||||
|
||||
# Behavior
|
||||
auto_save: bool = True
|
||||
confirm_exit: bool = True
|
||||
|
||||
# Integrations
|
||||
jira_enabled: bool = False
|
||||
|
||||
# Debug
|
||||
terminal_debug: bool = False # Verbose terminal/pyte logging to ~/.clide/terminal_debug.log
|
||||
|
||||
|
||||
class SettingsService:
|
||||
"""Service for loading and saving user settings.
|
||||
|
||||
Settings are stored in ~/.clide/settings.json
|
||||
"""
|
||||
|
||||
def __init__(self, settings_dir: Path | None = None) -> None:
|
||||
"""Initialize the settings service.
|
||||
|
||||
Args:
|
||||
settings_dir: Override the settings directory (default: ~/.clide)
|
||||
"""
|
||||
self._settings_dir = settings_dir or Path.home() / ".clide"
|
||||
self._settings_file = self._settings_dir / "settings.json"
|
||||
self._settings: UserSettings | None = None
|
||||
|
||||
@property
|
||||
def settings_dir(self) -> Path:
|
||||
"""Get the settings directory path."""
|
||||
return self._settings_dir
|
||||
|
||||
@property
|
||||
def settings_file(self) -> Path:
|
||||
"""Get the settings file path."""
|
||||
return self._settings_file
|
||||
|
||||
def load(self) -> UserSettings:
|
||||
"""Load settings from disk, creating defaults if needed.
|
||||
|
||||
Returns:
|
||||
The loaded or default UserSettings
|
||||
"""
|
||||
if self._settings is not None:
|
||||
return self._settings
|
||||
|
||||
if self._settings_file.exists():
|
||||
try:
|
||||
data = json.loads(self._settings_file.read_text())
|
||||
self._settings = UserSettings.model_validate(data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Invalid JSON or schema, use defaults
|
||||
self._settings = UserSettings()
|
||||
else:
|
||||
self._settings = UserSettings()
|
||||
|
||||
return self._settings
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save current settings to disk."""
|
||||
if self._settings is None:
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
self._settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write settings as formatted JSON
|
||||
data = self._settings.model_dump(mode="json")
|
||||
self._settings_file.write_text(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a setting value.
|
||||
|
||||
Args:
|
||||
key: The setting key (attribute name)
|
||||
default: Default value if key doesn't exist
|
||||
|
||||
Returns:
|
||||
The setting value or default
|
||||
"""
|
||||
settings = self.load()
|
||||
return getattr(settings, key, default)
|
||||
|
||||
def set(self, key: str, value: Any, *, save: bool = True) -> None:
|
||||
"""Set a setting value.
|
||||
|
||||
Args:
|
||||
key: The setting key (attribute name)
|
||||
value: The value to set
|
||||
save: Whether to save immediately (default: True)
|
||||
"""
|
||||
settings = self.load()
|
||||
if hasattr(settings, key):
|
||||
# Create new settings with updated value
|
||||
data = settings.model_dump()
|
||||
data[key] = value
|
||||
self._settings = UserSettings.model_validate(data)
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update(self, **kwargs: Any) -> None:
|
||||
"""Update multiple settings at once.
|
||||
|
||||
Args:
|
||||
**kwargs: Key-value pairs to update
|
||||
"""
|
||||
settings = self.load()
|
||||
data = settings.model_dump()
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(settings, key):
|
||||
data[key] = value
|
||||
|
||||
self._settings = UserSettings.model_validate(data)
|
||||
self.save()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset settings to defaults."""
|
||||
self._settings = UserSettings()
|
||||
self.save()
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_settings_service: SettingsService | None = None
|
||||
|
||||
|
||||
def get_settings_service() -> SettingsService:
|
||||
"""Get the global settings service instance."""
|
||||
global _settings_service
|
||||
if _settings_service is None:
|
||||
_settings_service = SettingsService()
|
||||
return _settings_service
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Skill installer service for Claude Code skills.
|
||||
|
||||
This module provides functionality to install skill templates
|
||||
into the user's Claude Code configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
# Path to bundled skill templates within Clide package
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "skills"
|
||||
|
||||
# Default installation locations
|
||||
USER_SKILLS_DIR = Path.home() / ".claude" / "skills"
|
||||
|
||||
|
||||
class SkillInstaller:
|
||||
"""Installs Claude Code skills from templates.
|
||||
|
||||
Skills can be installed to:
|
||||
- User level: ~/.claude/skills/ (available globally)
|
||||
- Project level: .claude/skills/ (available in project only)
|
||||
|
||||
Example:
|
||||
installer = SkillInstaller()
|
||||
|
||||
# Check if skill exists
|
||||
if not installer.is_installed("git-workflow"):
|
||||
installer.install("git-workflow")
|
||||
|
||||
# Install to project instead of user
|
||||
installer.install("git-workflow", scope="project")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
templates_dir: Path | None = None,
|
||||
project_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the skill installer.
|
||||
|
||||
Args:
|
||||
templates_dir: Override the templates directory.
|
||||
project_dir: Project directory for project-scoped skills.
|
||||
"""
|
||||
self._templates_dir = templates_dir or TEMPLATES_DIR
|
||||
self._project_dir = project_dir or Path.cwd()
|
||||
|
||||
@property
|
||||
def templates_dir(self) -> Path:
|
||||
"""Get the templates directory."""
|
||||
return self._templates_dir
|
||||
|
||||
@property
|
||||
def user_skills_dir(self) -> Path:
|
||||
"""Get the user skills directory."""
|
||||
return USER_SKILLS_DIR
|
||||
|
||||
@property
|
||||
def project_skills_dir(self) -> Path:
|
||||
"""Get the project skills directory."""
|
||||
return self._project_dir / ".claude" / "skills"
|
||||
|
||||
def list_available_templates(self) -> list[str]:
|
||||
"""List all available skill templates.
|
||||
|
||||
Returns:
|
||||
List of skill names that can be installed.
|
||||
"""
|
||||
if not self._templates_dir.exists():
|
||||
return []
|
||||
|
||||
return [
|
||||
d.name
|
||||
for d in self._templates_dir.iterdir()
|
||||
if d.is_dir() and (d / "SKILL.md").exists()
|
||||
]
|
||||
|
||||
def list_installed_skills(
|
||||
self,
|
||||
scope: Literal["user", "project", "all"] = "all",
|
||||
) -> list[dict[str, str]]:
|
||||
"""List installed skills.
|
||||
|
||||
Args:
|
||||
scope: Which skills to list - user, project, or all.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'name', 'scope', and 'path' keys.
|
||||
"""
|
||||
skills = []
|
||||
|
||||
if scope in ("user", "all"):
|
||||
if self.user_skills_dir.exists():
|
||||
for d in self.user_skills_dir.iterdir():
|
||||
if d.is_dir() and (d / "SKILL.md").exists():
|
||||
skills.append(
|
||||
{
|
||||
"name": d.name,
|
||||
"scope": "user",
|
||||
"path": str(d),
|
||||
}
|
||||
)
|
||||
|
||||
if scope in ("project", "all"):
|
||||
if self.project_skills_dir.exists():
|
||||
for d in self.project_skills_dir.iterdir():
|
||||
if d.is_dir() and (d / "SKILL.md").exists():
|
||||
skills.append(
|
||||
{
|
||||
"name": d.name,
|
||||
"scope": "project",
|
||||
"path": str(d),
|
||||
}
|
||||
)
|
||||
|
||||
return skills
|
||||
|
||||
def is_installed(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project", "any"] = "any",
|
||||
) -> bool:
|
||||
"""Check if a skill is installed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name to check.
|
||||
scope: Where to check - user, project, or any.
|
||||
|
||||
Returns:
|
||||
True if the skill is installed.
|
||||
"""
|
||||
if scope in ("user", "any"):
|
||||
user_skill = self.user_skills_dir / skill_name / "SKILL.md"
|
||||
if user_skill.exists():
|
||||
return True
|
||||
|
||||
if scope in ("project", "any"):
|
||||
project_skill = self.project_skills_dir / skill_name / "SKILL.md"
|
||||
if project_skill.exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_skill_path(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project", "any"] = "any",
|
||||
) -> Path | None:
|
||||
"""Get the path to an installed skill.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name.
|
||||
scope: Where to look - user, project, or any (project takes priority).
|
||||
|
||||
Returns:
|
||||
Path to the skill directory, or None if not found.
|
||||
"""
|
||||
# Project scope takes priority when scope is "any"
|
||||
if scope in ("project", "any"):
|
||||
project_skill = self.project_skills_dir / skill_name
|
||||
if (project_skill / "SKILL.md").exists():
|
||||
return project_skill
|
||||
|
||||
if scope in ("user", "any"):
|
||||
user_skill = self.user_skills_dir / skill_name
|
||||
if (user_skill / "SKILL.md").exists():
|
||||
return user_skill
|
||||
|
||||
return None
|
||||
|
||||
def install(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project"] = "user",
|
||||
overwrite: bool = False,
|
||||
) -> Path:
|
||||
"""Install a skill from templates.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name to install.
|
||||
scope: Where to install - user or project level.
|
||||
overwrite: Whether to overwrite existing installation.
|
||||
|
||||
Returns:
|
||||
Path to the installed skill.
|
||||
|
||||
Raises:
|
||||
ValueError: If skill template doesn't exist.
|
||||
FileExistsError: If skill exists and overwrite is False.
|
||||
"""
|
||||
# Check template exists
|
||||
template_dir = self._templates_dir / skill_name
|
||||
if not template_dir.exists() or not (template_dir / "SKILL.md").exists():
|
||||
raise ValueError(f"Skill template '{skill_name}' not found")
|
||||
|
||||
# Determine target directory
|
||||
if scope == "user":
|
||||
target_dir = self.user_skills_dir / skill_name
|
||||
else:
|
||||
target_dir = self.project_skills_dir / skill_name
|
||||
|
||||
# Check if already exists
|
||||
if target_dir.exists():
|
||||
if not overwrite:
|
||||
raise FileExistsError(f"Skill '{skill_name}' already installed at {target_dir}")
|
||||
shutil.rmtree(target_dir)
|
||||
|
||||
# Create parent directory
|
||||
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy template
|
||||
shutil.copytree(template_dir, target_dir)
|
||||
|
||||
return target_dir
|
||||
|
||||
def uninstall(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project"] = "user",
|
||||
) -> bool:
|
||||
"""Uninstall a skill.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name to uninstall.
|
||||
scope: Where to uninstall from - user or project level.
|
||||
|
||||
Returns:
|
||||
True if skill was uninstalled, False if it wasn't installed.
|
||||
"""
|
||||
if scope == "user":
|
||||
skill_dir = self.user_skills_dir / skill_name
|
||||
else:
|
||||
skill_dir = self.project_skills_dir / skill_name
|
||||
|
||||
if skill_dir.exists():
|
||||
shutil.rmtree(skill_dir)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def ensure_installed(
|
||||
self,
|
||||
skill_name: str,
|
||||
scope: Literal["user", "project"] = "project",
|
||||
) -> Path:
|
||||
"""Ensure a skill is installed, installing if needed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name.
|
||||
scope: Where to install if not present (default: project).
|
||||
|
||||
Returns:
|
||||
Path to the skill directory.
|
||||
|
||||
Raises:
|
||||
ValueError: If skill template doesn't exist.
|
||||
"""
|
||||
existing = self.get_skill_path(skill_name)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
return self.install(skill_name, scope=scope)
|
||||
|
||||
|
||||
# Global instance
|
||||
_skill_installer: SkillInstaller | None = None
|
||||
|
||||
|
||||
def get_skill_installer(project_dir: Path | None = None) -> SkillInstaller:
|
||||
"""Get or create the global skill installer.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory (only used on first call).
|
||||
|
||||
Returns:
|
||||
The SkillInstaller instance.
|
||||
"""
|
||||
global _skill_installer
|
||||
if _skill_installer is None:
|
||||
_skill_installer = SkillInstaller(project_dir=project_dir)
|
||||
return _skill_installer
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Syntax highlighting service for additional language support.
|
||||
|
||||
Textual 7.x includes built-in support for many languages when tree-sitter
|
||||
packages are installed. This module provides utilities for checking and
|
||||
registering additional languages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Languages supported by Textual's TextArea with tree-sitter packages
|
||||
SUPPORTED_LANGUAGES = {
|
||||
# Core web languages
|
||||
"python",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
# Markup/config
|
||||
"markdown",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
# Shell
|
||||
"bash",
|
||||
# SQL
|
||||
"sql",
|
||||
# Systems languages
|
||||
"rust",
|
||||
"go",
|
||||
"java",
|
||||
# Regex
|
||||
"regex",
|
||||
}
|
||||
|
||||
# Additional languages that may be registered if packages are available
|
||||
OPTIONAL_LANGUAGES = [
|
||||
"dart",
|
||||
"kotlin",
|
||||
"swift",
|
||||
"scala",
|
||||
"ruby",
|
||||
"php",
|
||||
"lua",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"elixir",
|
||||
"haskell",
|
||||
"ocaml",
|
||||
"zig",
|
||||
"nim",
|
||||
"vue",
|
||||
"svelte",
|
||||
]
|
||||
|
||||
|
||||
def register_languages() -> list[str]:
|
||||
"""Register additional languages with Textual's TextArea.
|
||||
|
||||
In Textual 7.x, languages are automatically registered when tree-sitter
|
||||
packages are installed. This function registers additional languages
|
||||
that need special handling (like TypeScript which has separate functions).
|
||||
|
||||
Returns:
|
||||
List of successfully registered language names
|
||||
"""
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
except ImportError:
|
||||
logger.warning("Textual not available")
|
||||
return []
|
||||
|
||||
registered = []
|
||||
|
||||
# Register TypeScript and TSX (they have special language function names)
|
||||
try:
|
||||
import tree_sitter_typescript as tst
|
||||
|
||||
# Register TypeScript
|
||||
try:
|
||||
TextArea.register_language(tst.language_typescript(), "typescript")
|
||||
registered.append("typescript")
|
||||
logger.debug("Registered language: typescript")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not register typescript: {e}")
|
||||
|
||||
# Register TSX
|
||||
try:
|
||||
TextArea.register_language(tst.language_tsx(), "tsx")
|
||||
registered.append("tsx")
|
||||
logger.debug("Registered language: tsx")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not register tsx: {e}")
|
||||
|
||||
except ImportError:
|
||||
logger.debug("tree-sitter-typescript not installed")
|
||||
|
||||
# Register other optional languages with standard API
|
||||
for lang_name in OPTIONAL_LANGUAGES:
|
||||
try:
|
||||
# Try to import the tree-sitter package for this language
|
||||
module_name = f"tree_sitter_{lang_name}"
|
||||
module = __import__(module_name)
|
||||
|
||||
# Get the language function
|
||||
if hasattr(module, "language"):
|
||||
language = module.language()
|
||||
|
||||
# Try to get a highlight query if available
|
||||
highlight_query = None
|
||||
if hasattr(module, "HIGHLIGHTS_QUERY"):
|
||||
highlight_query = module.HIGHLIGHTS_QUERY
|
||||
|
||||
# Register with Textual
|
||||
try:
|
||||
TextArea.register_language(language, lang_name, highlight_query)
|
||||
registered.append(lang_name)
|
||||
logger.debug(f"Registered language: {lang_name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not register language '{lang_name}': {e}")
|
||||
|
||||
except ImportError:
|
||||
# Package not installed, skip
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Error processing language '{lang_name}': {e}")
|
||||
|
||||
return registered
|
||||
|
||||
|
||||
def get_available_languages() -> list[str]:
|
||||
"""Get list of all available languages for syntax highlighting.
|
||||
|
||||
Returns:
|
||||
List of language names that can be used with TextArea
|
||||
"""
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
|
||||
# Create a temporary instance to check available languages
|
||||
ta = TextArea()
|
||||
return sorted(ta.available_languages)
|
||||
except ImportError:
|
||||
return sorted(SUPPORTED_LANGUAGES)
|
||||
except Exception:
|
||||
return sorted(SUPPORTED_LANGUAGES)
|
||||
|
||||
|
||||
def is_syntax_highlighting_available() -> bool:
|
||||
"""Check if syntax highlighting is available.
|
||||
|
||||
Returns:
|
||||
True if tree-sitter is installed and syntax highlighting works
|
||||
"""
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
|
||||
ta = TextArea("test", language="python")
|
||||
return ta.is_syntax_aware
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,244 @@
|
||||
"""TODO comment scanner service."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.todos import ProjectTodoItem, TodoItem, TodosSummary, TodoType
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class TodoScanner:
|
||||
"""Service for scanning TODO/FIXME comments in code."""
|
||||
|
||||
# Pattern to match TODO-style comments
|
||||
TODO_PATTERN = re.compile(
|
||||
r"(?:#|//|/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\s*:?\s*(.+?)(?:\*/|-->)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern to match markdown checkboxes: - [ ] or - [x]
|
||||
CHECKBOX_PATTERN = re.compile(r"^(\s*)-\s*\[([ xX])\]\s*(.+)$")
|
||||
|
||||
# File extensions to scan
|
||||
SCAN_EXTENSIONS = {
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".jsx",
|
||||
".tsx",
|
||||
".java",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".php",
|
||||
".css",
|
||||
".scss",
|
||||
".html",
|
||||
".vue",
|
||||
".svelte",
|
||||
".md",
|
||||
".sh",
|
||||
".bash",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
}
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
self._process = ProcessService(cwd=project_path)
|
||||
|
||||
async def scan(
|
||||
self,
|
||||
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
|
||||
"""Scan project for TODO comments and TODO.md items.
|
||||
|
||||
Returns:
|
||||
Tuple of (code todo items, project todo items, summary)
|
||||
"""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
# Use ripgrep if available for speed
|
||||
result = await self._process.run(
|
||||
"rg",
|
||||
"--line-number",
|
||||
"--no-heading",
|
||||
"-e",
|
||||
r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
|
||||
"--type-add",
|
||||
"code:*.py",
|
||||
"--type-add",
|
||||
"code:*.js",
|
||||
"--type-add",
|
||||
"code:*.ts",
|
||||
"--type",
|
||||
"code",
|
||||
".",
|
||||
)
|
||||
|
||||
if result.success:
|
||||
items = self._parse_ripgrep_output(result.stdout)
|
||||
else:
|
||||
# Fallback to Python-based scanning
|
||||
items = await self._scan_with_python()
|
||||
|
||||
# Parse TODO.md if it exists
|
||||
project_items = self._parse_todo_md()
|
||||
|
||||
# Create summary
|
||||
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
|
||||
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
|
||||
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
|
||||
other_count = len(items) - todo_count - fixme_count - hack_count
|
||||
project_todo_count = sum(1 for i in project_items if not i.checked)
|
||||
project_done_count = sum(1 for i in project_items if i.checked)
|
||||
|
||||
summary = TodosSummary(
|
||||
todo_count=todo_count,
|
||||
fixme_count=fixme_count,
|
||||
hack_count=hack_count,
|
||||
other_count=other_count,
|
||||
project_todo_count=project_todo_count,
|
||||
project_done_count=project_done_count,
|
||||
)
|
||||
|
||||
return items, project_items, summary
|
||||
|
||||
def _parse_todo_md(self) -> list[ProjectTodoItem]:
|
||||
"""Parse TODO.md file for checkbox items.
|
||||
|
||||
Returns:
|
||||
List of project TODO items
|
||||
"""
|
||||
todo_md_path = self.project_path / "TODO.md"
|
||||
if not todo_md_path.exists():
|
||||
return []
|
||||
|
||||
items: list[ProjectTodoItem] = []
|
||||
current_section = "General"
|
||||
current_subsection: str | None = None
|
||||
|
||||
try:
|
||||
content = todo_md_path.read_text(encoding="utf-8")
|
||||
for line_num, line in enumerate(content.split("\n"), 1):
|
||||
# Check for section headers (## Section)
|
||||
if line.startswith("## "):
|
||||
current_section = line[3:].strip()
|
||||
current_subsection = None
|
||||
continue
|
||||
|
||||
# Check for subsection headers (### Subsection)
|
||||
if line.startswith("### "):
|
||||
current_subsection = line[4:].strip()
|
||||
continue
|
||||
|
||||
# Check for checkbox items
|
||||
match = self.CHECKBOX_PATTERN.match(line)
|
||||
if match:
|
||||
checkbox_state = match.group(2)
|
||||
text = match.group(3).strip()
|
||||
checked = checkbox_state.lower() == "x"
|
||||
|
||||
items.append(
|
||||
ProjectTodoItem(
|
||||
text=text,
|
||||
section=current_section,
|
||||
subsection=current_subsection,
|
||||
line=line_num,
|
||||
checked=checked,
|
||||
)
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return items
|
||||
|
||||
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
|
||||
"""Parse ripgrep output into TodoItems."""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Format: path:line:content
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
file_path = Path(parts[0])
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
content = parts[2]
|
||||
|
||||
# Parse the TODO type and text
|
||||
match = self.TODO_PATTERN.search(content)
|
||||
if match:
|
||||
todo_type_str = match.group(1).upper()
|
||||
todo_text = match.group(2).strip()
|
||||
|
||||
try:
|
||||
todo_type = TodoType(todo_type_str)
|
||||
except ValueError:
|
||||
todo_type = TodoType.TODO
|
||||
|
||||
items.append(
|
||||
TodoItem(
|
||||
file_path=file_path,
|
||||
line=line_num,
|
||||
column=content.find(todo_type_str) + 1,
|
||||
todo_type=todo_type,
|
||||
text=todo_text,
|
||||
context_line=content.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
async def _scan_with_python(self) -> list[TodoItem]:
|
||||
"""Fallback Python-based scanning."""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
for ext in self.SCAN_EXTENSIONS:
|
||||
for file_path in self.project_path.rglob(f"*{ext}"):
|
||||
# Skip hidden directories and common excludes
|
||||
if any(part.startswith(".") for part in file_path.parts):
|
||||
continue
|
||||
if "node_modules" in file_path.parts:
|
||||
continue
|
||||
if "__pycache__" in file_path.parts:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
for line_num, line in enumerate(content.split("\n"), 1):
|
||||
match = self.TODO_PATTERN.search(line)
|
||||
if match:
|
||||
todo_type_str = match.group(1).upper()
|
||||
todo_text = match.group(2).strip()
|
||||
|
||||
try:
|
||||
todo_type = TodoType(todo_type_str)
|
||||
except ValueError:
|
||||
todo_type = TodoType.TODO
|
||||
|
||||
items.append(
|
||||
TodoItem(
|
||||
file_path=file_path.relative_to(self.project_path),
|
||||
line=line_num,
|
||||
column=line.find(todo_type_str) + 1,
|
||||
todo_type=todo_type,
|
||||
text=todo_text,
|
||||
context_line=line.strip(),
|
||||
)
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return items
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Auto-update service for Clide.
|
||||
|
||||
Checks for updates from the release server and handles self-updating.
|
||||
User settings in ~/.clide/ are preserved across updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Update server configuration
|
||||
UPDATE_SERVER = "https://git.schweitz.net"
|
||||
REPO_OWNER = "jeroen" # TODO: Update with actual owner
|
||||
REPO_NAME = "clide" # TODO: Update with actual repo name
|
||||
|
||||
# Current version (injected at build time or read from package)
|
||||
try:
|
||||
from clide import __version__ as CURRENT_VERSION
|
||||
except ImportError:
|
||||
CURRENT_VERSION = "0.0.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
"""Information about a release."""
|
||||
|
||||
version: str
|
||||
tag_name: str
|
||||
download_url: str
|
||||
release_notes: str
|
||||
published_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCheckResult:
|
||||
"""Result of checking for updates."""
|
||||
|
||||
update_available: bool
|
||||
current_version: str
|
||||
latest_version: str | None
|
||||
release_info: ReleaseInfo | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def get_platform_asset_name() -> str:
|
||||
"""Get the expected asset name for the current platform."""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "darwin":
|
||||
return "macos.dmg"
|
||||
elif system == "linux":
|
||||
# Normalize architecture names
|
||||
if machine in ("x86_64", "amd64"):
|
||||
arch = "x86_64"
|
||||
elif machine in ("aarch64", "arm64"):
|
||||
arch = "aarch64"
|
||||
else:
|
||||
arch = machine
|
||||
return f"linux-{arch}.AppImage"
|
||||
elif system == "windows":
|
||||
return "windows-setup.exe"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
def parse_version(version: str) -> tuple[int, ...]:
|
||||
"""Parse a version string into a tuple for comparison."""
|
||||
# Remove 'v' prefix if present
|
||||
version = version.lstrip("v")
|
||||
# Split and convert to integers
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
# Handle versions like "1.0.0-beta"
|
||||
num_part = ""
|
||||
for char in part:
|
||||
if char.isdigit():
|
||||
num_part += char
|
||||
else:
|
||||
break
|
||||
parts.append(int(num_part) if num_part else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def is_newer_version(current: str, latest: str) -> bool:
|
||||
"""Check if latest version is newer than current."""
|
||||
return parse_version(latest) > parse_version(current)
|
||||
|
||||
|
||||
def check_for_updates() -> UpdateCheckResult:
|
||||
"""Check the release server for available updates.
|
||||
|
||||
Returns:
|
||||
UpdateCheckResult with update status and release info.
|
||||
"""
|
||||
try:
|
||||
# Gitea API endpoint for releases
|
||||
api_url = f"{UPDATE_SERVER}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
|
||||
|
||||
request = Request(api_url)
|
||||
request.add_header("Accept", "application/json")
|
||||
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
|
||||
|
||||
with urlopen(request, timeout=10) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
tag_name = data.get("tag_name", "")
|
||||
latest_version = tag_name.lstrip("v")
|
||||
|
||||
# Find the download URL for current platform
|
||||
platform_asset = get_platform_asset_name()
|
||||
download_url = None
|
||||
|
||||
for asset in data.get("assets", []):
|
||||
if platform_asset in asset.get("name", ""):
|
||||
download_url = asset.get("browser_download_url")
|
||||
break
|
||||
|
||||
if not download_url:
|
||||
# Try constructing URL from release
|
||||
download_url = f"{UPDATE_SERVER}/{REPO_OWNER}/{REPO_NAME}/releases/download/{tag_name}/Clide-{tag_name}-{platform_asset}"
|
||||
|
||||
release_info = ReleaseInfo(
|
||||
version=latest_version,
|
||||
tag_name=tag_name,
|
||||
download_url=download_url,
|
||||
release_notes=data.get("body", ""),
|
||||
published_at=data.get("published_at", ""),
|
||||
)
|
||||
|
||||
update_available = is_newer_version(CURRENT_VERSION, latest_version)
|
||||
|
||||
return UpdateCheckResult(
|
||||
update_available=update_available,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=latest_version,
|
||||
release_info=release_info,
|
||||
)
|
||||
|
||||
except URLError as e:
|
||||
logger.error(f"Failed to check for updates: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error=f"Network error: {e.reason}",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse update response: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error="Invalid response from update server",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error checking for updates: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
def download_update(release_info: ReleaseInfo, progress_callback=None) -> Path:
|
||||
"""Download the update to a temporary location.
|
||||
|
||||
Args:
|
||||
release_info: Release information with download URL.
|
||||
progress_callback: Optional callback(bytes_downloaded, total_bytes).
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If download fails.
|
||||
"""
|
||||
try:
|
||||
request = Request(release_info.download_url)
|
||||
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
|
||||
|
||||
# Create temp file with appropriate extension
|
||||
suffix = Path(release_info.download_url).suffix or ""
|
||||
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix="clide_update_")
|
||||
os.close(fd)
|
||||
|
||||
with urlopen(request, timeout=300) as response:
|
||||
total_size = int(response.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 8192
|
||||
|
||||
with open(temp_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, total_size)
|
||||
|
||||
return Path(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download update: {e}")
|
||||
raise RuntimeError(f"Download failed: {e}") from e
|
||||
|
||||
|
||||
def get_executable_path() -> Path:
|
||||
"""Get the path to the current executable."""
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as compiled executable
|
||||
return Path(sys.executable)
|
||||
else:
|
||||
# Running as Python script
|
||||
return Path(sys.argv[0]).resolve()
|
||||
|
||||
|
||||
def apply_update(downloaded_file: Path) -> bool:
|
||||
"""Apply the downloaded update.
|
||||
|
||||
This replaces the current executable with the new version.
|
||||
On macOS/Linux, this can happen while the app is running.
|
||||
On Windows, we need to use a helper script.
|
||||
|
||||
Args:
|
||||
downloaded_file: Path to the downloaded update file.
|
||||
|
||||
Returns:
|
||||
True if update was applied successfully.
|
||||
"""
|
||||
system = platform.system().lower()
|
||||
current_exe = get_executable_path()
|
||||
|
||||
try:
|
||||
if system == "darwin":
|
||||
# macOS: For DMG, just inform user to install manually
|
||||
# For direct binary updates, we can replace in-place
|
||||
if downloaded_file.suffix == ".dmg":
|
||||
logger.info(f"DMG downloaded to: {downloaded_file}")
|
||||
return True # User needs to install manually
|
||||
|
||||
# Direct binary replacement
|
||||
backup_path = current_exe.with_suffix(".backup")
|
||||
shutil.copy2(current_exe, backup_path)
|
||||
shutil.copy2(downloaded_file, current_exe)
|
||||
current_exe.chmod(
|
||||
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
||||
)
|
||||
backup_path.unlink()
|
||||
return True
|
||||
|
||||
elif system == "linux":
|
||||
# Linux: AppImage can be replaced directly
|
||||
if downloaded_file.suffix == ".AppImage":
|
||||
backup_path = current_exe.with_suffix(".backup")
|
||||
shutil.copy2(current_exe, backup_path)
|
||||
shutil.copy2(downloaded_file, current_exe)
|
||||
current_exe.chmod(
|
||||
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
||||
)
|
||||
backup_path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
elif system == "windows":
|
||||
# Windows: Can't replace running executable directly
|
||||
# Create a batch script to replace after exit
|
||||
batch_script = current_exe.parent / "update_clide.bat"
|
||||
new_exe = downloaded_file
|
||||
|
||||
script_content = f"""@echo off
|
||||
echo Updating Clide...
|
||||
timeout /t 2 /nobreak >nul
|
||||
copy /y "{new_exe}" "{current_exe}"
|
||||
del "{new_exe}"
|
||||
del "%~f0"
|
||||
echo Update complete!
|
||||
"""
|
||||
batch_script.write_text(script_content)
|
||||
logger.info(f"Update script created: {batch_script}")
|
||||
logger.info("Please restart Clide to complete the update.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply update: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Clean up downloaded file if it still exists and wasn't moved
|
||||
if downloaded_file.exists() and system != "windows":
|
||||
try:
|
||||
downloaded_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def perform_update(progress_callback=None) -> tuple[bool, str]:
|
||||
"""Check for and perform an update.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for download progress.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
# Check for updates
|
||||
result = check_for_updates()
|
||||
|
||||
if result.error:
|
||||
return False, f"Failed to check for updates: {result.error}"
|
||||
|
||||
if not result.update_available:
|
||||
return True, f"Already running the latest version ({result.current_version})"
|
||||
|
||||
if not result.release_info:
|
||||
return False, "No release information available"
|
||||
|
||||
# Download update
|
||||
try:
|
||||
downloaded_file = download_update(result.release_info, progress_callback)
|
||||
except RuntimeError as e:
|
||||
return False, str(e)
|
||||
|
||||
# Apply update
|
||||
if apply_update(downloaded_file):
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
return True, f"Update to {result.latest_version} downloaded. Restart Clide to complete."
|
||||
elif system == "darwin" and downloaded_file.suffix == ".dmg":
|
||||
return (
|
||||
True,
|
||||
f"Update {result.latest_version} downloaded to {downloaded_file}. Please install manually.",
|
||||
)
|
||||
else:
|
||||
return (
|
||||
True,
|
||||
f"Updated to {result.latest_version}. Restart Clide to use the new version.",
|
||||
)
|
||||
else:
|
||||
return False, "Failed to apply update"
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: branch
|
||||
description: Create, switch, or manage git branches
|
||||
---
|
||||
|
||||
# Git Branch
|
||||
|
||||
Create, switch, or manage branches.
|
||||
|
||||
## Steps
|
||||
|
||||
1. If no argument, list branches with `git branch -a`
|
||||
2. If branch name provided:
|
||||
- Check if it exists
|
||||
- If exists: `git checkout <branch>`
|
||||
- If not: `git checkout -b <branch>`
|
||||
3. Show current branch status after switch
|
||||
|
||||
## Common Operations
|
||||
|
||||
- List all branches: `git branch -a`
|
||||
- Create and switch: `git checkout -b <name>`
|
||||
- Switch to existing: `git checkout <name>`
|
||||
- Delete local branch: `git branch -d <name>`
|
||||
- Delete remote branch: `git push origin --delete <name>`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive branch names (feature/*, fix/*, etc.)
|
||||
- Keep branches short-lived
|
||||
- Delete merged branches to keep repo clean
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: commit
|
||||
description: Create a well-formatted git commit with staged changes
|
||||
---
|
||||
|
||||
# Git Commit
|
||||
|
||||
Create a well-formatted commit with staged changes following best practices.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status --porcelain` to check for changes
|
||||
2. If no staged changes, show unstaged files and ask what to stage
|
||||
3. Run `git diff --cached` to review staged changes
|
||||
4. Generate a commit message following Conventional Commits format:
|
||||
- `feat:` new feature
|
||||
- `fix:` bug fix
|
||||
- `docs:` documentation
|
||||
- `refactor:` code restructuring
|
||||
- `test:` adding tests
|
||||
- `chore:` maintenance
|
||||
5. Create commit with the message, adding Co-Authored-By trailer
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
|
||||
<body - what and why, not how>
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Warn about large commits (>500 lines changed)
|
||||
- Suggest splitting large changes into smaller commits
|
||||
- Never skip pre-commit hooks unless explicitly requested
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: pull
|
||||
description: Pull changes from remote with rebase
|
||||
---
|
||||
|
||||
# Git Pull
|
||||
|
||||
Pull changes from remote with rebase to keep history clean.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Check for uncommitted changes - stash if needed
|
||||
2. Run `git pull --rebase origin <current-branch>`
|
||||
3. If conflicts occur:
|
||||
- Show conflicting files
|
||||
- Help resolve conflicts one by one
|
||||
- Continue rebase after resolution
|
||||
4. Pop stash if we stashed earlier
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When conflicts are found:
|
||||
1. Show the conflicting files with `git status`
|
||||
2. For each file, show the conflict markers
|
||||
3. Help user decide how to resolve
|
||||
4. Stage resolved files with `git add`
|
||||
5. Continue with `git rebase --continue`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use rebase for pulls to keep history clean
|
||||
- Stash local changes before pulling
|
||||
- Never force push after rebase on shared branches
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: push
|
||||
description: Push current branch to remote
|
||||
---
|
||||
|
||||
# Git Push
|
||||
|
||||
Push current branch to remote repository.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Check if branch has upstream: `git rev-parse --abbrev-ref @{u}`
|
||||
2. If no upstream, set it: `git push -u origin <branch>`
|
||||
3. Otherwise: `git push`
|
||||
4. If push is rejected (non-fast-forward):
|
||||
- Suggest pull --rebase first
|
||||
- Never force push to main/master without explicit request
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Never force push to protected branches (main, master, develop)
|
||||
- Always set upstream on first push with `-u` flag
|
||||
- If rejected, pull with rebase first rather than force pushing
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: stash
|
||||
description: Stash current working directory changes
|
||||
---
|
||||
|
||||
# Git Stash
|
||||
|
||||
Stash current working directory changes for later use.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status` to show what will be stashed
|
||||
2. Ask for optional stash message
|
||||
3. Run `git stash push -m "<message>"` or `git stash push` if no message
|
||||
4. Confirm stash was created with `git stash list`
|
||||
|
||||
## Options
|
||||
|
||||
- Include untracked files: `git stash push -u`
|
||||
- Stash specific files: `git stash push -- <files>`
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `git stash list` - List all stashes
|
||||
- `git stash pop` - Apply and remove most recent stash
|
||||
- `git stash apply` - Apply but keep stash
|
||||
- `git stash drop` - Remove a stash
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Theme system for Clide."""
|
||||
|
||||
from clide.themes.registry import (
|
||||
DEFAULT_THEME,
|
||||
get_all_themes,
|
||||
get_theme,
|
||||
get_themes_by_category,
|
||||
register_theme,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_theme",
|
||||
"get_all_themes",
|
||||
"get_themes_by_category",
|
||||
"register_theme",
|
||||
"DEFAULT_THEME",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Built-in themes for Clide."""
|
||||
|
||||
from clide.themes.builtin import (
|
||||
all_hallows_eve,
|
||||
christmas,
|
||||
dark_autumn,
|
||||
dracula,
|
||||
fall,
|
||||
gamma,
|
||||
gruvbox_dark,
|
||||
gruvbox_light,
|
||||
hacker_style,
|
||||
halloween,
|
||||
houston,
|
||||
monokai_winter,
|
||||
nord,
|
||||
one_dark,
|
||||
one_dark_pro,
|
||||
one_dark_teal,
|
||||
one_light,
|
||||
pro_hacker,
|
||||
santa_baby,
|
||||
summer_day,
|
||||
summer_night,
|
||||
winter_is_coming,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"summer_night",
|
||||
"summer_day",
|
||||
"one_dark",
|
||||
"one_dark_pro",
|
||||
"one_light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox_dark",
|
||||
"gruvbox_light",
|
||||
"one_dark_teal",
|
||||
"gamma",
|
||||
"winter_is_coming",
|
||||
"monokai_winter",
|
||||
"fall",
|
||||
"dark_autumn",
|
||||
"all_hallows_eve",
|
||||
"halloween",
|
||||
"christmas",
|
||||
"santa_baby",
|
||||
"pro_hacker",
|
||||
"hacker_style",
|
||||
"houston",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""All Hallows' Eve Plus theme - Halloween."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="all-hallows-eve",
|
||||
display_name="All Hallows' Eve",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff7518", # Pumpkin orange
|
||||
secondary="#9932cc", # Dark orchid
|
||||
accent="#ff6347",
|
||||
background="#1a0a1a",
|
||||
surface="#2d1a2d",
|
||||
panel="#401a40",
|
||||
foreground="#dda0dd",
|
||||
success="#32cd32",
|
||||
warning="#ff7518",
|
||||
error="#dc143c",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Christmas theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="christmas",
|
||||
display_name="Christmas",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff0000", # Christmas red
|
||||
secondary="#228b22", # Forest green
|
||||
accent="#ffd700", # Gold
|
||||
background="#0a1a0a",
|
||||
surface="#1a2a1a",
|
||||
panel="#2a3a2a",
|
||||
foreground="#f0f0f0",
|
||||
success="#228b22",
|
||||
warning="#ffd700",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Dark Autumn Frost theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="dark-autumn",
|
||||
display_name="Dark Autumn Frost",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#c49a6c",
|
||||
secondary="#8b7355",
|
||||
accent="#a0522d",
|
||||
background="#1c1410",
|
||||
surface="#2a1f18",
|
||||
panel="#382a20",
|
||||
foreground="#d2b48c",
|
||||
success="#6b8e23",
|
||||
warning="#b8860b",
|
||||
error="#8b0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Dracula theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="dracula",
|
||||
display_name="Dracula",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#bd93f9",
|
||||
secondary="#8be9fd",
|
||||
accent="#ff79c6",
|
||||
background="#282a36",
|
||||
surface="#21222c",
|
||||
panel="#343746",
|
||||
foreground="#f8f8f2",
|
||||
success="#50fa7b",
|
||||
warning="#ffb86c",
|
||||
error="#ff5555",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Fall theme - Autumn colors."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="fall",
|
||||
display_name="Fall",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#e9967a",
|
||||
secondary="#daa520",
|
||||
accent="#cd853f",
|
||||
background="#2d1f1f",
|
||||
surface="#3d2929",
|
||||
panel="#4d3333",
|
||||
foreground="#f5deb3",
|
||||
success="#8fbc8f",
|
||||
warning="#daa520",
|
||||
error="#cd5c5c",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gamma theme - GitKraken Gamma style."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gamma",
|
||||
display_name="Gamma",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00d4aa",
|
||||
secondary="#7c3aed",
|
||||
accent="#f472b6",
|
||||
background="#0f172a",
|
||||
surface="#1e293b",
|
||||
panel="#334155",
|
||||
foreground="#e2e8f0",
|
||||
success="#22c55e",
|
||||
warning="#f59e0b",
|
||||
error="#ef4444",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gruvbox Dark theme - Retro groove color scheme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gruvbox-dark",
|
||||
display_name="Gruvbox Dark",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#83a598",
|
||||
secondary="#8ec07c",
|
||||
accent="#d3869b",
|
||||
background="#282828",
|
||||
surface="#3c3836",
|
||||
panel="#504945",
|
||||
foreground="#ebdbb2",
|
||||
success="#b8bb26",
|
||||
warning="#fabd2f",
|
||||
error="#fb4934",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gruvbox Light theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gruvbox-light",
|
||||
display_name="Gruvbox Light",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#076678",
|
||||
secondary="#427b58",
|
||||
accent="#8f3f71",
|
||||
background="#fbf1c7",
|
||||
surface="#ebdbb2",
|
||||
panel="#d5c4a1",
|
||||
foreground="#3c3836",
|
||||
success="#79740e",
|
||||
warning="#b57614",
|
||||
error="#9d0006",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Hacker Style theme - Matrix inspired."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="hacker-style",
|
||||
display_name="Hacker Style",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#20c20e",
|
||||
secondary="#33ff33",
|
||||
accent="#66ff66",
|
||||
background="#0c0c0c",
|
||||
surface="#121212",
|
||||
panel="#1a1a1a",
|
||||
foreground="#33ff33",
|
||||
success="#20c20e",
|
||||
warning="#c0c020",
|
||||
error="#c02020",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Halloween theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="halloween",
|
||||
display_name="Halloween",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff6600",
|
||||
secondary="#8a2be2",
|
||||
accent="#ff4500",
|
||||
background="#0d0d0d",
|
||||
surface="#1a1a1a",
|
||||
panel="#262626",
|
||||
foreground="#e6e6e6",
|
||||
success="#00ff00",
|
||||
warning="#ff6600",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Houston theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="houston",
|
||||
display_name="Houston",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff6f00",
|
||||
secondary="#00bcd4",
|
||||
accent="#ff4081",
|
||||
background="#17212b",
|
||||
surface="#232e3c",
|
||||
panel="#2e3a48",
|
||||
foreground="#eeffff",
|
||||
success="#4caf50",
|
||||
warning="#ff9800",
|
||||
error="#f44336",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Monokai Winter Night theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="monokai-winter",
|
||||
display_name="Monokai Winter Night",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#66d9ef",
|
||||
secondary="#a6e22e",
|
||||
accent="#f92672",
|
||||
background="#1a1a2e",
|
||||
surface="#16213e",
|
||||
panel="#0f3460",
|
||||
foreground="#f8f8f2",
|
||||
success="#a6e22e",
|
||||
warning="#e6db74",
|
||||
error="#f92672",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Nord theme - Arctic, north-bluish color palette."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="nord",
|
||||
display_name="Nord",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#88c0d0",
|
||||
secondary="#81a1c1",
|
||||
accent="#b48ead",
|
||||
background="#2e3440",
|
||||
surface="#3b4252",
|
||||
panel="#434c5e",
|
||||
foreground="#eceff4",
|
||||
success="#a3be8c",
|
||||
warning="#ebcb8b",
|
||||
error="#bf616a",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Atom One Dark theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark",
|
||||
display_name="One Dark",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#61afef",
|
||||
secondary="#56b6c2",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#21252b",
|
||||
panel="#2c313a",
|
||||
foreground="#abb2bf",
|
||||
success="#98c379",
|
||||
warning="#e5c07b",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One Dark Pro theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark-pro",
|
||||
display_name="One Dark Pro",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#61afef",
|
||||
secondary="#56b6c2",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#1e2227",
|
||||
panel="#333842",
|
||||
foreground="#abb2bf",
|
||||
success="#98c379",
|
||||
warning="#d19a66",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One Dark Teal theme - GitKraken signature teal accent."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark-teal",
|
||||
display_name="One Dark Teal",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#2acf9f", # GitKraken teal
|
||||
secondary="#61afef",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#21252b",
|
||||
panel="#2c313a",
|
||||
foreground="#abb2bf",
|
||||
success="#2acf9f",
|
||||
warning="#e5c07b",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Atom One Light theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-light",
|
||||
display_name="One Light",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#4078f2",
|
||||
secondary="#0184bc",
|
||||
accent="#a626a4",
|
||||
background="#fafafa",
|
||||
surface="#f0f0f0",
|
||||
panel="#e5e5e6",
|
||||
foreground="#383a42",
|
||||
success="#50a14f",
|
||||
warning="#c18401",
|
||||
error="#e45649",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Pro Hacker theme - Green on black."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="pro-hacker",
|
||||
display_name="Pro Hacker",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00ff00",
|
||||
secondary="#00cc00",
|
||||
accent="#00ff88",
|
||||
background="#000000",
|
||||
surface="#0a0a0a",
|
||||
panel="#141414",
|
||||
foreground="#00ff00",
|
||||
success="#00ff00",
|
||||
warning="#ffff00",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Santa Baby theme - Light Christmas theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="santa-baby",
|
||||
display_name="Santa Baby",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#c41e3a", # Cardinal red
|
||||
secondary="#228b22", # Forest green
|
||||
accent="#b8860b", # Dark goldenrod
|
||||
background="#fff8f0",
|
||||
surface="#f0e8e0",
|
||||
panel="#e0d8d0",
|
||||
foreground="#2f1f1f",
|
||||
success="#228b22",
|
||||
warning="#daa520",
|
||||
error="#c41e3a",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Summer Day theme - Light variant of Summer Night.
|
||||
|
||||
Inverted lightness scale with adjusted accent hues for readability.
|
||||
"""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="summer-day",
|
||||
display_name="Summer Day",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#0088b0",
|
||||
secondary="#008a99",
|
||||
accent="#d03060",
|
||||
background="#f5f7fa",
|
||||
surface="#e8ebf0",
|
||||
panel="#dde1e8",
|
||||
foreground="#21262f",
|
||||
success="#008a7a",
|
||||
warning="#b06830",
|
||||
error="#c04048",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Summer Night theme - Default dark theme.
|
||||
|
||||
Based on jackw01/summer-night-vscode-theme.
|
||||
Vibrant colors with HCL-based monochrome scale.
|
||||
"""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
# Monochrome scale (HCL equidistant lightness)
|
||||
# mono_1: #e2e8f5 - Lightest text
|
||||
# mono_2: #c4c9d6 - Secondary text
|
||||
# mono_3: #a6abb8 - Muted text
|
||||
# mono_4: #898e9a - Comments
|
||||
# mono_5: #6d727e - Subtle
|
||||
# mono_6: #525762 - Borders
|
||||
# mono_7: #393e48 - Surface
|
||||
# mono_8: #21262f - Background
|
||||
|
||||
# Accent colors (HCL analogous scales)
|
||||
# cyan: #00a3d2 - Primary accent
|
||||
# teal: #00a9b9 - Links
|
||||
# pink: #fa5f8b - Keywords
|
||||
# yellow: #d3ab58 - Strings
|
||||
# red: #f06c6f - Errors
|
||||
# orange: #d08447 - Warnings
|
||||
# coral: #e17954 - Functions
|
||||
# green: #00ab9a - Success
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="summer-night",
|
||||
display_name="Summer Night",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Winter Is Coming theme - Bluish, icy vibe."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="winter-is-coming",
|
||||
display_name="Winter Is Coming",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#89ddff",
|
||||
secondary="#82aaff",
|
||||
accent="#c792ea",
|
||||
background="#011627",
|
||||
surface="#0d293e",
|
||||
panel="#1d3b53",
|
||||
foreground="#d6deeb",
|
||||
success="#22da6e",
|
||||
warning="#ecc48d",
|
||||
error="#ef5350",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Custom theme loader for user-defined themes."""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
from clide.themes.registry import register_theme
|
||||
|
||||
|
||||
def load_custom_themes(themes_dir: Path) -> list[str]:
|
||||
"""Load custom themes from a directory.
|
||||
|
||||
Args:
|
||||
themes_dir: Directory containing .toml theme files
|
||||
|
||||
Returns:
|
||||
List of loaded theme names
|
||||
"""
|
||||
loaded = []
|
||||
|
||||
if not themes_dir.exists():
|
||||
return loaded
|
||||
|
||||
for theme_file in themes_dir.glob("*.toml"):
|
||||
try:
|
||||
theme = load_theme_file(theme_file)
|
||||
if theme:
|
||||
register_theme(theme, "custom")
|
||||
loaded.append(theme.name)
|
||||
except Exception as e:
|
||||
# Log but don't crash on bad theme files
|
||||
print(f"Failed to load theme {theme_file}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load_theme_file(path: Path) -> ThemeDefinition | None:
|
||||
"""Load a single theme from a TOML file.
|
||||
|
||||
Args:
|
||||
path: Path to the theme TOML file
|
||||
|
||||
Returns:
|
||||
Theme definition or None if invalid
|
||||
|
||||
Example TOML format:
|
||||
name = "my-theme"
|
||||
display_name = "My Custom Theme"
|
||||
dark = true
|
||||
|
||||
[colors]
|
||||
primary = "#007acc"
|
||||
secondary = "#3c3c3c"
|
||||
accent = "#0e639c"
|
||||
background = "#1e1e1e"
|
||||
surface = "#252526"
|
||||
panel = "#2d2d2d"
|
||||
foreground = "#d4d4d4"
|
||||
success = "#4ec9b0"
|
||||
warning = "#dcdcaa"
|
||||
error = "#f14c4c"
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
# Validate required fields
|
||||
required = ["name", "display_name", "dark", "colors"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
colors_data = data["colors"]
|
||||
color_fields = [
|
||||
"primary", "secondary", "accent", "background", "surface",
|
||||
"panel", "foreground", "success", "warning", "error"
|
||||
]
|
||||
for field in color_fields:
|
||||
if field not in colors_data:
|
||||
raise ValueError(f"Missing color field: {field}")
|
||||
|
||||
colors = ThemeColors(**colors_data)
|
||||
return ThemeDefinition(
|
||||
name=data["name"],
|
||||
display_name=data["display_name"],
|
||||
dark=data["dark"],
|
||||
colors=colors,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Theme registry for managing available themes."""
|
||||
|
||||
|
||||
from clide.models.theme import ThemeDefinition, ThemeMetadata
|
||||
|
||||
# Theme registry
|
||||
_themes: dict[str, ThemeDefinition] = {}
|
||||
_theme_metadata: dict[str, ThemeMetadata] = {}
|
||||
|
||||
DEFAULT_THEME = "summer-night"
|
||||
|
||||
|
||||
def register_theme(
|
||||
theme: ThemeDefinition,
|
||||
category: str = "custom",
|
||||
) -> None:
|
||||
"""Register a theme in the registry.
|
||||
|
||||
Args:
|
||||
theme: Theme definition to register
|
||||
category: Theme category (core, popular, seasonal, custom)
|
||||
"""
|
||||
_themes[theme.name] = theme
|
||||
_theme_metadata[theme.name] = ThemeMetadata(
|
||||
name=theme.name,
|
||||
display_name=theme.display_name,
|
||||
dark=theme.dark,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def get_theme(name: str) -> ThemeDefinition | None:
|
||||
"""Get a theme by name.
|
||||
|
||||
Args:
|
||||
name: Theme name
|
||||
|
||||
Returns:
|
||||
Theme definition or None if not found
|
||||
"""
|
||||
return _themes.get(name)
|
||||
|
||||
|
||||
def get_all_themes() -> list[ThemeMetadata]:
|
||||
"""Get metadata for all registered themes.
|
||||
|
||||
Returns:
|
||||
List of theme metadata sorted by category then name
|
||||
"""
|
||||
themes = list(_theme_metadata.values())
|
||||
# Sort: core first, then alphabetically by category, then by name
|
||||
category_order = {"core": 0, "popular": 1, "gitkraken": 2, "seasonal": 3, "hacker": 4, "custom": 5}
|
||||
themes.sort(key=lambda t: (category_order.get(t.category, 99), t.name))
|
||||
return themes
|
||||
|
||||
|
||||
def get_themes_by_category(category: str) -> list[ThemeMetadata]:
|
||||
"""Get themes filtered by category.
|
||||
|
||||
Args:
|
||||
category: Category to filter by
|
||||
|
||||
Returns:
|
||||
List of theme metadata in that category
|
||||
"""
|
||||
return [t for t in _theme_metadata.values() if t.category == category]
|
||||
|
||||
|
||||
def _load_builtin_themes() -> None:
|
||||
"""Load all built-in themes."""
|
||||
# Import here to avoid circular imports
|
||||
from clide.themes.builtin import (
|
||||
all_hallows_eve,
|
||||
christmas,
|
||||
dark_autumn,
|
||||
dracula,
|
||||
fall,
|
||||
gamma,
|
||||
gruvbox_dark,
|
||||
gruvbox_light,
|
||||
hacker_style,
|
||||
halloween,
|
||||
houston,
|
||||
monokai_winter,
|
||||
nord,
|
||||
one_dark,
|
||||
one_dark_pro,
|
||||
one_dark_teal,
|
||||
one_light,
|
||||
pro_hacker,
|
||||
santa_baby,
|
||||
summer_day,
|
||||
summer_night,
|
||||
winter_is_coming,
|
||||
)
|
||||
|
||||
# Core themes
|
||||
register_theme(summer_night.theme, "core")
|
||||
register_theme(summer_day.theme, "core")
|
||||
|
||||
# Popular themes
|
||||
register_theme(one_dark.theme, "popular")
|
||||
register_theme(one_dark_pro.theme, "popular")
|
||||
register_theme(one_light.theme, "popular")
|
||||
register_theme(dracula.theme, "popular")
|
||||
register_theme(nord.theme, "popular")
|
||||
register_theme(gruvbox_dark.theme, "popular")
|
||||
register_theme(gruvbox_light.theme, "popular")
|
||||
|
||||
# GitKraken style
|
||||
register_theme(one_dark_teal.theme, "gitkraken")
|
||||
register_theme(gamma.theme, "gitkraken")
|
||||
|
||||
# Seasonal - Winter
|
||||
register_theme(winter_is_coming.theme, "seasonal")
|
||||
register_theme(monokai_winter.theme, "seasonal")
|
||||
|
||||
# Seasonal - Fall
|
||||
register_theme(fall.theme, "seasonal")
|
||||
register_theme(dark_autumn.theme, "seasonal")
|
||||
|
||||
# Seasonal - Halloween
|
||||
register_theme(all_hallows_eve.theme, "seasonal")
|
||||
register_theme(halloween.theme, "seasonal")
|
||||
|
||||
# Seasonal - Christmas
|
||||
register_theme(christmas.theme, "seasonal")
|
||||
register_theme(santa_baby.theme, "seasonal")
|
||||
|
||||
# Hacker style
|
||||
register_theme(pro_hacker.theme, "hacker")
|
||||
register_theme(hacker_style.theme, "hacker")
|
||||
|
||||
# Bonus
|
||||
register_theme(houston.theme, "popular")
|
||||
|
||||
|
||||
# Load built-in themes on module import
|
||||
_load_builtin_themes()
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
"""Vendored third-party libraries for Clide."""
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
pyte - LGPL License
|
||||
====================
|
||||
|
||||
This is a vendored copy of pyte (https://github.com/selectel/pyte)
|
||||
with modifications for Clide diagnostic logging.
|
||||
|
||||
Original copyright:
|
||||
(c) 2011-2012 by Selectel.
|
||||
(c) 2012-2017 by pyte authors and contributors.
|
||||
|
||||
This code is licensed under the GNU Lesser General Public License (LGPL).
|
||||
Modifications made by the Clide project are also licensed under LGPL.
|
||||
|
||||
For the full LGPL license text, see:
|
||||
https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
Modifications:
|
||||
- Added diagnostic logging hooks for debugging terminal rendering issues
|
||||
- Added event callback support for Claude Code integration
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
pyte
|
||||
~~~~
|
||||
|
||||
`pyte` implements a mix of VT100, VT220 and VT520 specification,
|
||||
and aims to support most of the `TERM=linux` functionality.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"Screen",
|
||||
"DiffScreen",
|
||||
"HistoryScreen",
|
||||
"DebugScreen",
|
||||
"Stream",
|
||||
"ByteStream",
|
||||
# Clide additions
|
||||
"set_debug_logger",
|
||||
"get_debug_logger",
|
||||
"set_event_callback",
|
||||
)
|
||||
|
||||
import io
|
||||
|
||||
# Re-export submodules for compatibility
|
||||
from . import screens
|
||||
from .screens import DebugScreen, DiffScreen, HistoryScreen, Screen
|
||||
from .screens import set_debug_logger as _set_screen_logger
|
||||
from .streams import ByteStream, Stream, set_event_callback
|
||||
from .streams import set_debug_logger as _set_stream_logger
|
||||
|
||||
|
||||
def set_debug_logger(logger):
|
||||
"""Set debug logger for both streams and screens.
|
||||
|
||||
Args:
|
||||
logger: A callable that accepts a string message, or None to disable.
|
||||
"""
|
||||
_set_stream_logger(logger)
|
||||
_set_screen_logger(logger)
|
||||
|
||||
|
||||
def get_debug_logger():
|
||||
"""Get the current debug logger (if set).
|
||||
|
||||
Returns:
|
||||
The current debug logger callable, or None if not set.
|
||||
"""
|
||||
return screens._debug_logger
|
||||
|
||||
|
||||
if __debug__:
|
||||
|
||||
def dis(chars: bytes | str) -> None:
|
||||
"""A :func:`dis.dis` for terminals."""
|
||||
if isinstance(chars, str):
|
||||
chars = chars.encode("utf-8")
|
||||
|
||||
with io.StringIO() as buf:
|
||||
ByteStream(DebugScreen(to=buf)).feed(chars)
|
||||
print(buf.getvalue())
|
||||
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
pyte.charsets
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
This module defines ``G0`` and ``G1`` charset mappings the same way
|
||||
they are defined for linux terminal, see
|
||||
``linux/drivers/tty/consolemap.c`` @ http://git.kernel.org
|
||||
|
||||
.. note:: ``VT100_MAP`` and ``IBMPC_MAP`` were taken unchanged
|
||||
from linux kernel source and therefore are licensed
|
||||
under **GPL**.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: Latin1.
|
||||
LAT1_MAP = "".join(map(chr, range(256)))
|
||||
|
||||
#: VT100 graphic character set.
|
||||
VT100_MAP = "".join(chr(c) for c in [
|
||||
0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
|
||||
0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x000e, 0x000f,
|
||||
0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
|
||||
0x0018, 0x0019, 0x001a, 0x001b, 0x001c, 0x001d, 0x001e, 0x001f,
|
||||
0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
|
||||
0x0028, 0x0029, 0x002a, 0x2192, 0x2190, 0x2191, 0x2193, 0x002f,
|
||||
0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
|
||||
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
|
||||
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
|
||||
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
|
||||
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
|
||||
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x00a0,
|
||||
0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1,
|
||||
0x2591, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba,
|
||||
0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c,
|
||||
0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x007f,
|
||||
0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
|
||||
0x0088, 0x0089, 0x008a, 0x008b, 0x008c, 0x008d, 0x008e, 0x008f,
|
||||
0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
|
||||
0x0098, 0x0099, 0x009a, 0x009b, 0x009c, 0x009d, 0x009e, 0x009f,
|
||||
0x00a0, 0x00a1, 0x00a2, 0x00a3, 0x00a4, 0x00a5, 0x00a6, 0x00a7,
|
||||
0x00a8, 0x00a9, 0x00aa, 0x00ab, 0x00ac, 0x00ad, 0x00ae, 0x00af,
|
||||
0x00b0, 0x00b1, 0x00b2, 0x00b3, 0x00b4, 0x00b5, 0x00b6, 0x00b7,
|
||||
0x00b8, 0x00b9, 0x00ba, 0x00bb, 0x00bc, 0x00bd, 0x00be, 0x00bf,
|
||||
0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c6, 0x00c7,
|
||||
0x00c8, 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf,
|
||||
0x00d0, 0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d7,
|
||||
0x00d8, 0x00d9, 0x00da, 0x00db, 0x00dc, 0x00dd, 0x00de, 0x00df,
|
||||
0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, 0x00e5, 0x00e6, 0x00e7,
|
||||
0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00ed, 0x00ee, 0x00ef,
|
||||
0x00f0, 0x00f1, 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00f7,
|
||||
0x00f8, 0x00f9, 0x00fa, 0x00fb, 0x00fc, 0x00fd, 0x00fe, 0x00ff
|
||||
])
|
||||
|
||||
#: IBM Codepage 437.
|
||||
IBMPC_MAP = "".join(chr(c) for c in [
|
||||
0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
|
||||
0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
|
||||
0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
|
||||
0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
|
||||
0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
|
||||
0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
|
||||
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
|
||||
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
|
||||
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
|
||||
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
|
||||
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
|
||||
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
|
||||
0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
|
||||
0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x006f,
|
||||
0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
|
||||
0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
|
||||
0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
|
||||
0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
|
||||
0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
|
||||
0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
|
||||
0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
|
||||
0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
|
||||
0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
|
||||
0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
|
||||
0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
|
||||
0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
|
||||
0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
|
||||
0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
|
||||
0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
|
||||
0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
|
||||
0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
|
||||
0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
|
||||
])
|
||||
|
||||
|
||||
#: VAX42 character set.
|
||||
VAX42_MAP = "".join(chr(c) for c in [
|
||||
0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
|
||||
0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
|
||||
0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
|
||||
0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
|
||||
0x0020, 0x043b, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
|
||||
0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
|
||||
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
|
||||
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x0435,
|
||||
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
|
||||
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
|
||||
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
|
||||
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
|
||||
0x0060, 0x0441, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
|
||||
0x0435, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x043a,
|
||||
0x0070, 0x0071, 0x0442, 0x0073, 0x043b, 0x0435, 0x0076, 0x0077,
|
||||
0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
|
||||
0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
|
||||
0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
|
||||
0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
|
||||
0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
|
||||
0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
|
||||
0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
|
||||
0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
|
||||
0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
|
||||
0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
|
||||
0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
|
||||
0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
|
||||
0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
|
||||
0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
|
||||
0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
|
||||
0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
|
||||
0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
|
||||
])
|
||||
|
||||
|
||||
MAPS = {
|
||||
"B": LAT1_MAP,
|
||||
"0": VT100_MAP,
|
||||
"U": IBMPC_MAP,
|
||||
"V": VAX42_MAP
|
||||
}
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
pyte.control
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module defines simple control sequences, recognized by
|
||||
:class:`~pyte.streams.Stream`, the set of codes here is for
|
||||
``TERM=linux`` which is a superset of VT102.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: *Space*: Not surprisingly -- ``" "``.
|
||||
SP = " "
|
||||
|
||||
#: *Null*: Does nothing.
|
||||
NUL = "\x00"
|
||||
|
||||
#: *Bell*: Beeps.
|
||||
BEL = "\x07"
|
||||
|
||||
#: *Backspace*: Backspace one column, but not past the beginning of the
|
||||
#: line.
|
||||
BS = "\x08"
|
||||
|
||||
#: *Horizontal tab*: Move cursor to the next tab stop, or to the end
|
||||
#: of the line if there is no earlier tab stop.
|
||||
HT = "\x09"
|
||||
|
||||
#: *Linefeed*: Give a line feed, and, if :data:`pyte.modes.LNM` (new
|
||||
#: line mode) is set also a carriage return.
|
||||
LF = "\n"
|
||||
#: *Vertical tab*: Same as :data:`LF`.
|
||||
VT = "\x0b"
|
||||
#: *Form feed*: Same as :data:`LF`.
|
||||
FF = "\x0c"
|
||||
|
||||
#: *Carriage return*: Move cursor to left margin on current line.
|
||||
CR = "\r"
|
||||
|
||||
#: *Shift out*: Activate G1 character set.
|
||||
SO = "\x0e"
|
||||
|
||||
#: *Shift in*: Activate G0 character set.
|
||||
SI = "\x0f"
|
||||
|
||||
#: *Cancel*: Interrupt escape sequence. If received during an escape or
|
||||
#: control sequence, cancels the sequence and displays substitution
|
||||
#: character.
|
||||
CAN = "\x18"
|
||||
#: *Substitute*: Same as :data:`CAN`.
|
||||
SUB = "\x1a"
|
||||
|
||||
#: *Escape*: Starts an escape sequence.
|
||||
ESC = "\x1b"
|
||||
|
||||
#: *Delete*: Is ignored.
|
||||
DEL = "\x7f"
|
||||
|
||||
#: *Control sequence introducer*.
|
||||
CSI_C0 = ESC + "["
|
||||
CSI_C1 = "\x9b"
|
||||
CSI = CSI_C0
|
||||
|
||||
#: *String terminator*.
|
||||
ST_C0 = ESC + "\\"
|
||||
ST_C1 = "\x9c"
|
||||
ST = ST_C0
|
||||
|
||||
#: *Operating system command*.
|
||||
OSC_C0 = ESC + "]"
|
||||
OSC_C1 = "\x9d"
|
||||
OSC = OSC_C0
|
||||
Vendored
+154
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
pyte.escape
|
||||
~~~~~~~~~~~
|
||||
|
||||
This module defines both CSI and non-CSI escape sequences, recognized
|
||||
by :class:`~pyte.streams.Stream` and subclasses.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: *Reset*.
|
||||
RIS = "c"
|
||||
|
||||
#: *Index*: Move cursor down one line in same column. If the cursor is
|
||||
#: at the bottom margin, the screen performs a scroll-up.
|
||||
IND = "D"
|
||||
|
||||
#: *Next line*: Same as :data:`pyte.control.LF`.
|
||||
NEL = "E"
|
||||
|
||||
#: Tabulation set: Set a horizontal tab stop at cursor position.
|
||||
HTS = "H"
|
||||
|
||||
#: *Reverse index*: Move cursor up one line in same column. If the
|
||||
#: cursor is at the top margin, the screen performs a scroll-down.
|
||||
RI = "M"
|
||||
|
||||
#: Save cursor: Save cursor position, character attribute (graphic
|
||||
#: rendition), character set, and origin mode selection (see
|
||||
#: :data:`DECRC`).
|
||||
DECSC = "7"
|
||||
|
||||
#: *Restore cursor*: Restore previously saved cursor position, character
|
||||
#: attribute (graphic rendition), character set, and origin mode
|
||||
#: selection. If none were saved, move cursor to home position.
|
||||
DECRC = "8"
|
||||
|
||||
# "Sharp" escape sequences.
|
||||
# -------------------------
|
||||
|
||||
#: *Alignment display*: Fill screen with uppercase E's for testing
|
||||
#: screen focus and alignment.
|
||||
DECALN = "8"
|
||||
|
||||
|
||||
# ECMA-48 CSI sequences.
|
||||
# ---------------------
|
||||
|
||||
#: *Insert character*: Insert the indicated # of blank characters.
|
||||
ICH = "@"
|
||||
|
||||
#: *Cursor up*: Move cursor up the indicated # of lines in same column.
|
||||
#: Cursor stops at top margin.
|
||||
CUU = "A"
|
||||
|
||||
#: *Cursor down*: Move cursor down the indicated # of lines in same
|
||||
#: column. Cursor stops at bottom margin.
|
||||
CUD = "B"
|
||||
|
||||
#: *Cursor forward*: Move cursor right the indicated # of columns.
|
||||
#: Cursor stops at right margin.
|
||||
CUF = "C"
|
||||
|
||||
#: *Cursor back*: Move cursor left the indicated # of columns. Cursor
|
||||
#: stops at left margin.
|
||||
CUB = "D"
|
||||
|
||||
#: *Cursor next line*: Move cursor down the indicated # of lines to
|
||||
#: column 1.
|
||||
CNL = "E"
|
||||
|
||||
#: *Cursor previous line*: Move cursor up the indicated # of lines to
|
||||
#: column 1.
|
||||
CPL = "F"
|
||||
|
||||
#: *Cursor horizontal align*: Move cursor to the indicated column in
|
||||
#: current line.
|
||||
CHA = "G"
|
||||
|
||||
#: *Cursor position*: Move cursor to the indicated line, column (origin
|
||||
#: at ``1, 1``).
|
||||
CUP = "H"
|
||||
|
||||
#: *Erase data* (default: from cursor to end of line).
|
||||
ED = "J"
|
||||
|
||||
#: *Erase in line* (default: from cursor to end of line).
|
||||
EL = "K"
|
||||
|
||||
#: *Insert line*: Insert the indicated # of blank lines, starting from
|
||||
#: the current line. Lines displayed below cursor move down. Lines moved
|
||||
#: past the bottom margin are lost.
|
||||
IL = "L"
|
||||
|
||||
#: *Delete line*: Delete the indicated # of lines, starting from the
|
||||
#: current line. As lines are deleted, lines displayed below cursor
|
||||
#: move up. Lines added to bottom of screen have spaces with same
|
||||
#: character attributes as last line move up.
|
||||
DL = "M"
|
||||
|
||||
#: *Delete character*: Delete the indicated # of characters on the
|
||||
#: current line. When character is deleted, all characters to the right
|
||||
#: of cursor move left.
|
||||
DCH = "P"
|
||||
|
||||
#: *Erase character*: Erase the indicated # of characters on the
|
||||
#: current line.
|
||||
ECH = "X"
|
||||
|
||||
#: *Horizontal position relative*: Same as :data:`CUF`.
|
||||
HPR = "a"
|
||||
|
||||
#: *Device Attributes*.
|
||||
DA = "c"
|
||||
|
||||
#: *Vertical position adjust*: Move cursor to the indicated line,
|
||||
#: current column.
|
||||
VPA = "d"
|
||||
|
||||
#: *Vertical position relative*: Same as :data:`CUD`.
|
||||
VPR = "e"
|
||||
|
||||
#: *Horizontal / Vertical position*: Same as :data:`CUP`.
|
||||
HVP = "f"
|
||||
|
||||
#: *Tabulation clear*: Clears a horizontal tab stop at cursor position.
|
||||
TBC = "g"
|
||||
|
||||
#: *Set mode*.
|
||||
SM = "h"
|
||||
|
||||
#: *Reset mode*.
|
||||
RM = "l"
|
||||
|
||||
#: *Select graphics rendition*: The terminal can display the following
|
||||
#: character attributes that change the character display without
|
||||
#: changing the character (see :mod:`pyte.graphics`).
|
||||
SGR = "m"
|
||||
|
||||
#: *Device status report*.
|
||||
DSR = "n"
|
||||
|
||||
#: *Select top and bottom margins*: Selects margins, defining the
|
||||
#: scrolling region; parameters are top and bottom line. If called
|
||||
#: without any arguments, whole screen is used.
|
||||
DECSTBM = "r"
|
||||
|
||||
#: *Horizontal position adjust*: Same as :data:`CHA`.
|
||||
HPA = "'"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user