move python clide to legacy/

Clide is being rebuilt as a Flutter desktop app. The Python Textual
implementation moves wholesale into legacy/ rather than being deleted:
its pane model, panel set, git skills, and panel communication design
are real thought that should remain readable next to the new code
while the rebuild finds its shape. Git's rename tracking preserves
history, so `git log -- legacy/` still works.

The Flutter rebuild lives at the repo root alongside a Go sidecar
(the architecture claudian was heading toward, which folds into
clide as a core component rather than a separate plugin project).
Bootstrap of the new shape lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:30:51 +02:00
co-authored by Claude Opus 4.7
parent 30b8cf7db9
commit a355751437
163 changed files with 0 additions and 0 deletions
+126
View File
@@ -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)
+21
View File
@@ -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
+60
View File
@@ -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
+345
View File
@@ -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
View File
@@ -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"
+168
View File
@@ -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
View File
@@ -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
+86
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""clide-web: Web server for Clide TUI."""
__version__ = "1.0.0"
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m clide_web."""
from clide_web.server import main
if __name__ == "__main__":
main()
+108
View File
@@ -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.
+169
View File
@@ -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)
+309
View File
@@ -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,
)
+240
View File
@@ -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
+117
View File
@@ -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
+66
View File
@@ -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
+149
View File
@@ -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,
)
+3
View File
@@ -0,0 +1,3 @@
"""Clide - A TUI CLI IDE for Claude Code CLI."""
__version__ = "1.0.0"
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m clide."""
from clide.cli import app
if __name__ == "__main__":
app()
+863
View File
@@ -0,0 +1,863 @@
"""Main Textual Application for Clide."""
from pathlib import Path
from typing import ClassVar
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Footer, Header
from textual.worker import Worker, WorkerState
from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings
from clide.services.claude_events import (
ClaudeEvent,
FileEditEvent,
FileReadEvent,
FileWriteEvent,
setup_event_parsing,
)
from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching
from clide.services.settings_service import get_settings_service
from clide.services.syntax_service import register_languages
from clide.themes.registry import get_all_themes, get_theme
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
from clide.widgets.panels.sidebar import SidebarPanel
from clide.widgets.panels.workspace import WorkspacePanel
class ClideApp(App[None]):
"""Clide TUI Application - Claude Code IDE.
Panel architecture:
- Sidebar (left): Files, Git, Tree tabs + branch status
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
- Context (right): Problems, TODOs, Jira tabs
Workspace is hidden by default. Claude takes full height when
workspace is hidden, 40% when visible.
"""
TITLE = "Clide"
SUB_TITLE = "Claude Code IDE"
CSS: ClassVar[str] = """
/* Main layout */
Screen {
layout: horizontal;
}
#main-container {
width: 100%;
height: 100%;
layout: horizontal;
}
#center-column {
width: 1fr;
height: 100%;
layout: vertical;
}
/* Panel styling */
SidebarPanel {
width: 20%;
min-width: 25;
max-width: 50;
}
ContextPanel {
width: 25%;
min-width: 30;
max-width: 50;
}
/* Workspace + Claude layout */
WorkspacePanel {
height: 60%;
}
WorkspacePanel.hidden {
display: none;
}
ClaudePanel {
height: 100%;
}
ClaudePanel.with-workspace {
height: 40%;
}
/* Compact mode - applied when .compact class is on #main-container */
#main-container.compact SidebarPanel {
display: none;
}
#main-container.compact ContextPanel {
display: none;
}
/* Fullscreen mode */
.fullscreen {
dock: top;
width: 100%;
height: 100%;
layer: fullscreen;
}
/* Global button styling - outlined look */
Button {
background: transparent;
border: solid $secondary;
color: $foreground;
margin: 0 1;
}
Button:hover {
background: $secondary 20%;
border: solid $secondary;
}
Button:focus {
border: solid $primary;
}
Button.-primary {
border: solid $primary;
color: $primary;
}
Button.-primary:hover {
background: $primary 20%;
}
"""
# Keybindings
# OS-native shortcuts (Ctrl on Windows/Linux, Cmd on Mac mapped to ctrl in terminal)
# Alt-based shortcuts for actions that shouldn't interfere with input fields
# Note: priority=True ensures bindings work even when widgets have focus
BINDINGS: ClassVar[list[Binding]] = [
# Global
Binding("alt+q", "quit", "Quit", priority=True),
Binding("alt+p", "command_palette", "Commands"),
Binding("alt+o", "quick_open", "Quick Open"),
Binding("alt+b", "toggle_sidebar", "Sidebar", priority=True),
Binding("alt+shift+b", "toggle_context", "Context", priority=True),
Binding("alt+`", "toggle_terminal", "Terminal", priority=True),
Binding("alt+c", "toggle_compact", "Compact", priority=True),
Binding("f11", "toggle_fullscreen", "Fullscreen"),
Binding("escape", "escape", "Escape", show=False),
# Navigation
Binding("alt+1", "focus_claude", "Claude", show=False, priority=True),
Binding("alt+2", "focus_editor", "Editor", show=False, priority=True),
Binding("alt+3", "focus_terminal", "Terminal", show=False, priority=True),
Binding("alt+0", "focus_sidebar", "Sidebar", show=False, priority=True),
Binding("alt+w", "close_tab", "Close Tab", show=False),
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
# Git
Binding("alt+g", "open_git", "Git", show=False, priority=True),
# Problems
Binding("alt+m", "open_problems", "Problems", show=False, priority=True),
Binding("f8", "next_problem", "Next Problem", show=False),
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
# Editor - OS-native shortcuts with priority
Binding("ctrl+s", "save_file", "Save", priority=True),
Binding("alt+s", "save_file", "Save", show=False, priority=True),
Binding("ctrl+z", "undo", "Undo", show=False, priority=True),
# Theme
Binding("alt+t", "select_theme", "Theme", priority=True),
]
# Reactive state
current_file: reactive[Path | None] = reactive(None)
workspace_visible: reactive[bool] = reactive(False)
compact_mode: reactive[bool] = reactive(False)
fullscreen_panel: reactive[str | None] = reactive(None)
def __init__(
self,
workdir: Path | None = None,
settings: ClideSettings | None = None,
test_mode: bool = False,
) -> None:
super().__init__()
self.workdir = self._resolve_workdir(workdir)
self.settings = settings or ClideSettings()
self._test_mode = test_mode
# User settings persistence
self._settings_service = get_settings_service()
self._user_settings = self._settings_service.load()
# Extension manager
self.extension_manager = ExtensionManager()
# Controllers
self.git_controller = GitController(self.workdir)
self.editor_controller = EditorController()
self.diff_controller = DiffController(self.workdir)
self.problems_controller = ProblemsController(self.workdir)
self.todos_controller = TodosController(self.workdir)
# Use settings parameter if jira_enabled explicitly set, otherwise user settings
jira_enabled = self.settings.jira_enabled or self._user_settings.jira_enabled
self.jira_controller = JiraController(
enabled=jira_enabled,
)
# Register themes
self._register_themes()
# Register additional syntax highlighting languages
register_languages()
@staticmethod
def _resolve_workdir(workdir: Path | None) -> Path:
"""Resolve the working directory to the git root when possible."""
import subprocess
target = workdir.resolve() if workdir and workdir.is_dir() else Path.cwd()
# Find git root -- anchors to the project root even if cwd is a subdirectory
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=str(target),
capture_output=True,
text=True,
timeout=3,
)
if result.returncode == 0:
git_root = Path(result.stdout.strip())
if git_root.is_dir():
return git_root
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
return target
def _register_themes(self) -> None:
"""Register all themes with Textual."""
for theme_meta in get_all_themes():
theme_def = get_theme(theme_meta.name)
if theme_def:
self.register_theme(theme_def.to_textual_theme())
# Set initial theme: settings parameter takes precedence over user settings
# Use settings.theme if different from default, otherwise user settings
if self.settings.theme != "summer-night":
self.theme = self.settings.theme
else:
self.theme = self._user_settings.theme
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
"""Set the application theme.
Args:
theme_name: Name of the theme to apply
save: Whether to persist the setting (default: True)
"""
self.theme = theme_name
if save:
self._settings_service.set("theme", theme_name)
self.notify(f"Theme set to: {theme_name}", severity="information")
def save_user_settings(self) -> None:
"""Save current user settings to disk."""
self._settings_service.update(
theme=self.theme,
compact_mode=self.compact_mode,
sidebar_visible=self.query_one(SidebarPanel).display,
context_visible=self.query_one(ContextPanel).display,
)
def compose(self) -> ComposeResult:
"""Create the main layout."""
yield Header()
with Horizontal(id="main-container"):
# Left sidebar
yield SidebarPanel(workdir=self.workdir)
# Center column with workspace and claude
with Vertical(id="center-column"):
yield WorkspacePanel(workdir=self.workdir)
yield ClaudePanel(
workdir=self.workdir,
auto_start=not self._test_mode,
)
# Right context panel
yield ContextPanel(
jira_enabled=self.settings.jira_enabled,
project_path=self.workdir,
)
yield Footer()
async def on_mount(self) -> None:
"""Initialize application on mount."""
# Apply saved user settings
self._apply_user_settings()
# Load extensions
self.extension_manager.load_extensions()
await self.extension_manager.trigger_app_startup(self)
# Set up file watching for real-time sync
self._setup_file_watching()
# Set up Claude event parsing for IDE integration
self._setup_claude_events()
# Initial data refresh
await self._refresh_git()
await self._refresh_problems()
await self._refresh_todos()
if self._user_settings.jira_enabled:
await self._refresh_jira()
# Focus Claude panel
self.action_focus_claude()
def _setup_file_watching(self) -> None:
"""Set up file system watching for real-time updates."""
self._file_watcher = setup_file_watching(
self.workdir,
handlers=[self._on_file_changed],
)
def _setup_claude_events(self) -> None:
"""Set up Claude event parsing for IDE integration."""
setup_event_parsing(self._on_claude_event)
def _on_file_changed(self, event: FileEvent) -> None:
"""Handle file system changes by posting a FileEventMessage.
This bridges the watchdog callback to Textual's message system,
allowing widgets to subscribe to file events via on_file_event_message.
Note: This is called from the watchdog thread, so we use call_from_thread
to safely execute on the main thread. The FileEvent (Pydantic model) is
thread-safe, but we create the Message on the main thread to avoid any
potential Textual threading issues.
"""
def post_file_event():
self.post_message(FileEventMessage(event))
self.call_from_thread(post_file_event)
async def on_file_event_message(self, message: FileEventMessage) -> None:
"""Handle file event messages from the file watcher.
This is the central handler for all file system events.
The App coordinates updates to child widgets since Textual
messages bubble up (not down to children).
"""
event = message.event
# Ignore files in .clide directory (settings, etc.)
if ".clide" in str(event.path):
return
# Trigger extension hooks
self.extension_manager.trigger_file_changed(event)
# Debounce: skip if we refreshed recently (within 1 second)
import time
now = time.time()
if hasattr(self, "_last_file_refresh") and now - self._last_file_refresh < 1.0:
return
self._last_file_refresh = now
# Refresh file tree for created/deleted/moved files
if event.event_type in ("created", "deleted", "moved"):
try:
sidebar = self.query_one(SidebarPanel)
sidebar.refresh_files()
except Exception:
pass
# Use call_later to avoid blocking the event loop during refreshes
# This ensures UI responsiveness isn't affected by heavy git operations
if event.event_type in ("created", "modified", "deleted", "moved"):
self.call_later(self._async_refresh_after_file_change, event)
async def _async_refresh_after_file_change(self, event: FileEvent) -> None:
"""Perform async refreshes after a file change without blocking UI."""
# Refresh git status for all file changes
await self._refresh_git()
# Only refresh problems/todos for Python/text files
if event.path.suffix in (".py", ".pyi", ".txt", ".md", ".rst"):
if event.event_type in ("created", "modified"):
await self._refresh_problems()
await self._refresh_todos()
def _on_claude_event(self, event: ClaudeEvent) -> None:
"""Handle Claude Code events for IDE integration.
When Claude reads/edits files, we can update the UI accordingly.
"""
# Schedule handling on the main thread
self.call_later(self._handle_claude_event, event)
async def _handle_claude_event(self, event: ClaudeEvent) -> None:
"""Async handler for Claude events."""
if isinstance(event, FileReadEvent):
# Claude read a file - highlight in sidebar file tree
try:
sidebar = self.query_one(SidebarPanel)
sidebar.highlight_file(event.path)
except Exception:
pass
# Trigger extension hook
self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
elif isinstance(event, FileEditEvent):
# Claude edited a file - notify user
# Note: Git refresh is handled by FileEventMessage from the file watcher
self.notify(f"Claude edited: {event.path.name}", severity="information")
# Trigger extension hook
self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
elif isinstance(event, FileWriteEvent):
# Claude created/wrote a file - notify user
# Note: Git refresh and file tree refresh are handled by
# FileEventMessage from the file watcher (event-driven)
self.notify(f"Claude wrote: {event.path.name}", severity="information")
# Trigger extension hook
self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
def _apply_user_settings(self) -> None:
"""Apply saved user settings on startup."""
# Panel visibility
sidebar = self.query_one(SidebarPanel)
sidebar.visible = self._user_settings.sidebar_visible
context = self.query_one(ContextPanel)
context.visible = self._user_settings.context_visible
# Compact mode
self.compact_mode = self._user_settings.compact_mode
# Re-apply theme after mount (Textual needs this for proper initialization)
if self._user_settings.theme:
self.theme = self._user_settings.theme
# Reactive watchers
def watch_workspace_visible(self, visible: bool) -> None:
"""Update panels when workspace visibility changes."""
workspace = self.query_one(WorkspacePanel)
claude = self.query_one(ClaudePanel)
workspace.visible = visible
claude.workspace_visible = visible
def watch_compact_mode(self, compact: bool) -> None:
"""Toggle compact mode class."""
container = self.query_one("#main-container")
if compact:
container.add_class("compact")
else:
container.remove_class("compact")
# Data refresh methods
async def _refresh_git(self) -> None:
"""Refresh git status."""
status = await self.git_controller.get_status()
if status:
sidebar = self.query_one(SidebarPanel)
sidebar.update_git_status(status.staged, status.unstaged)
sidebar.current_branch = status.branch
branches = await self.git_controller.get_branches()
if branches:
sidebar = self.query_one(SidebarPanel)
sidebar.update_branches([b.name for b in branches])
commits = await self.git_controller.get_log(limit=50)
if commits:
sidebar = self.query_one(SidebarPanel)
sidebar.update_git_graph(commits)
async def _refresh_problems(self) -> None:
"""Refresh linter problems."""
problems = await self.problems_controller.run_all()
context = self.query_one(ContextPanel)
context.update_problems(problems)
async def _refresh_todos(self) -> None:
"""Refresh TODOs."""
code_todos, project_todos = await self.todos_controller.scan()
context = self.query_one(ContextPanel)
context.update_todos(code_todos, project_todos)
async def _refresh_jira(self) -> None:
"""Refresh Jira content."""
context = self.query_one(ContextPanel)
context.set_jira_loading()
content = await self.jira_controller.get_content()
if content:
context.update_jira(content)
else:
context.set_jira_error("Failed to load Jira content")
# Action methods
def action_toggle_sidebar(self) -> None:
"""Toggle sidebar visibility."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = not sidebar.visible
self._settings_service.set("sidebar_visible", sidebar.visible)
def action_toggle_context(self) -> None:
"""Toggle context panel visibility."""
context = self.query_one(ContextPanel)
context.visible = not context.visible
self._settings_service.set("context_visible", context.visible)
def action_toggle_terminal(self) -> None:
"""Toggle terminal (shows workspace with terminal tab)."""
workspace = self.query_one(WorkspacePanel)
if self.workspace_visible and workspace.get_active_tab_type() == "terminal":
self.workspace_visible = False
else:
workspace.show_terminal()
self.workspace_visible = True
def action_toggle_compact(self) -> None:
"""Toggle compact mode."""
self.compact_mode = not self.compact_mode
self._settings_service.set("compact_mode", self.compact_mode)
def action_toggle_fullscreen(self) -> None:
"""Toggle fullscreen for focused panel."""
# TODO: Implement fullscreen toggle
pass
def action_escape(self) -> None:
"""Handle escape key."""
if self.fullscreen_panel:
self.fullscreen_panel = None
elif self.workspace_visible:
workspace = self.query_one(WorkspacePanel)
if not workspace.has_unsaved_changes():
self.workspace_visible = False
def action_focus_claude(self) -> None:
"""Focus Claude panel."""
claude = self.query_one(ClaudePanel)
claude.focus_terminal()
def action_focus_editor(self) -> None:
"""Focus editor."""
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.focus_last_editor()
def action_focus_terminal(self) -> None:
"""Focus terminal."""
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.show_terminal()
def action_focus_sidebar(self) -> None:
"""Focus sidebar."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = True
sidebar.focus()
def action_close_tab(self) -> None:
"""Close current tab in workspace."""
workspace = self.query_one(WorkspacePanel)
workspace.close_tab()
def action_open_git(self) -> None:
"""Open git panel."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = True
sidebar.focus_tab("sidebar-git")
def action_open_problems(self) -> None:
"""Open problems panel."""
context = self.query_one(ContextPanel)
context.visible = True
context.focus_problems()
def action_next_problem(self) -> None:
"""Go to next problem."""
# TODO: Implement problem navigation
pass
def action_prev_problem(self) -> None:
"""Go to previous problem."""
# TODO: Implement problem navigation
pass
def action_save_file(self) -> None:
"""Save current file in editor."""
try:
workspace = self.query_one(WorkspacePanel)
if workspace.has_unsaved_changes():
workspace.save_active_editor()
else:
self.notify("No unsaved changes", severity="warning")
except Exception as e:
self.notify(f"Save failed: {e}", severity="error")
def action_undo(self) -> None:
"""Undo last action in focused widget."""
# Undo is handled by the focused widget (TextArea has built-in undo)
# This action provides feedback if no undo is available
focused = self.focused
if focused and hasattr(focused, "undo"):
focused.undo()
else:
self.notify("Undo not available", severity="warning")
def action_quick_open(self) -> None:
"""Quick file open."""
# TODO: Implement quick open
pass
async def action_select_theme(self) -> None:
"""Open theme selector."""
from clide.themes.registry import get_all_themes
# Get all available themes
themes = get_all_themes()
theme_names = [t.name for t in themes]
# Use Textual's built-in selection if available, otherwise cycle
# For now, simple cycle through themes
current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0
next_idx = (current_idx + 1) % len(theme_names)
self.set_theme(theme_names[next_idx])
# Event handlers for panel messages
async def on_sidebar_panel_file_selected(
self,
event: SidebarPanel.FileSelected,
) -> None:
"""Handle file selection from sidebar."""
self.current_file = event.path
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(event.path)
async def on_sidebar_panel_git_file_selected(
self,
event: SidebarPanel.GitFileSelected,
) -> None:
"""Handle git file selection - show diff."""
diff = await self.diff_controller.get_file_diff(
str(event.path),
staged=event.staged,
)
if diff:
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.show_diff(diff)
async def on_sidebar_panel_branch_changed(
self,
event: SidebarPanel.BranchChanged,
) -> None:
"""Handle branch change."""
success = await self.git_controller.checkout_branch(event.branch)
if success:
await self._refresh_git()
async def on_context_panel_problem_clicked(
self,
event: ContextPanel.ProblemClicked,
) -> None:
"""Handle problem click - open file at line."""
problem = event.problem
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(problem.file_path, line=problem.line)
async def on_context_panel_todo_clicked(
self,
event: ContextPanel.TodoClicked,
) -> None:
"""Handle TODO click - open file at line."""
item = event.item
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(item.file_path, line=item.line)
async def on_context_panel_project_todo_clicked(
self,
event: ContextPanel.ProjectTodoClicked,
) -> None:
"""Handle project TODO click - open TODO.md at line."""
item = event.item
todo_path = self.workdir / "TODO.md"
if todo_path.exists():
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(todo_path, line=item.line)
async def on_context_panel_todo_md_created(
self,
event: ContextPanel.TodoMdCreated,
) -> None:
"""Handle TODO.md creation - refresh TODOs and open file."""
# Refresh TODOs to pick up the new file
await self._refresh_todos()
# Open the new file in editor
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(event.path)
self.notify("Created TODO.md")
async def on_context_panel_jira_refresh_requested(
self,
_event: ContextPanel.JiraRefreshRequested,
) -> None:
"""Handle Jira refresh request."""
await self._refresh_jira()
async def on_workspace_panel_file_saved(
self,
event: WorkspacePanel.FileSaved,
) -> None:
"""Handle file save - refresh problems and git."""
self.notify(f"Saved: {event.path.name}", severity="information")
await self._refresh_git()
await self._refresh_problems()
async def on_workspace_panel_diff_accepted(
self,
event: WorkspacePanel.DiffAccepted,
) -> None:
"""Handle diff accept."""
await self.diff_controller.accept_proposal(event.file_path)
await self._refresh_git()
await self._refresh_problems()
async def on_workspace_panel_diff_rejected(
self,
event: WorkspacePanel.DiffRejected,
) -> None:
"""Handle diff reject."""
await self.diff_controller.reject_proposal(event.file_path)
workspace = self.query_one(WorkspacePanel)
workspace.clear_diff()
def on_claude_panel_claude_started(
self,
_event: ClaudePanel.ClaudeStarted,
) -> None:
"""Handle Claude Code started."""
self.notify("Claude Code started", severity="information")
def on_claude_panel_claude_exited(
self,
event: ClaudePanel.ClaudeExited,
) -> None:
"""Handle Claude Code exited."""
if event.return_code != 0:
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
def on_workspace_panel_maximize_requested(
self,
_event: WorkspacePanel.MaximizeRequested,
) -> None:
"""Handle workspace maximize request - hide sidebars."""
# Hide sidebars when workspace is maximized
sidebar = self.query_one(SidebarPanel)
context = self.query_one(ContextPanel)
sidebar.display = False
context.display = False
# Hide Claude panel
claude = self.query_one(ClaudePanel)
claude.display = False
def on_workspace_panel_restore_requested(
self,
_event: WorkspacePanel.RestoreRequested,
) -> None:
"""Handle workspace restore request - show sidebars."""
# Restore sidebars based on saved visibility settings
sidebar = self.query_one(SidebarPanel)
context = self.query_one(ContextPanel)
sidebar.display = self._user_settings.sidebar_visible
context.display = self._user_settings.context_visible
# Show Claude panel
claude = self.query_one(ClaudePanel)
claude.display = True
def on_workspace_panel_close_requested(
self,
_event: WorkspacePanel.CloseRequested,
) -> None:
"""Handle workspace close request."""
self.workspace_visible = False
def on_sidebar_panel_claude_command_requested(
self,
event: SidebarPanel.ClaudeCommandRequested,
) -> None:
"""Handle Claude command request from git panel.
Sends skill commands (e.g., /commit) to Claude terminal.
Ensures the specific skill is installed before sending.
"""
from clide.services.skill_installer import get_skill_installer
# Extract skill name from command (e.g., "/commit" -> "commit")
skill_name = event.command.lstrip("/").split()[0]
installer = get_skill_installer(project_dir=self.workdir)
# Quick check if already installed
if installer.is_installed(skill_name):
self._send_claude_command(event.command)
return
# Need to install - show notification and do in background
self.notify(f"Installing {skill_name} skill...", timeout=10)
self._install_skill_and_run(skill_name, event.command)
@work(thread=True)
def _install_skill_and_run(self, skill_name: str, command: str) -> tuple[str, str]:
"""Install skill in background thread and return command to run."""
self.git_controller._ensure_skill(skill_name)
return (skill_name, command)
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle worker completion."""
if event.state == WorkerState.SUCCESS:
# Check if this was a skill installation worker
if event.worker.name == "_install_skill_and_run":
result = event.worker.result
if result:
skill_name, command = result
self._send_claude_command(command)
self.notify(f"{skill_name} skill installed!", severity="information", timeout=3)
def _send_claude_command(self, command: str) -> None:
"""Send a command to Claude terminal."""
claude = self.query_one(ClaudePanel)
claude.send_input(command)
self.action_focus_claude()
+105
View File
@@ -0,0 +1,105 @@
"""Typer CLI entry point for Clide."""
from pathlib import Path
from typing import Annotated
import typer
from clide import __version__
app = typer.Typer(
name="clide",
help="A TUI CLI IDE for Claude Code CLI",
add_completion=True,
no_args_is_help=False,
)
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
typer.echo(f"clide {__version__}")
raise typer.Exit()
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
_version: Annotated[
bool | None,
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
] = None,
workdir: Annotated[
Path | None,
typer.Option("--workdir", "-w", help="Working directory to open"),
] = None,
) -> None:
"""Launch Clide TUI application."""
if ctx.invoked_subcommand is None:
from clide.app import ClideApp
app_instance = ClideApp(workdir=workdir)
app_instance.run()
@app.command()
def config() -> None:
"""Open configuration editor."""
typer.echo("Configuration editor not yet implemented")
@app.command()
def extensions() -> None:
"""List installed extensions."""
typer.echo("Extension manager not yet implemented")
@app.command()
def update(
check_only: Annotated[
bool,
typer.Option("--check", "-c", help="Only check for updates, don't install"),
] = False,
force: Annotated[
bool,
typer.Option("--force", "-f", help="Force update even if already on latest"),
] = False,
) -> None:
"""Check for and install updates.
Updates are downloaded from git.schweitz.net releases.
User settings in ~/.clide/ are preserved across updates.
"""
_ = force # TODO: Implement force update functionality
from clide.services.update_service import check_for_updates, perform_update
typer.echo(f"Current version: {__version__}")
typer.echo("Checking for updates...")
if check_only:
result = check_for_updates()
if result.error:
typer.echo(f"Error: {result.error}", err=True)
raise typer.Exit(1)
if result.update_available:
typer.echo(f"Update available: {result.latest_version}")
if result.release_info and result.release_info.release_notes:
typer.echo("\nRelease notes:")
typer.echo(result.release_info.release_notes[:500])
else:
typer.echo("Already running the latest version.")
return
# Perform update with progress indication
def progress_callback(downloaded: int, total: int) -> None:
if total > 0:
pct = (downloaded / total) * 100
typer.echo(f"\rDownloading: {pct:.1f}%", nl=False)
success, message = perform_update(progress_callback)
typer.echo("") # Newline after progress
typer.echo(message)
if not success:
raise typer.Exit(1)
+20
View File
@@ -0,0 +1,20 @@
"""Domain controllers for Clide."""
from clide.controllers.base import ControllerMixin, controller
from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
__all__ = [
"controller",
"ControllerMixin",
"GitController",
"EditorController",
"DiffController",
"ProblemsController",
"TodosController",
"JiraController",
]
+102
View File
@@ -0,0 +1,102 @@
"""Base controller utilities using decorator pattern."""
from functools import wraps
from typing import TYPE_CHECKING
from textual.message import Message
if TYPE_CHECKING:
from textual.app import App
def controller[T](cls: type[T]) -> type[T]:
"""Decorator to add controller capabilities to a class.
Adds:
- _app attribute for parent application reference
- set_app() method to set the application
- post_message() method to emit messages
- initialize() and shutdown() lifecycle hooks (if not defined)
Usage:
@controller
class GitController:
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
async def get_status(self) -> GitStatus:
...
"""
original_init = cls.__init__
@wraps(original_init)
def new_init(self, *args, **kwargs):
self._app = None
original_init(self, *args, **kwargs)
cls.__init__ = new_init
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
# Only add methods if they don't exist
if not hasattr(cls, "set_app"):
cls.set_app = set_app
if not hasattr(cls, "post_message"):
cls.post_message = post_message
if not hasattr(cls, "initialize"):
cls.initialize = initialize
if not hasattr(cls, "shutdown"):
cls.shutdown = shutdown
return cls
class ControllerMixin:
"""Mixin alternative for controller capabilities.
Use this if you prefer inheritance over decorators.
Usage:
class GitController(ControllerMixin):
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
"""
_app: "App[object] | None" = None
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
@property
def app(self) -> "App[object] | None":
"""Get the parent application."""
return self._app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
+330
View File
@@ -0,0 +1,330 @@
"""Diff controller for viewing and managing diffs."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.services.git_service import GitService
@controller
class DiffController:
"""Controller for diff viewing and Claude-proposed changes."""
class DiffLoaded(Message):
"""Emitted when a diff is loaded."""
def __init__(self, diff: DiffContent) -> None:
self.diff = diff
super().__init__()
class HunkAccepted(Message):
"""Emitted when a hunk is accepted."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class HunkRejected(Message):
"""Emitted when a hunk is rejected."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class AllChangesAccepted(Message):
"""Emitted when all changes are accepted."""
pass
class AllChangesRejected(Message):
"""Emitted when all changes are rejected."""
pass
def __init__(self, repo_path: Path) -> None:
self._git_service = GitService(repo_path)
self._state = DiffViewState()
@property
def state(self) -> DiffViewState:
"""Get diff view state."""
return self._state
@property
def diff(self) -> DiffContent | None:
"""Get current diff content."""
return self._state.diff
@property
def is_proposal(self) -> bool:
"""Check if current diff is a Claude proposal."""
return self._state.is_proposal
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Load diff from git.
Args:
path: File path
staged: Whether to load staged diff
Returns:
DiffContent or None if no diff
"""
diff_text = await self._git_service.get_diff(path, staged)
if not diff_text:
return None
diff = self._parse_diff(path, diff_text)
self._state.diff = diff
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
"""Load a Claude-proposed change as a diff.
Args:
path: File path
old_content: Original content
new_content: Proposed content
Returns:
DiffContent of the proposal
"""
diff = self._create_diff_from_content(path, old_content, new_content)
self._state.diff = diff
self._state.is_proposal = True
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def accept_hunk(self, index: int) -> None:
"""Accept a specific hunk.
Args:
index: Hunk index
"""
self._state.accepted_hunks.add(index)
self._state.rejected_hunks.discard(index)
def reject_hunk(self, index: int) -> None:
"""Reject a specific hunk.
Args:
index: Hunk index
"""
self._state.rejected_hunks.add(index)
self._state.accepted_hunks.discard(index)
def accept_all(self) -> None:
"""Accept all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.accepted_hunks.add(i)
self._state.rejected_hunks.clear()
def reject_all(self) -> None:
"""Reject all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.rejected_hunks.add(i)
self._state.accepted_hunks.clear()
def clear(self) -> None:
"""Clear current diff."""
self._state.diff = None
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
def toggle_side_by_side(self) -> bool:
"""Toggle side-by-side view.
Returns:
New side_by_side value
"""
self._state.side_by_side = not self._state.side_by_side
return self._state.side_by_side
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
"""Parse git diff output into DiffContent."""
hunks: list[DiffHunk] = []
current_hunk_lines: list[DiffLine] = []
current_header = ""
old_start = old_count = new_start = new_count = 0
for line in diff_text.split("\n"):
if line.startswith("@@"):
# Save previous hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
current_hunk_lines = []
# Parse hunk header
current_header = line
# Format: @@ -old_start,old_count +new_start,new_count @@
import re
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
if match:
old_start = int(match.group(1))
old_count = int(match.group(2)) if match.group(2) else 1
new_start = int(match.group(3))
new_count = int(match.group(4)) if match.group(4) else 1
elif line.startswith("+") and not line.startswith("+++"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line[1:],
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
))
elif line.startswith("-") and not line.startswith("---"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line[1:],
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
))
elif line.startswith(" "):
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
current_hunk_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line[1:],
old_line_num=old_num,
new_line_num=new_num,
))
# Save last hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
return DiffContent(
file_path=path,
hunks=tuple(hunks),
)
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Get diff for a file (alias for load_git_diff).
Args:
path: File path
staged: Whether to get staged diff
Returns:
DiffContent or None
"""
return await self.load_git_diff(path, staged)
async def accept_proposal(self, _file_path: str) -> bool:
"""Accept a proposed change and apply it.
Args:
file_path: Path to the file
Returns:
True if successful
"""
if not self._state.diff or not self._state.is_proposal:
return False
self.accept_all()
# TODO: Apply the changes to the file
self.clear()
return True
async def reject_proposal(self, _file_path: str) -> bool:
"""Reject a proposed change.
Args:
file_path: Path to the file
Returns:
True if successful
"""
self.reject_all()
self.clear()
return True
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
"""Create diff from old and new content."""
import difflib
old_lines = old.splitlines(keepends=True)
new_lines = new.splitlines(keepends=True)
diff_lines: list[DiffLine] = []
old_num = new_num = 1
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
if tag == "equal":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line.rstrip("\n"),
old_line_num=old_num,
new_line_num=new_num,
))
old_num += 1
new_num += 1
elif tag == "delete":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
elif tag == "insert":
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
elif tag == "replace":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
hunk = DiffHunk(
header="@@ -1 +1 @@",
old_start=1,
old_count=len(old_lines),
new_start=1,
new_count=len(new_lines),
lines=tuple(diff_lines),
)
return DiffContent(
file_path=path,
hunks=(hunk,),
)
+214
View File
@@ -0,0 +1,214 @@
"""Editor controller for managing open files."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.editor import CursorPosition, EditorState, FileBuffer
@controller
class EditorController:
"""Controller for editor state and file operations."""
class FileOpened(Message):
"""Emitted when a file is opened."""
def __init__(self, buffer: FileBuffer) -> None:
self.buffer = buffer
super().__init__()
class FileClosed(Message):
"""Emitted when a file is closed."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileSaved(Message):
"""Emitted when a file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileModified(Message):
"""Emitted when file content changes."""
def __init__(self, path: Path, is_modified: bool) -> None:
self.path = path
self.is_modified = is_modified
super().__init__()
class ActiveBufferChanged(Message):
"""Emitted when active buffer changes."""
def __init__(self, buffer: FileBuffer | None) -> None:
self.buffer = buffer
super().__init__()
def __init__(self, project_path: Path | None = None) -> None:
self._project_path = project_path or Path.cwd()
self._state = EditorState()
@property
def state(self) -> EditorState:
"""Get editor state."""
return self._state
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
return self._state.active_buffer
@property
def open_files(self) -> list[FileBuffer]:
"""Get list of open file buffers."""
return self._state.buffers
@property
def has_unsaved_changes(self) -> bool:
"""Check if any buffer has unsaved changes."""
return any(b.is_modified for b in self._state.buffers)
async def open_file(self, path: Path, line: int | None = None) -> FileBuffer:
"""Open a file in the editor.
Args:
path: File path to open
line: Optional line number to jump to
Returns:
FileBuffer for the opened file
"""
# Check if already open
existing = self._state.get_buffer_by_path(path)
if existing:
self._set_active_buffer(existing)
if line:
existing.cursor = CursorPosition(line=line - 1, column=0)
return existing
# Read file content
content = await self._service.read_file(path)
language = await self._service.get_language(path)
cursor = CursorPosition(line=line - 1 if line else 0, column=0)
buffer = FileBuffer(
path=path,
content=content,
language=language,
cursor=cursor,
)
self._state.buffers.append(buffer)
self._set_active_buffer(buffer)
# Add to recent files
if path not in self._state.recent_files:
self._state.recent_files.insert(0, path)
self._state.recent_files = self._state.recent_files[:20]
return buffer
async def close_file(self, path: Path) -> bool:
"""Close a file buffer.
Args:
path: File path to close
Returns:
True if closed (may be False if unsaved and user cancels)
"""
buffer = self._state.get_buffer_by_path(path)
if not buffer:
return True
# Remove from buffers
self._state.buffers.remove(buffer)
# Update active buffer
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(
self._state.buffers
):
self._state.active_buffer_index = (
len(self._state.buffers) - 1 if self._state.buffers else None
)
return True
async def save_file(self, path: Path | None = None) -> bool:
"""Save a file.
Args:
path: File path (defaults to active buffer)
Returns:
True if saved successfully
"""
buffer = self._state.get_buffer_by_path(path) if path else self.active_buffer
if not buffer:
return False
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
return True
async def save_all(self) -> int:
"""Save all modified buffers.
Returns:
Number of files saved
"""
count = 0
for buffer in self._state.buffers:
if buffer.is_modified:
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
count += 1
return count
def update_content(self, path: Path, content: str) -> None:
"""Update buffer content.
Args:
path: File path
content: New content
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.content = content
buffer.is_modified = True
def update_cursor(self, path: Path, line: int, column: int) -> None:
"""Update cursor position.
Args:
path: File path
line: Line number (0-indexed)
column: Column number (0-indexed)
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.cursor = CursorPosition(line=line, column=column)
def set_active_by_index(self, index: int) -> None:
"""Set active buffer by index.
Args:
index: Buffer index
"""
if 0 <= index < len(self._state.buffers):
self._state.active_buffer_index = index
def _set_active_buffer(self, buffer: FileBuffer) -> None:
"""Set active buffer."""
try:
index = self._state.buffers.index(buffer)
self._state.active_buffer_index = index
except ValueError:
pass
+303
View File
@@ -0,0 +1,303 @@
"""Git controller for managing git operations."""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from textual.message import Message
from clide.controllers.base import controller
from clide.services.git_service import GitService
if TYPE_CHECKING:
from pathlib import Path
from clide.models.git import GitBranch, GitCommit, GitStatus
from clide.services.skill_installer import get_skill_installer
# Git operations that can be delegated to Claude via skills
GitSkillCommand = Literal["commit", "stash", "pull", "push", "branch"]
@controller
class GitController:
"""Controller for git operations."""
class StatusUpdated(Message):
"""Emitted when git status changes."""
def __init__(self, status: GitStatus) -> None:
self.status = status
super().__init__()
class BranchesUpdated(Message):
"""Emitted when branches list changes."""
def __init__(self, branches: list[GitBranch]) -> None:
self.branches = branches
super().__init__()
class LogUpdated(Message):
"""Emitted when commit log is refreshed."""
def __init__(self, commits: list[GitCommit]) -> None:
self.commits = commits
super().__init__()
class FileStaged(Message):
"""Emitted when a file is staged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
class FileUnstaged(Message):
"""Emitted when a file is unstaged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
def __init__(self, repo_path: Path) -> None:
self._service = GitService(repo_path)
self._status: GitStatus | None = None
self._branches: list[GitBranch] = []
self._commits: list[GitCommit] = []
@property
def status(self) -> GitStatus | None:
"""Get current git status."""
return self._status
@property
def branches(self) -> list[GitBranch]:
"""Get list of branches."""
return self._branches
@property
def commits(self) -> list[GitCommit]:
"""Get commit log."""
return self._commits
@property
def current_branch(self) -> str:
"""Get current branch name."""
return self._status.branch if self._status else "unknown"
async def refresh_status(self) -> GitStatus:
"""Refresh git status.
Returns:
Updated GitStatus
"""
self._status = await self._service.get_status()
return self._status
async def get_status(self) -> GitStatus | None:
"""Get git status (refreshes if needed)."""
return await self.refresh_status()
async def refresh_branches(self) -> list[GitBranch]:
"""Refresh branches list.
Returns:
Updated list of branches
"""
self._branches = await self._service.get_branches()
return self._branches
async def get_branches(self) -> list[GitBranch]:
"""Get branches list (refreshes if needed)."""
return await self.refresh_branches()
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
"""Refresh commit log.
Args:
max_count: Maximum commits to fetch
Returns:
Updated list of commits
"""
self._commits = await self._service.get_log(max_count)
return self._commits
async def get_log(self, limit: int = 50) -> list[GitCommit]:
"""Get commit log (refreshes if needed)."""
return await self.refresh_log(limit)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
success = await self._service.stage_file(path)
if success:
await self.refresh_status()
return success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
success = await self._service.unstage_file(path)
if success:
await self.refresh_status()
return success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path
Returns:
True if successful
"""
success = await self._service.discard_changes(path)
if success:
await self.refresh_status()
return success
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name
Returns:
True if successful
"""
success = await self._service.checkout_branch(branch)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def create_branch(self, name: str) -> bool:
"""Create and checkout a new branch.
Args:
name: New branch name
Returns:
True if successful
"""
success = await self._service.create_branch(name)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def get_file_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff string
"""
return await self._service.get_diff(path, staged)
# =========================================================================
# Claude Skill Integration
# =========================================================================
class ClaudeCommandRequested(Message):
"""Emitted when a git command should be sent to Claude."""
def __init__(self, command: str) -> None:
self.command = command
super().__init__()
def _ensure_skill(self, skill_name: str) -> bool:
"""Ensure a specific skill is installed.
Args:
skill_name: The skill name (e.g., "commit", "stash").
Returns:
True if skill is available.
"""
installer = get_skill_installer()
# Check if already installed
if installer.is_installed(skill_name):
return True
# Try to install from template (project scope by default)
try:
installer.install(skill_name, scope="project")
return True
except ValueError:
# Template not found
return False
except FileExistsError:
# Already exists (race condition)
return True
def _ensure_git_skill(self) -> bool:
"""Ensure all git skills are installed (legacy compatibility).
Returns:
True if commit skill is available.
"""
return self._ensure_skill("commit")
def request_claude_commit(self) -> bool:
"""Request Claude to handle the commit workflow.
Emits ClaudeCommandRequested with /commit command.
Returns:
True if skill is available and command was requested.
"""
# The app will handle this message and send to Claude terminal
return self._ensure_git_skill()
def request_claude_stash(self) -> bool:
"""Request Claude to handle stashing changes.
Returns:
True if skill is available and command was requested.
"""
return self._ensure_git_skill()
def request_claude_pull(self) -> bool:
"""Request Claude to handle pulling changes.
Returns:
True if skill is available and command was requested.
"""
return self._ensure_git_skill()
def request_claude_push(self) -> bool:
"""Request Claude to handle pushing changes.
Returns:
True if skill is available and command was requested.
"""
return self._ensure_git_skill()
def get_claude_command(self, action: GitSkillCommand) -> str:
"""Get the Claude command string for a git action.
Args:
action: The git action to perform.
Returns:
The command string to send to Claude (e.g., "/commit").
"""
self._ensure_git_skill()
return f"/{action}"
+158
View File
@@ -0,0 +1,158 @@
"""Jira controller for Jira CLI integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.services.process_service import ProcessService
@controller
class JiraController:
"""Controller for Jira CLI integration."""
class JiraOutputUpdated(Message):
"""Emitted when Jira output is updated."""
def __init__(self, output: str) -> None:
self.output = output
super().__init__()
class JiraError(Message):
"""Emitted when Jira command fails."""
def __init__(self, error: str) -> None:
self.error = error
super().__init__()
def __init__(
self,
project_path: Path | None = None,
jira_cli_path: str = "jira",
enabled: bool = False,
) -> None:
self._project_path = project_path or Path.cwd()
self._jira_cli = jira_cli_path
self._enabled = enabled
self._last_output: str = ""
self._last_error: str = ""
@property
def enabled(self) -> bool:
"""Check if Jira integration is enabled."""
return self._enabled
@property
def last_output(self) -> str:
"""Get last Jira output."""
return self._last_output
@property
def last_error(self) -> str:
"""Get last error message."""
return self._last_error
def enable(self) -> None:
"""Enable Jira integration."""
self._enabled = True
def disable(self) -> None:
"""Disable Jira integration."""
self._enabled = False
async def run_command(self, *args: str) -> str:
"""Run a Jira CLI command.
Args:
*args: Command arguments
Returns:
Command output
"""
if not self._enabled:
return "Jira integration is disabled"
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, *args)
if result.success:
self._last_output = result.stdout
self._last_error = ""
return result.stdout
else:
self._last_error = result.stderr
return f"Error: {result.stderr}"
async def list_issues(self, project: str | None = None) -> str:
"""List Jira issues.
Args:
project: Optional project key
Returns:
Formatted issue list
"""
args = ["issue", "list"]
if project:
args.extend(["--project", project])
return await self.run_command(*args)
async def get_issue(self, issue_key: str) -> str:
"""Get a specific issue.
Args:
issue_key: Issue key (e.g., PROJ-123)
Returns:
Issue details
"""
return await self.run_command("issue", "view", issue_key)
async def get_my_issues(self) -> str:
"""Get issues assigned to current user.
Returns:
Formatted issue list
"""
return await self.run_command("issue", "list", "--assignee", "@me")
async def get_sprint_issues(self) -> str:
"""Get issues in current sprint.
Returns:
Formatted issue list
"""
return await self.run_command("sprint", "list", "--current")
async def refresh(self) -> str:
"""Refresh Jira data (get my issues).
Returns:
Updated output
"""
return await self.get_my_issues()
async def check_available(self) -> bool:
"""Check if Jira CLI is available.
Returns:
True if available
"""
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, "--version")
return result.success
async def get_content(self) -> str | None:
"""Get Jira content for display.
Returns:
Markdown content or None
"""
if not self._enabled:
return None
try:
return await self.get_my_issues()
except Exception:
return None
+180
View File
@@ -0,0 +1,180 @@
"""Problems controller for linter integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.services.linter_service import LinterService
@controller
class ProblemsController:
"""Controller for problems/diagnostics from linters."""
class ProblemsUpdated(Message):
"""Emitted when problems list is updated."""
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
self.problems = problems
self.summary = summary
super().__init__()
class ProblemSelected(Message):
"""Emitted when a problem is selected."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
self._service = LinterService(project_path)
self._linters = linters or ["ruff"]
self._state = ProblemsState()
@property
def state(self) -> ProblemsState:
"""Get problems state."""
return self._state
@property
def problems(self) -> list[Problem]:
"""Get list of problems."""
return self._state.problems
@property
def summary(self) -> ProblemsSummary:
"""Get problems summary."""
return self._state.summary
@property
def error_count(self) -> int:
"""Get error count."""
return self._state.summary.errors
@property
def warning_count(self) -> int:
"""Get warning count."""
return self._state.summary.warnings
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
"""Refresh problems from all linters.
Returns:
Tuple of (problems, summary)
"""
problems, summary = await self._service.run_all(self._linters)
self._state.problems = problems
self._state.summary = summary
return problems, summary
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
"""Filter problems by severity.
Args:
severity: Severity to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_severity = severity
if severity is None:
return self._state.problems
return [p for p in self._state.problems if p.severity == severity]
def filter_by_source(self, source: str | None) -> list[Problem]:
"""Filter problems by source linter.
Args:
source: Source to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_source = source
if source is None:
return self._state.problems
return [p for p in self._state.problems if p.source == source]
def get_problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file.
Args:
path: File path
Returns:
List of problems for that file
"""
return self._state.problems_for_file(path)
def select_problem(self, index: int) -> Problem | None:
"""Select a problem by index.
Args:
index: Problem index
Returns:
Selected problem or None
"""
if 0 <= index < len(self._state.problems):
self._state.selected_index = index
return self._state.problems[index]
return None
def next_problem(self) -> Problem | None:
"""Select next problem.
Returns:
Next problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = 0
else:
self._state.selected_index = (
self._state.selected_index + 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def prev_problem(self) -> Problem | None:
"""Select previous problem.
Returns:
Previous problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = len(self._state.problems) - 1
else:
self._state.selected_index = (
self._state.selected_index - 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def clear(self) -> None:
"""Clear all problems."""
self._state.problems = []
self._state.summary = ProblemsSummary()
self._state.selected_index = None
async def run_all(self) -> list[Problem]:
"""Run all linters and return problems.
Returns:
List of problems
"""
problems, _ = await self.refresh()
return problems
+163
View File
@@ -0,0 +1,163 @@
"""TODOs controller for tracking TODO comments."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.todos import (
ProjectTodoItem,
TodoItem,
TodosState,
TodosSummary,
TodoType,
)
from clide.services.todo_scanner import TodoScanner
@controller
class TodosController:
"""Controller for TODO/FIXME comment tracking."""
class TodosUpdated(Message):
"""Emitted when TODOs list is updated."""
def __init__(
self,
items: list[TodoItem],
project_items: list[ProjectTodoItem],
summary: TodosSummary,
) -> None:
self.items = items
self.project_items = project_items
self.summary = summary
super().__init__()
class TodoSelected(Message):
"""Emitted when a TODO is selected."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
def __init__(self, project_path: Path) -> None:
self._scanner = TodoScanner(project_path)
self._state = TodosState()
@property
def state(self) -> TodosState:
"""Get TODOs state."""
return self._state
@property
def items(self) -> list[TodoItem]:
"""Get list of code TODO items."""
return self._state.items
@property
def project_items(self) -> list[ProjectTodoItem]:
"""Get list of project TODO items from TODO.md."""
return self._state.project_items
@property
def summary(self) -> TodosSummary:
"""Get TODOs summary."""
return self._state.summary
@property
def total_count(self) -> int:
"""Get total code TODO count."""
return self._state.summary.total
@property
def project_count(self) -> int:
"""Get total project TODO count."""
return self._state.summary.project_total
async def refresh(
self,
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
"""Refresh TODOs from project.
Returns:
Tuple of (code items, project items, summary)
"""
items, project_items, summary = await self._scanner.scan()
self._state.items = items
self._state.project_items = project_items
self._state.summary = summary
return items, project_items, summary
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
"""Filter TODOs by type.
Args:
todo_type: Type to filter by, or None for all
Returns:
Filtered list of TODOs
"""
self._state.filter_type = todo_type
if todo_type is None:
return self._state.items
return self._state.items_by_type(todo_type)
def get_items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODOs for a specific file.
Args:
path: File path
Returns:
List of TODOs for that file
"""
return self._state.items_for_file(path)
def select_item(self, index: int) -> TodoItem | None:
"""Select a TODO by index.
Args:
index: Item index
Returns:
Selected item or None
"""
if 0 <= index < len(self._state.items):
self._state.selected_index = index
return self._state.items[index]
return None
def toggle_group_by_file(self) -> bool:
"""Toggle grouping by file.
Returns:
New group_by_file value
"""
self._state.group_by_file = not self._state.group_by_file
return self._state.group_by_file
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
"""Get TODOs grouped by file.
Returns:
Dictionary mapping file paths to TODO lists
"""
grouped: dict[Path, list[TodoItem]] = {}
for item in self._state.items:
if item.file_path not in grouped:
grouped[item.file_path] = []
grouped[item.file_path].append(item)
return grouped
async def scan(self) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
"""Scan for TODOs and return items.
Returns:
Tuple of (code items, project items)
"""
items, project_items, _ = await self.refresh()
return items, project_items
+6
View File
@@ -0,0 +1,6 @@
"""Extension system for Clide using pluggy."""
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
from clide.extensions.manager import ExtensionManager
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
@@ -0,0 +1 @@
"""Built-in extensions for Clide."""
+116
View File
@@ -0,0 +1,116 @@
"""Pluggy hook specifications for Clide extensions."""
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pluggy
if TYPE_CHECKING:
from textual.app import App
from textual.widget import Widget
from clide.services.file_watcher import FileEvent
hookspec = pluggy.HookspecMarker("clide")
hookimpl = pluggy.HookimplMarker("clide")
class ClideHookSpec:
"""Hook specifications for Clide extensions.
Extensions implement these hooks to extend functionality.
"""
@hookspec
def clide_register_panel(self) -> dict[str, Any] | None:
"""Register a custom panel for the UI.
Returns:
Dictionary with panel configuration:
- name: Panel identifier
- widget: Widget class to instantiate
- position: "left", "right", or "bottom"
- keybinding: Optional keyboard shortcut
"""
@hookspec
def clide_register_commands(self) -> list[dict[str, Any]] | None:
"""Register custom commands for the command palette.
Returns:
List of command dictionaries:
- name: Command display name
- callback: Async callable to execute
- description: Help text
"""
@hookspec
def clide_on_app_startup(self, app: "App[object]") -> None:
"""Called when the application starts.
Args:
app: The Clide application instance
"""
@hookspec
def clide_on_app_shutdown(self, app: "App[object]") -> None:
"""Called when the application is shutting down.
Args:
app: The Clide application instance
"""
@hookspec
def clide_on_file_open(self, path: str) -> None:
"""Called when a file is opened in the file browser.
Args:
path: Absolute path to the opened file
"""
@hookspec
def clide_modify_widget(self, widget: "Widget") -> "Widget":
"""Modify a widget before it's mounted.
Args:
widget: The widget about to be mounted
Returns:
The modified (or original) widget
"""
@hookspec
def clide_on_file_changed(self, event: "FileEvent") -> None:
"""Called when a file is created, modified, deleted, or moved.
Extensions can use this to:
- Refresh TODO scanning
- Re-run linters
- Update Jira issue links
- Trigger custom actions
Args:
event: The file event with path, type, and timestamp
"""
@hookspec
def clide_on_file_saved(self, path: Path) -> None:
"""Called after a file is saved by the editor.
More specific than file_changed - only for user saves.
Args:
path: Absolute path to the saved file
"""
@hookspec
def clide_on_claude_event(self, event_type: str, data: dict[str, Any]) -> None:
"""Called when Claude Code performs an action.
Extensions can use this to react to Claude's actions,
such as opening files, making edits, or running commands.
Args:
event_type: Type of event (e.g., "file_read", "file_edit", "tool_use")
data: Event-specific data (e.g., {"path": "/path/to/file"})
"""
+108
View File
@@ -0,0 +1,108 @@
"""Extension manager for loading and managing Clide extensions."""
from importlib.metadata import entry_points
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pluggy
from clide.extensions.hookspecs import ClideHookSpec
if TYPE_CHECKING:
from textual.app import App
from clide.services.file_watcher import FileEvent
EXTENSION_NAMESPACE = "clide.extensions"
class ExtensionManager:
"""Manages loading and lifecycle of Clide extensions."""
def __init__(self) -> None:
self._pm = pluggy.PluginManager("clide")
self._pm.add_hookspecs(ClideHookSpec)
self._loaded: list[str] = []
@property
def hook(self) -> pluggy.HookRelay:
"""Access the hook relay for calling hooks."""
return self._pm.hook
def load_extensions(self) -> None:
"""Load all extensions from entry points."""
eps = entry_points(group=EXTENSION_NAMESPACE)
for ep in eps:
try:
plugin = ep.load()
self._pm.register(plugin, name=ep.name)
self._loaded.append(ep.name)
except Exception as e:
# Log but don't crash on extension load failure
print(f"Failed to load extension {ep.name}: {e}")
def register_plugin(self, plugin: object, name: str) -> None:
"""Manually register a plugin instance.
Args:
plugin: Plugin object with hookimpl methods
name: Unique name for the plugin
"""
self._pm.register(plugin, name=name)
self._loaded.append(name)
def unregister_plugin(self, name: str) -> None:
"""Unregister a plugin by name.
Args:
name: Name of the plugin to unregister
"""
plugin = self._pm.get_plugin(name)
if plugin:
self._pm.unregister(plugin)
self._loaded.remove(name)
def list_extensions(self) -> list[str]:
"""Get list of loaded extension names."""
return self._loaded.copy()
async def trigger_app_startup(self, app: "App[object]") -> None:
"""Trigger startup hooks for all extensions.
Args:
app: The Clide application instance
"""
self.hook.clide_on_app_startup(app=app)
async def trigger_app_shutdown(self, app: "App[object]") -> None:
"""Trigger shutdown hooks for all extensions.
Args:
app: The Clide application instance
"""
self.hook.clide_on_app_shutdown(app=app)
def trigger_file_changed(self, event: "FileEvent") -> None:
"""Trigger file change hooks for all extensions.
Args:
event: The file event with path, type, and timestamp
"""
self.hook.clide_on_file_changed(event=event)
def trigger_file_saved(self, path: Path) -> None:
"""Trigger file saved hooks for all extensions.
Args:
path: Path to the saved file
"""
self.hook.clide_on_file_saved(path=path)
def trigger_claude_event(self, event_type: str, data: dict[str, Any]) -> None:
"""Trigger Claude event hooks for all extensions.
Args:
event_type: Type of event (e.g., "file_read", "file_edit")
data: Event-specific data
"""
self.hook.clide_on_claude_event(event_type=event_type, data=data)
+1
View File
@@ -0,0 +1 @@
"""Shared utility functions and helpers for Clide."""
+65
View File
@@ -0,0 +1,65 @@
"""Pydantic models for Clide."""
from clide.models.config import ClideSettings, PanelConfig
from clide.models.db import ConnectionLog, Project, Session, UserPreference
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitGraph,
GitStatus,
)
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
from clide.models.workspace import TAB_ICONS, TabInfo, TabType
__all__ = [
# Config
"ClideSettings",
"PanelConfig",
# Git
"ChangeStatus",
"GitBranch",
"GitChange",
"GitCommit",
"GitGraph",
"GitStatus",
# Editor
"CursorPosition",
"EditorState",
"FileBuffer",
"Selection",
# Diff
"ChangeType",
"DiffContent",
"DiffHunk",
"DiffLine",
"DiffViewState",
# Problems
"Problem",
"ProblemsSummary",
"ProblemsState",
"Severity",
# Todos
"TodoItem",
"TodosSummary",
"TodosState",
"TodoType",
# Theme
"ThemeColors",
"ThemeDefinition",
"ThemeMetadata",
# Workspace
"TabInfo",
"TabType",
"TAB_ICONS",
# Database (SQLModel)
"Project",
"Session",
"UserPreference",
"ConnectionLog",
]
+94
View File
@@ -0,0 +1,94 @@
"""Configuration models using Pydantic Settings."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from pydantic_settings import BaseSettings, SettingsConfigDict
class ClideSettings(BaseSettings):
"""Main application settings loaded from environment and config files."""
model_config = SettingsConfigDict(
env_prefix="CLIDE_",
env_file=".config/.env",
env_file_encoding="utf-8",
extra="ignore",
)
# Appearance
theme: str = "summer-night" # Default to Summer Night
# Paths
claude_path: str = "claude"
default_workdir: Path = Path.cwd()
jira_cli_path: str = "jira"
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
confluence_enabled: bool = False
imagin_enabled: bool = False
# Linters
linters: list[str] = ["ruff"]
class PanelConfig(BaseModel):
"""Panel visibility and layout configuration."""
model_config = ConfigDict(strict=True, frozen=True)
sidebar_visible: bool = True
context_visible: bool = True
workspace_visible: bool = False # Hidden by default
sidebar_width_percent: int = 20
context_width_percent: int = 25
class KeybindingsConfig(BaseModel):
"""Keybinding configuration."""
model_config = ConfigDict(strict=True)
# Global
command_palette: str = "ctrl+shift+p"
quick_open: str = "ctrl+p"
toggle_sidebar: str = "ctrl+b"
toggle_context: str = "ctrl+shift+b"
toggle_terminal: str = "ctrl+`"
toggle_compact: str = "ctrl+shift+c"
fullscreen: str = "f11"
# Navigation
focus_claude: str = "ctrl+1"
focus_editor: str = "ctrl+2"
focus_terminal: str = "ctrl+3"
focus_sidebar: str = "ctrl+0"
next_tab: str = "ctrl+tab"
prev_tab: str = "ctrl+shift+tab"
close_tab: str = "ctrl+w"
# Git
git_panel: str = "ctrl+shift+g"
stage_file: str = "ctrl+enter"
unstage_file: str = "ctrl+backspace"
# Search
find_in_file: str = "ctrl+f"
find_in_project: str = "ctrl+shift+f"
problems_panel: str = "ctrl+shift+m"
next_problem: str = "f8"
prev_problem: str = "shift+f8"
# Editor
save: str = "ctrl+s"
undo: str = "ctrl+z"
redo: str = "ctrl+shift+z"
go_to_line: str = "ctrl+g"
# Theme
select_theme: str = "ctrl+k ctrl+t"
+50
View File
@@ -0,0 +1,50 @@
"""SQLModel table models for persistent storage.
These models serve both the standalone Clide TUI and the clide-web server.
They ARE Pydantic models (SQLModel inherits from BaseModel).
"""
from datetime import datetime
from sqlmodel import Field, SQLModel
class Project(SQLModel, table=True):
"""A project (git repo) that can be opened in Clide."""
id: int | None = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True)
path: str
theme: str = "summer-night"
last_accessed: datetime | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)
class Session(SQLModel, table=True):
"""A tmux session running a Clide instance (used by clide-web)."""
id: int | None = Field(default=None, primary_key=True)
project_name: str = Field(index=True)
tmux_session: str = Field(unique=True)
pid: int | None = None
status: str = "active"
created_at: datetime = Field(default_factory=datetime.utcnow)
last_activity: datetime = Field(default_factory=datetime.utcnow)
class UserPreference(SQLModel, table=True):
"""Key-value user preferences persisted across restarts."""
id: int | None = Field(default=None, primary_key=True)
key: str = Field(unique=True, index=True)
value: str
class ConnectionLog(SQLModel, table=True):
"""Audit log of browser connections (used by clide-web)."""
id: int | None = Field(default=None, primary_key=True)
project_name: str
client_ip: str
connected_at: datetime = Field(default_factory=datetime.utcnow)
disconnected_at: datetime | None = None
+66
View File
@@ -0,0 +1,66 @@
"""Diff-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeType(str, Enum):
"""Type of change in a diff line."""
ADDED = "added"
REMOVED = "removed"
CONTEXT = "context"
HEADER = "header"
class DiffLine(BaseModel):
"""A single line in a diff."""
model_config = ConfigDict(strict=True, frozen=True)
change_type: ChangeType
content: str
old_line_num: int | None = None
new_line_num: int | None = None
class DiffHunk(BaseModel):
"""A hunk (section) of a diff."""
model_config = ConfigDict(strict=True, frozen=True)
header: str
old_start: int
old_count: int
new_start: int
new_count: int
lines: tuple[DiffLine, ...]
class DiffContent(BaseModel):
"""Complete diff for a file."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: str
old_path: str | None = None # For renames
hunks: tuple[DiffHunk, ...]
is_binary: bool = False
is_new_file: bool = False
is_deleted: bool = False
class DiffViewState(BaseModel):
"""State of the diff viewer."""
model_config = ConfigDict(strict=True)
diff: DiffContent | None = None
scroll_offset: int = 0
selected_hunk_index: int | None = None
side_by_side: bool = True
# For Claude-proposed changes
is_proposal: bool = False
accepted_hunks: set[int] = set()
rejected_hunks: set[int] = set()
+72
View File
@@ -0,0 +1,72 @@
"""Editor-related Pydantic models."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class CursorPosition(BaseModel):
"""Cursor position in editor."""
model_config = ConfigDict(strict=True, frozen=True)
line: int
column: int
class Selection(BaseModel):
"""Text selection range."""
model_config = ConfigDict(strict=True, frozen=True)
start: CursorPosition
end: CursorPosition
class FileBuffer(BaseModel):
"""A file buffer in the editor."""
model_config = ConfigDict(strict=True)
path: Path
content: str
language: str | None = None
is_modified: bool = False
cursor: CursorPosition = CursorPosition(line=0, column=0)
selection: Selection | None = None
scroll_offset: int = 0
@property
def filename(self) -> str:
"""Get the filename from path."""
return self.path.name
@property
def display_name(self) -> str:
"""Get display name with modification indicator."""
prefix = "" if self.is_modified else ""
return f"{prefix}{self.filename}"
class EditorState(BaseModel):
"""State of the editor panel."""
model_config = ConfigDict(strict=True)
buffers: list[FileBuffer] = []
active_buffer_index: int | None = None
recent_files: list[Path] = []
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
if self.active_buffer_index is not None and self.buffers:
return self.buffers[self.active_buffer_index]
return None
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
"""Find a buffer by its file path."""
for buffer in self.buffers:
if buffer.path == path:
return buffer
return None
+80
View File
@@ -0,0 +1,80 @@
"""Git-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeStatus(str, Enum):
"""Git file change status."""
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
RENAMED = "renamed"
COPIED = "copied"
UNTRACKED = "untracked"
IGNORED = "ignored"
UNMERGED = "unmerged"
class GitChange(BaseModel):
"""A single file change in git."""
model_config = ConfigDict(strict=True, frozen=True)
path: str
status: ChangeStatus
staged: bool
old_path: str | None = None # For renames
class GitStatus(BaseModel):
"""Current git repository status."""
model_config = ConfigDict(strict=True, frozen=True)
branch: str
ahead: int = 0
behind: int = 0
staged: tuple[GitChange, ...]
unstaged: tuple[GitChange, ...]
untracked: tuple[str, ...] = ()
has_conflicts: bool = False
class GitBranch(BaseModel):
"""Git branch information."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
is_current: bool = False
is_remote: bool = False
tracking: str | None = None
commit_hash: str | None = None
commit_message: str | None = None
class GitCommit(BaseModel):
"""Git commit information for graph view."""
model_config = ConfigDict(strict=True, frozen=True)
hash: str
short_hash: str
message: str
author: str
date: str
is_merge: bool = False
refs: tuple[str, ...] = () # branch names, tags
parents: tuple[str, ...] = ()
class GitGraph(BaseModel):
"""Git log graph data."""
model_config = ConfigDict(strict=True, frozen=True)
commits: tuple[GitCommit, ...]
branches: tuple[GitBranch, ...]
+88
View File
@@ -0,0 +1,88 @@
"""Problems (linter errors) Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class Severity(str, Enum):
"""Problem severity level."""
ERROR = "error"
WARNING = "warning"
INFO = "info"
HINT = "hint"
class Problem(BaseModel):
"""A single linter problem/diagnostic."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
end_line: int | None = None
end_column: int | None = None
severity: Severity
message: str
source: str # e.g., "ruff", "mypy", "eslint"
code: str | None = None # e.g., "E501", "W0612"
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}:{self.column}"
@property
def severity_icon(self) -> str:
"""Icon for severity level."""
icons = {
Severity.ERROR: "",
Severity.WARNING: "",
Severity.INFO: "",
Severity.HINT: "💡",
}
return icons[self.severity]
class ProblemsSummary(BaseModel):
"""Summary of problems in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
errors: int = 0
warnings: int = 0
infos: int = 0
hints: int = 0
@property
def total(self) -> int:
"""Total number of problems."""
return self.errors + self.warnings + self.infos + self.hints
@property
def display_text(self) -> str:
"""Text for tab badge."""
if self.errors:
return f"{self.errors}"
if self.warnings:
return f"{self.warnings}"
return f"{self.total}"
class ProblemsState(BaseModel):
"""State of the problems panel."""
model_config = ConfigDict(strict=True)
problems: list[Problem] = []
summary: ProblemsSummary = ProblemsSummary()
filter_severity: Severity | None = None
filter_source: str | None = None
selected_index: int | None = None
def problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file."""
return [p for p in self.problems if p.file_path == path]
+102
View File
@@ -0,0 +1,102 @@
"""Theme-related Pydantic models."""
import re
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, field_validator
if TYPE_CHECKING:
from textual.theme import Theme
def validate_hex_color(value: str) -> str:
"""Validate hex color format."""
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
raise ValueError(f"Invalid hex color: {value}")
return value.lower()
class ThemeColors(BaseModel):
"""Color definitions for a theme."""
model_config = ConfigDict(strict=True, frozen=True)
# Core colors
primary: str
secondary: str
accent: str
# Backgrounds
background: str
surface: str
panel: str
# Text
foreground: str
# Status
success: str
warning: str
error: str
@field_validator("*", mode="before")
@classmethod
def validate_colors(cls, v: str) -> str:
"""Validate all color fields are valid hex."""
return validate_hex_color(v)
class ThemeDefinition(BaseModel):
"""Complete theme definition."""
model_config = ConfigDict(strict=True)
name: str # e.g., "summer-night"
display_name: str # e.g., "Summer Night"
dark: bool # True for dark themes
colors: ThemeColors
def to_textual_colors(self) -> dict[str, str]:
"""Convert to Textual theme color dict."""
return {
"primary": self.colors.primary,
"secondary": self.colors.secondary,
"accent": self.colors.accent,
"background": self.colors.background,
"surface": self.colors.surface,
"panel": self.colors.panel,
"foreground": self.colors.foreground,
"success": self.colors.success,
"warning": self.colors.warning,
"error": self.colors.error,
}
def to_textual_theme(self) -> "Theme":
"""Convert to a Textual Theme object."""
from textual.theme import Theme
return Theme(
name=self.name,
primary=self.colors.primary,
secondary=self.colors.secondary,
accent=self.colors.accent,
background=self.colors.background,
surface=self.colors.surface,
panel=self.colors.panel,
foreground=self.colors.foreground,
success=self.colors.success,
warning=self.colors.warning,
error=self.colors.error,
dark=self.dark,
)
class ThemeMetadata(BaseModel):
"""Theme metadata for listing themes."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
display_name: str
dark: bool
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
+138
View File
@@ -0,0 +1,138 @@
"""TODO comments Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class TodoType(str, Enum):
"""Type of TODO comment."""
TODO = "TODO"
FIXME = "FIXME"
HACK = "HACK"
XXX = "XXX"
NOTE = "NOTE"
BUG = "BUG"
OPTIMIZE = "OPTIMIZE"
REVIEW = "REVIEW"
class ProjectTodoItem(BaseModel):
"""A TODO item from TODO.md file."""
model_config = ConfigDict(strict=True, frozen=True)
text: str
section: str # Top-level section (## heading)
subsection: str | None = None # Optional subsection (### heading)
line: int # Line number in TODO.md
checked: bool = False # Whether the checkbox is checked
@property
def category(self) -> str:
"""Get full category path."""
if self.subsection:
return f"{self.section} {self.subsection}"
return self.section
@property
def icon(self) -> str:
"""Icon for display."""
return "" if self.checked else ""
class TodoItem(BaseModel):
"""A single TODO comment found in code."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
todo_type: TodoType
text: str
context_line: str # The full line containing the TODO
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}"
@property
def type_icon(self) -> str:
"""Icon for TODO type."""
icons = {
TodoType.TODO: "",
TodoType.FIXME: "🔧",
TodoType.HACK: "",
TodoType.XXX: "",
TodoType.NOTE: "📝",
TodoType.BUG: "🐛",
TodoType.OPTIMIZE: "",
TodoType.REVIEW: "👀",
}
return icons[self.todo_type]
class TodosSummary(BaseModel):
"""Summary of TODOs in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
todo_count: int = 0
fixme_count: int = 0
hack_count: int = 0
other_count: int = 0
project_todo_count: int = 0 # Count from TODO.md
project_done_count: int = 0 # Checked items in TODO.md
@property
def total(self) -> int:
"""Total number of code TODOs."""
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
@property
def project_total(self) -> int:
"""Total number of project TODOs."""
return self.project_todo_count + self.project_done_count
@property
def display_text(self) -> str:
"""Text for tab badge."""
return f"{self.total}"
class TodosState(BaseModel):
"""State of the TODOs panel."""
model_config = ConfigDict(strict=True)
items: list[TodoItem] = []
project_items: list[ProjectTodoItem] = [] # Items from TODO.md
summary: TodosSummary = TodosSummary()
filter_type: TodoType | None = None
selected_index: int | None = None
group_by_file: bool = True
show_completed_project_todos: bool = False # Toggle for checked items
def items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODO items for a specific file."""
return [item for item in self.items if item.file_path == path]
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Get TODO items of a specific type."""
return [item for item in self.items if item.todo_type == todo_type]
def project_items_by_section(self, section: str) -> list[ProjectTodoItem]:
"""Get project TODO items for a specific section."""
return [item for item in self.project_items if item.section == section]
def get_project_sections(self) -> list[str]:
"""Get unique sections from project TODOs."""
sections: list[str] = []
for item in self.project_items:
if item.section not in sections:
sections.append(item.section)
return sections
+35
View File
@@ -0,0 +1,35 @@
"""Workspace tab models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class TabType(str, Enum):
"""Types of workspace tabs."""
EDITOR = "editor"
TERMINAL = "terminal"
DIFF = "diff"
# Nerd Font icons for each tab type
TAB_ICONS: dict[TabType, str] = {
TabType.EDITOR: "\uf15c", # nf-fa-file_text_o
TabType.TERMINAL: "\uf120", # nf-fa-terminal
TabType.DIFF: "\uf440", # nf-oct-diff
}
class TabInfo(BaseModel):
"""Metadata for a workspace tab."""
model_config = ConfigDict(strict=True)
tab_id: str
tab_type: TabType
label: str
file_path: Path | None = None
is_proposal: bool = False
diff_file_path: str | None = None
+20
View File
@@ -0,0 +1,20 @@
"""Business logic services for Clide."""
from clide.services.git_service import GitService
from clide.services.linter_service import LinterService
from clide.services.process_service import ProcessService
from clide.services.settings_service import SettingsService, UserSettings, get_settings_service
from clide.services.skill_installer import SkillInstaller, get_skill_installer
from clide.services.todo_scanner import TodoScanner
__all__ = [
"GitService",
"LinterService",
"ProcessService",
"SettingsService",
"SkillInstaller",
"TodoScanner",
"UserSettings",
"get_settings_service",
"get_skill_installer",
]
+238
View File
@@ -0,0 +1,238 @@
"""Claude Code event detection and parsing.
This module provides event infrastructure for detecting Claude Code actions
from terminal output, enabling tight IDE integration.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from textual.message import Message
# Event Types
# -----------
@dataclass
class ClaudeEvent:
"""Base class for Claude Code events."""
pass
@dataclass
class FileReadEvent(ClaudeEvent):
"""Emitted when Claude reads a file."""
path: Path
@dataclass
class FileEditEvent(ClaudeEvent):
"""Emitted when Claude edits a file."""
path: Path
@dataclass
class FileWriteEvent(ClaudeEvent):
"""Emitted when Claude creates/writes a file."""
path: Path
@dataclass
class GlobEvent(ClaudeEvent):
"""Emitted when Claude searches for files."""
pattern: str
@dataclass
class GrepEvent(ClaudeEvent):
"""Emitted when Claude searches file contents."""
pattern: str
@dataclass
class ToolStartEvent(ClaudeEvent):
"""Emitted when Claude starts using a tool."""
tool_name: str
@dataclass
class ToolEndEvent(ClaudeEvent):
"""Emitted when Claude finishes using a tool."""
tool_name: str
@dataclass
class DiffProposedEvent(ClaudeEvent):
"""Emitted when Claude proposes a diff."""
content: str
# Textual Messages
# ----------------
class ClaudeEventMessage(Message):
"""Textual message wrapper for Claude events."""
def __init__(self, event: ClaudeEvent) -> None:
self.event = event
super().__init__()
# Pattern Matching
# ----------------
# Patterns for detecting Claude Code output
PATTERNS = {
# Tool invocations - Claude Code shows these with bullet points
"tool_read": re.compile(r"● Read\(([^)]+)\)"),
"tool_edit": re.compile(r"● Edit\(([^)]+)\)"),
"tool_write": re.compile(r"● Write\(([^)]+)\)"),
"tool_glob": re.compile(r"● Glob\(([^)]+)\)"),
"tool_grep": re.compile(r"● Grep\(([^)]+)\)"),
# Generic tool pattern
"tool_start": re.compile(r"● (\w+)\("),
"tool_end": re.compile(r"└─"),
# Diff headers
"diff_header": re.compile(r"^@@\s*-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*@@", re.MULTILINE),
"diff_file": re.compile(r"^(?:---|\+\+\+)\s+([^\s]+)", re.MULTILINE),
}
class ClaudeEventParser:
"""Parses Claude Code terminal output to detect events.
This parser is designed to work with raw terminal data fed
through the pyte event callback.
"""
def __init__(self, callback: Callable[[ClaudeEvent], None] | None = None) -> None:
"""Initialize the event parser.
Args:
callback: Optional callback invoked for each detected event.
"""
self._callback = callback
self._buffer = ""
self._current_tool: str | None = None
def set_callback(self, callback: Callable[[ClaudeEvent], None] | None) -> None:
"""Set the event callback."""
self._callback = callback
def feed(self, data: str) -> list[ClaudeEvent]:
"""Feed terminal data and return detected events.
Args:
data: Raw terminal data from Claude Code.
Returns:
List of detected events.
"""
events: list[ClaudeEvent] = []
# Add to buffer for multi-line matching
self._buffer += data
# Limit buffer size to prevent memory issues
if len(self._buffer) > 10000:
self._buffer = self._buffer[-5000:]
# Check for tool invocations
for match in PATTERNS["tool_read"].finditer(data):
path = Path(match.group(1).strip())
events.append(FileReadEvent(path=path))
for match in PATTERNS["tool_edit"].finditer(data):
path = Path(match.group(1).strip())
events.append(FileEditEvent(path=path))
for match in PATTERNS["tool_write"].finditer(data):
path = Path(match.group(1).strip())
events.append(FileWriteEvent(path=path))
for match in PATTERNS["tool_glob"].finditer(data):
pattern = match.group(1).strip()
events.append(GlobEvent(pattern=pattern))
for match in PATTERNS["tool_grep"].finditer(data):
pattern = match.group(1).strip()
events.append(GrepEvent(pattern=pattern))
# Check for generic tool start/end
for match in PATTERNS["tool_start"].finditer(data):
tool_name = match.group(1)
# Don't emit for tools we handle specifically
if tool_name not in ("Read", "Edit", "Write", "Glob", "Grep"):
events.append(ToolStartEvent(tool_name=tool_name))
self._current_tool = tool_name
if PATTERNS["tool_end"].search(data) and self._current_tool:
events.append(ToolEndEvent(tool_name=self._current_tool))
self._current_tool = None
# Check for diff content
if PATTERNS["diff_header"].search(self._buffer):
# Extract diff content (simplified - real impl would be more sophisticated)
events.append(DiffProposedEvent(content=self._buffer))
# Clear buffer after detecting diff
self._buffer = ""
# Invoke callback for each event
if self._callback:
for evt in events:
try:
self._callback(evt)
except Exception:
pass # Don't let callback errors propagate
return events
def reset(self) -> None:
"""Reset parser state."""
self._buffer = ""
self._current_tool = None
# Global parser instance for convenience
_event_parser: ClaudeEventParser | None = None
def get_event_parser() -> ClaudeEventParser:
"""Get the global event parser instance."""
global _event_parser
if _event_parser is None:
_event_parser = ClaudeEventParser()
return _event_parser
def setup_event_parsing(callback: Callable[[ClaudeEvent], None]) -> None:
"""Set up event parsing with the given callback.
This should be called during app initialization to wire up
the event parser with the terminal stream.
"""
from clide.vendor import pyte
parser = get_event_parser()
parser.set_callback(callback)
# Wire up to pyte's event callback
# The parser.feed returns events but pyte expects None return
def _feed_wrapper(data: str) -> None:
parser.feed(data)
pyte.set_event_callback(_feed_wrapper)
+48
View File
@@ -0,0 +1,48 @@
"""SQLite database engine and session management.
Used by both standalone Clide and clide-web. The database file
defaults to ~/.clide/clide.db but is configurable.
"""
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
from sqlmodel import Session as DBSession
from sqlmodel import SQLModel, create_engine
_engine = None
DEFAULT_DB_PATH = Path.home() / ".clide" / "clide.db"
def get_engine(db_path: Path | None = None):
"""Create or return the SQLAlchemy engine."""
global _engine
if _engine is None:
path = db_path or DEFAULT_DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
_engine = create_engine(
f"sqlite:///{path}",
echo=False,
connect_args={"check_same_thread": False},
)
return _engine
def init_db(db_path: Path | None = None) -> None:
"""Create all tables if they don't exist."""
# Import models so SQLModel registers them
import clide.models.db # noqa: F401
engine = get_engine(db_path)
SQLModel.metadata.create_all(engine)
def get_db() -> Generator[DBSession, None, None]:
"""Yield a database session. Usable as a FastAPI dependency or context manager."""
if _engine is None:
raise RuntimeError("Database not initialized — call init_db() first")
with DBSession(_engine) as session:
yield session
+204
View File
@@ -0,0 +1,204 @@
"""File operations service."""
from pathlib import Path
# Language extension mapping for syntax highlighting
# Maps file extensions to tree-sitter language identifiers
LANGUAGE_MAP: dict[str, str] = {
# Python
".py": "python",
".pyi": "python",
".pyw": "python",
# JavaScript/TypeScript
".js": "javascript",
".mjs": "javascript",
".cjs": "javascript",
".jsx": "javascript",
".ts": "typescript",
".tsx": "typescript",
".mts": "typescript",
".cts": "typescript",
# Web
".html": "html",
".htm": "html",
".css": "css",
".scss": "css",
".sass": "css",
".less": "css",
# Dart/Flutter
".dart": "dart",
# Data formats
".json": "json",
".jsonc": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".xml": "xml",
# Markdown
".md": "markdown",
".markdown": "markdown",
# Shell
".sh": "bash",
".bash": "bash",
".zsh": "bash",
".fish": "bash",
# SQL
".sql": "sql",
# Other languages
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".hpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".rb": "ruby",
".php": "php",
".vue": "vue",
".svelte": "svelte",
".lua": "lua",
".r": "r",
".R": "r",
".swift": "swift",
".kt": "kotlin",
".kts": "kotlin",
".scala": "scala",
".ex": "elixir",
".exs": "elixir",
}
class FileService:
"""Service for file I/O operations."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
# Static methods for simple sync operations (used by EditorPane)
@staticmethod
def read_file(path: Path) -> str:
"""Read file contents synchronously.
Args:
path: Path to file
Returns:
File contents as string
"""
return path.read_text(encoding="utf-8")
@staticmethod
def write_file(path: Path, content: str) -> bool:
"""Write content to file synchronously.
Args:
path: Path to file
content: Content to write
Returns:
True if successful
"""
try:
path.write_text(content, encoding="utf-8")
return True
except OSError:
return False
@staticmethod
def detect_language(path: Path) -> str | None:
"""Detect language from file extension.
Args:
path: File path
Returns:
Language identifier for tree-sitter or None
"""
return LANGUAGE_MAP.get(path.suffix.lower())
# Instance methods for async operations
async def read_file_async(self, path: Path) -> str:
"""Read file contents asynchronously.
Args:
path: Path to file (relative or absolute)
Returns:
File contents as string
"""
full_path = self._resolve_path(path)
return full_path.read_text(encoding="utf-8")
async def write_file_async(self, path: Path, content: str) -> None:
"""Write content to file asynchronously.
Args:
path: Path to file
content: Content to write
"""
full_path = self._resolve_path(path)
full_path.write_text(content, encoding="utf-8")
async def file_exists(self, path: Path) -> bool:
"""Check if file exists.
Args:
path: Path to check
Returns:
True if file exists
"""
full_path = self._resolve_path(path)
return full_path.exists() and full_path.is_file()
async def get_language(self, path: Path) -> str | None:
"""Detect language from file extension.
Args:
path: File path
Returns:
Language identifier or None
"""
return LANGUAGE_MAP.get(path.suffix.lower())
def _resolve_path(self, path: Path) -> Path:
"""Resolve path relative to project root.
Args:
path: Path to resolve
Returns:
Absolute path
"""
if path.is_absolute():
return path
return self.project_path / path
def list_directory(self, path: Path | None = None) -> list[Path]:
"""List directory contents.
Args:
path: Directory path (defaults to project root)
Returns:
List of paths in directory
"""
dir_path = self._resolve_path(path) if path else self.project_path
if not dir_path.is_dir():
return []
entries = []
for entry in sorted(dir_path.iterdir()):
# Skip hidden files and common excludes
if entry.name.startswith("."):
continue
if entry.name in ("__pycache__", "node_modules", ".git"):
continue
entries.append(entry)
# Sort: directories first, then files
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
return entries
+287
View File
@@ -0,0 +1,287 @@
"""File system watching service for real-time sync.
This module provides file system monitoring capabilities for the Clide IDE,
enabling reactive updates when files change on disk.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from collections.abc import Callable
from pydantic import BaseModel, ConfigDict
from textual.message import Message
try:
from watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
)
from watchdog.events import (
FileSystemEventHandler as WatchdogHandler,
)
from watchdog.observers import Observer as WatchdogObserver
WATCHDOG_AVAILABLE = True
except ImportError:
WATCHDOG_AVAILABLE = False
WatchdogObserver = None # type: ignore[misc, assignment]
WatchdogHandler = object # type: ignore[misc, assignment]
FileCreatedEvent = None # type: ignore[misc, assignment]
FileModifiedEvent = None # type: ignore[misc, assignment]
FileDeletedEvent = None # type: ignore[misc, assignment]
FileMovedEvent = None # type: ignore[misc, assignment]
DirCreatedEvent = None # type: ignore[misc, assignment]
DirModifiedEvent = None # type: ignore[misc, assignment]
DirDeletedEvent = None # type: ignore[misc, assignment]
DirMovedEvent = None # type: ignore[misc, assignment]
class FileEvent(BaseModel):
"""A file system event.
Attributes:
path: The path to the file/directory that changed.
event_type: The type of change that occurred.
timestamp: When the event occurred.
is_directory: Whether this is a directory event.
old_path: For move events, the original path.
"""
model_config = ConfigDict(strict=True, frozen=True)
path: Path
event_type: Literal["created", "modified", "deleted", "moved"]
timestamp: datetime
is_directory: bool = False
old_path: Path | None = None
class FileEventMessage(Message):
"""Textual message for file events."""
def __init__(self, event: FileEvent) -> None:
self.event = event
super().__init__()
class FileWatcher:
"""Watches a directory for file system changes.
Uses watchdog for efficient cross-platform file monitoring.
Emits FileEvent objects to registered handlers.
Example:
watcher = FileWatcher(Path.cwd())
watcher.register_handler(my_handler)
watcher.start()
# ... later ...
watcher.stop()
"""
def __init__(self, root: Path, ignore_patterns: list[str] | None = None) -> None:
"""Initialize the file watcher.
Args:
root: The root directory to watch.
ignore_patterns: Glob patterns to ignore (e.g., ["*.pyc", "__pycache__"]).
"""
self._root = root.resolve()
self._ignore_patterns = ignore_patterns or [
"*.pyc",
"__pycache__",
".git",
".venv",
"venv",
"node_modules",
".mypy_cache",
".ruff_cache",
".pytest_cache",
"*.egg-info",
".clide",
".claude",
]
self._handlers: list[Callable[[FileEvent], None]] = []
self._observer: WatchdogObserver | None = None # type: ignore[valid-type]
self._running = False
@property
def is_available(self) -> bool:
"""Check if watchdog is available."""
return WATCHDOG_AVAILABLE
@property
def is_running(self) -> bool:
"""Check if the watcher is currently running."""
return self._running
@property
def root(self) -> Path:
"""Get the root directory being watched."""
return self._root
def register_handler(self, handler: Callable[[FileEvent], None]) -> None:
"""Register a handler for file events.
Args:
handler: A callable that accepts a FileEvent.
"""
if handler not in self._handlers:
self._handlers.append(handler)
def unregister_handler(self, handler: Callable[[FileEvent], None]) -> None:
"""Unregister a handler.
Args:
handler: The handler to remove.
"""
if handler in self._handlers:
self._handlers.remove(handler)
def _should_ignore(self, path: Path) -> bool:
"""Check if a path should be ignored based on patterns."""
path_str = str(path)
for pattern in self._ignore_patterns:
# Simple pattern matching - could be enhanced with fnmatch
if pattern.startswith("*"):
if path_str.endswith(pattern[1:]):
return True
elif pattern in path_str:
return True
return False
def _emit_event(self, event: FileEvent) -> None:
"""Emit an event to all handlers."""
if self._should_ignore(event.path):
return
for handler in self._handlers:
try:
handler(event)
except Exception:
pass # Don't let handler errors affect other handlers
def start(self) -> bool:
"""Start watching for file changes.
Returns:
True if started successfully, False if watchdog is not available.
"""
if not WATCHDOG_AVAILABLE:
return False
if self._running:
return True
event_handler = _WatchdogHandler(self)
self._observer = WatchdogObserver()
self._observer.schedule(event_handler, str(self._root), recursive=True)
self._observer.start()
self._running = True
return True
def stop(self) -> None:
"""Stop watching for file changes."""
if self._observer is not None:
self._observer.stop()
self._observer.join(timeout=5)
self._observer = None
self._running = False
class _WatchdogHandler(WatchdogHandler): # type: ignore[misc, valid-type]
"""Internal handler for watchdog events."""
def __init__(self, watcher: FileWatcher) -> None:
super().__init__()
self._watcher = watcher
def _create_event(
self,
src_path: str | bytes,
event_type: Literal["created", "modified", "deleted", "moved"],
is_directory: bool,
dest_path: str | bytes | None = None,
) -> FileEvent:
"""Create a FileEvent from watchdog event data."""
# Watchdog can return bytes or str depending on platform
src = src_path.decode() if isinstance(src_path, bytes) else src_path
dest = dest_path.decode() if isinstance(dest_path, bytes) else dest_path
return FileEvent(
path=Path(dest if dest else src),
event_type=event_type,
timestamp=datetime.now(),
is_directory=is_directory,
old_path=Path(src) if dest else None,
)
def on_created(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(event.src_path, "created", event.is_directory)
self._watcher._emit_event(file_event)
def on_modified(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(event.src_path, "modified", event.is_directory)
self._watcher._emit_event(file_event)
def on_deleted(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(event.src_path, "deleted", event.is_directory)
self._watcher._emit_event(file_event)
def on_moved(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(
event.src_path, "moved", event.is_directory, event.dest_path
)
self._watcher._emit_event(file_event)
# Global watcher instance
_file_watcher: FileWatcher | None = None
def get_file_watcher(root: Path | None = None) -> FileWatcher:
"""Get or create the global file watcher.
Args:
root: The root directory to watch. Only used on first call.
Returns:
The FileWatcher instance.
"""
global _file_watcher
if _file_watcher is None:
_file_watcher = FileWatcher(root or Path.cwd())
return _file_watcher
def setup_file_watching(
root: Path,
handlers: list[Callable[[FileEvent], None]] | None = None,
) -> FileWatcher:
"""Set up file watching with optional initial handlers.
Args:
root: The root directory to watch.
handlers: Optional list of handlers to register.
Returns:
The configured FileWatcher.
"""
global _file_watcher
_file_watcher = FileWatcher(root)
if handlers:
for handler in handlers:
_file_watcher.register_handler(handler)
_file_watcher.start()
return _file_watcher
+273
View File
@@ -0,0 +1,273 @@
"""Git operations service."""
from pathlib import Path
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitStatus,
)
from clide.services.process_service import ProcessService
class GitService:
"""Service for git operations."""
def __init__(self, repo_path: Path) -> None:
self.repo_path = repo_path
self._process = ProcessService(cwd=repo_path)
async def get_status(self) -> GitStatus:
"""Get current git status.
Returns:
GitStatus with staged/unstaged changes
"""
# Get porcelain status
result = await self._process.run("git", "status", "--porcelain", "-z")
staged: list[GitChange] = []
unstaged: list[GitChange] = []
untracked: list[str] = []
if result.success and result.stdout:
entries = result.stdout.split("\0")
for entry in entries:
if not entry or len(entry) < 3:
continue
index_status = entry[0]
worktree_status = entry[1]
path = entry[3:]
# Parse status
if index_status == "?":
# Untracked files go in both untracked list and unstaged
untracked.append(path)
unstaged.append(
GitChange(
path=path,
status=ChangeStatus.UNTRACKED,
staged=False,
)
)
else:
if index_status != " ":
staged.append(
GitChange(
path=path,
status=self._parse_status(index_status),
staged=True,
)
)
if worktree_status != " ":
unstaged.append(
GitChange(
path=path,
status=self._parse_status(worktree_status),
staged=False,
)
)
# Get current branch
branch_result = await self._process.run("git", "branch", "--show-current")
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
# Get ahead/behind
ahead, behind = await self._get_ahead_behind(branch)
return GitStatus(
branch=branch,
ahead=ahead,
behind=behind,
staged=tuple(staged),
unstaged=tuple(unstaged),
untracked=tuple(untracked),
)
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
"""Get commits ahead/behind upstream."""
result = await self._process.run(
"git", "rev-list", "--left-right", "--count", f"{branch}...@{{upstream}}"
)
if result.success:
parts = result.stdout.strip().split()
if len(parts) == 2:
return int(parts[0]), int(parts[1])
return 0, 0
def _parse_status(self, char: str) -> ChangeStatus:
"""Parse git status character to ChangeStatus."""
mapping = {
"A": ChangeStatus.ADDED,
"M": ChangeStatus.MODIFIED,
"D": ChangeStatus.DELETED,
"R": ChangeStatus.RENAMED,
"C": ChangeStatus.COPIED,
"?": ChangeStatus.UNTRACKED,
"!": ChangeStatus.IGNORED,
"U": ChangeStatus.UNMERGED,
}
return mapping.get(char, ChangeStatus.MODIFIED)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
result = await self._process.run("git", "add", path)
return result.success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
result = await self._process.run("git", "restore", "--staged", path)
return result.success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path to discard
Returns:
True if successful
"""
result = await self._process.run("git", "restore", path)
return result.success
async def get_branches(self) -> list[GitBranch]:
"""Get list of branches.
Returns:
List of GitBranch objects
"""
result = await self._process.run(
"git",
"branch",
"-a",
"--format",
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)",
)
branches: list[GitBranch] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
is_current = line.startswith("*")
parts = line[1:].split("|")
if len(parts) >= 4:
name = parts[0].strip()
branches.append(
GitBranch(
name=name,
is_current=is_current,
is_remote=name.startswith("remotes/"),
tracking=parts[1] or None,
commit_hash=parts[2],
commit_message=parts[3],
)
)
return branches
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name to checkout
Returns:
True if successful
"""
result = await self._process.run("git", "checkout", branch)
return result.success
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
"""Create a new branch.
Args:
name: New branch name
start_point: Optional starting commit/branch
Returns:
True if successful
"""
args = ["git", "checkout", "-b", name]
if start_point:
args.append(start_point)
result = await self._process.run(*args)
return result.success
async def get_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff output string
"""
args = ["git", "diff"]
if staged:
args.append("--cached")
args.append("--")
args.append(path)
result = await self._process.run(*args)
return result.stdout if result.success else ""
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
"""Get commit log.
Args:
max_count: Maximum number of commits to return
Returns:
List of GitCommit objects
"""
result = await self._process.run(
"git",
"log",
f"--max-count={max_count}",
"--format=%H|%h|%s|%an|%ar|%P|%D",
"--all",
)
commits: list[GitCommit] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|")
if len(parts) >= 7:
parents = tuple(parts[5].split()) if parts[5] else ()
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
commits.append(
GitCommit(
hash=parts[0],
short_hash=parts[1],
message=parts[2],
author=parts[3],
date=parts[4],
is_merge=len(parents) > 1,
parents=parents,
refs=refs,
)
)
return commits
+127
View File
@@ -0,0 +1,127 @@
"""Linter integration service."""
import json
from pathlib import Path
from clide.models.problems import Problem, ProblemsSummary, Severity
from clide.services.process_service import ProcessService
class LinterService:
"""Service for running linters and parsing output."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def run_ruff(self) -> list[Problem]:
"""Run ruff linter.
Returns:
List of problems found
"""
result = await self._process.run(
"ruff", "check", "--output-format=json", "."
)
problems: list[Problem] = []
if result.stdout:
try:
data = json.loads(result.stdout)
for item in data:
severity = self._ruff_severity(item.get("code", ""))
problems.append(Problem(
file_path=Path(item["filename"]),
line=item["location"]["row"],
column=item["location"]["column"],
end_line=item.get("end_location", {}).get("row"),
end_column=item.get("end_location", {}).get("column"),
severity=severity,
message=item["message"],
source="ruff",
code=item.get("code"),
))
except json.JSONDecodeError:
pass
return problems
def _ruff_severity(self, code: str) -> Severity:
"""Map ruff code to severity."""
if code.startswith("E") or code.startswith("F"):
return Severity.ERROR
if code.startswith("W"):
return Severity.WARNING
return Severity.INFO
async def run_mypy(self) -> list[Problem]:
"""Run mypy type checker.
Returns:
List of problems found
"""
result = await self._process.run(
"mypy", "--output=json", "."
)
problems: list[Problem] = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
try:
data = json.loads(line)
severity = self._mypy_severity(data.get("severity", "error"))
problems.append(Problem(
file_path=Path(data["file"]),
line=data["line"],
column=data.get("column", 1),
severity=severity,
message=data["message"],
source="mypy",
code=data.get("code"),
))
except (json.JSONDecodeError, KeyError):
continue
return problems
def _mypy_severity(self, severity: str) -> Severity:
"""Map mypy severity to Severity enum."""
mapping = {
"error": Severity.ERROR,
"warning": Severity.WARNING,
"note": Severity.INFO,
}
return mapping.get(severity, Severity.ERROR)
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
"""Run all configured linters.
Args:
linters: List of linter names to run
Returns:
Tuple of (problems list, summary)
"""
all_problems: list[Problem] = []
for linter in linters:
if linter == "ruff":
all_problems.extend(await self.run_ruff())
elif linter == "mypy":
all_problems.extend(await self.run_mypy())
# Create summary
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
summary = ProblemsSummary(
errors=errors,
warnings=warnings,
infos=infos,
hints=hints,
)
return all_problems, summary
+124
View File
@@ -0,0 +1,124 @@
"""Generic subprocess management service."""
import asyncio
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CommandResult:
"""Result of a command execution."""
returncode: int
stdout: str
stderr: str
@property
def success(self) -> bool:
"""Check if command succeeded."""
return self.returncode == 0
class ProcessService:
"""Service for running subprocess commands."""
def __init__(self, cwd: Path | None = None) -> None:
self.cwd = cwd or Path.cwd()
async def run(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
env: dict[str, str] | None = None,
) -> CommandResult:
"""Run a command asynchronously.
Args:
*args: Command and arguments
cwd: Working directory (defaults to service cwd)
timeout: Timeout in seconds
env: Environment variables to add
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
env=env,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
return CommandResult(
returncode=process.returncode or 0,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
)
except TimeoutError:
process.kill()
return CommandResult(
returncode=-1,
stdout="",
stderr="Command timed out",
)
except Exception as e:
return CommandResult(
returncode=-1,
stdout="",
stderr=str(e),
)
def run_sync(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
) -> CommandResult:
"""Run a command synchronously (for use in threads).
Args:
*args: Command and arguments
cwd: Working directory
timeout: Timeout in seconds
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
result = subprocess.run(
args,
capture_output=True,
cwd=working_dir,
timeout=timeout,
text=True,
)
return CommandResult(
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
)
except subprocess.TimeoutExpired:
return CommandResult(
returncode=-1,
stdout="",
stderr="Command timed out",
)
except Exception as e:
return CommandResult(
returncode=-1,
stdout="",
stderr=str(e),
)
+162
View File
@@ -0,0 +1,162 @@
"""Settings persistence service for user preferences."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pydantic import BaseModel
class UserSettings(BaseModel):
"""User settings that persist across sessions.
Stored in ~/.clide/settings.json
"""
# Appearance
theme: str = "summer-night"
# Panel state
sidebar_visible: bool = True
context_visible: bool = True
# Window
compact_mode: bool = False
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
# Debug
terminal_debug: bool = False # Verbose terminal/pyte logging to ~/.clide/terminal_debug.log
class SettingsService:
"""Service for loading and saving user settings.
Settings are stored in ~/.clide/settings.json
"""
def __init__(self, settings_dir: Path | None = None) -> None:
"""Initialize the settings service.
Args:
settings_dir: Override the settings directory (default: ~/.clide)
"""
self._settings_dir = settings_dir or Path.home() / ".clide"
self._settings_file = self._settings_dir / "settings.json"
self._settings: UserSettings | None = None
@property
def settings_dir(self) -> Path:
"""Get the settings directory path."""
return self._settings_dir
@property
def settings_file(self) -> Path:
"""Get the settings file path."""
return self._settings_file
def load(self) -> UserSettings:
"""Load settings from disk, creating defaults if needed.
Returns:
The loaded or default UserSettings
"""
if self._settings is not None:
return self._settings
if self._settings_file.exists():
try:
data = json.loads(self._settings_file.read_text())
self._settings = UserSettings.model_validate(data)
except (json.JSONDecodeError, ValueError):
# Invalid JSON or schema, use defaults
self._settings = UserSettings()
else:
self._settings = UserSettings()
return self._settings
def save(self) -> None:
"""Save current settings to disk."""
if self._settings is None:
return
# Ensure directory exists
self._settings_dir.mkdir(parents=True, exist_ok=True)
# Write settings as formatted JSON
data = self._settings.model_dump(mode="json")
self._settings_file.write_text(
json.dumps(data, indent=2, sort_keys=True) + "\n"
)
def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value.
Args:
key: The setting key (attribute name)
default: Default value if key doesn't exist
Returns:
The setting value or default
"""
settings = self.load()
return getattr(settings, key, default)
def set(self, key: str, value: Any, *, save: bool = True) -> None:
"""Set a setting value.
Args:
key: The setting key (attribute name)
value: The value to set
save: Whether to save immediately (default: True)
"""
settings = self.load()
if hasattr(settings, key):
# Create new settings with updated value
data = settings.model_dump()
data[key] = value
self._settings = UserSettings.model_validate(data)
if save:
self.save()
def update(self, **kwargs: Any) -> None:
"""Update multiple settings at once.
Args:
**kwargs: Key-value pairs to update
"""
settings = self.load()
data = settings.model_dump()
for key, value in kwargs.items():
if hasattr(settings, key):
data[key] = value
self._settings = UserSettings.model_validate(data)
self.save()
def reset(self) -> None:
"""Reset settings to defaults."""
self._settings = UserSettings()
self.save()
# Global instance for convenience
_settings_service: SettingsService | None = None
def get_settings_service() -> SettingsService:
"""Get the global settings service instance."""
global _settings_service
if _settings_service is None:
_settings_service = SettingsService()
return _settings_service
+285
View File
@@ -0,0 +1,285 @@
"""Skill installer service for Claude Code skills.
This module provides functionality to install skill templates
into the user's Claude Code configuration.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Literal
# Path to bundled skill templates within Clide package
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "skills"
# Default installation locations
USER_SKILLS_DIR = Path.home() / ".claude" / "skills"
class SkillInstaller:
"""Installs Claude Code skills from templates.
Skills can be installed to:
- User level: ~/.claude/skills/ (available globally)
- Project level: .claude/skills/ (available in project only)
Example:
installer = SkillInstaller()
# Check if skill exists
if not installer.is_installed("git-workflow"):
installer.install("git-workflow")
# Install to project instead of user
installer.install("git-workflow", scope="project")
"""
def __init__(
self,
templates_dir: Path | None = None,
project_dir: Path | None = None,
) -> None:
"""Initialize the skill installer.
Args:
templates_dir: Override the templates directory.
project_dir: Project directory for project-scoped skills.
"""
self._templates_dir = templates_dir or TEMPLATES_DIR
self._project_dir = project_dir or Path.cwd()
@property
def templates_dir(self) -> Path:
"""Get the templates directory."""
return self._templates_dir
@property
def user_skills_dir(self) -> Path:
"""Get the user skills directory."""
return USER_SKILLS_DIR
@property
def project_skills_dir(self) -> Path:
"""Get the project skills directory."""
return self._project_dir / ".claude" / "skills"
def list_available_templates(self) -> list[str]:
"""List all available skill templates.
Returns:
List of skill names that can be installed.
"""
if not self._templates_dir.exists():
return []
return [
d.name
for d in self._templates_dir.iterdir()
if d.is_dir() and (d / "SKILL.md").exists()
]
def list_installed_skills(
self,
scope: Literal["user", "project", "all"] = "all",
) -> list[dict[str, str]]:
"""List installed skills.
Args:
scope: Which skills to list - user, project, or all.
Returns:
List of dicts with 'name', 'scope', and 'path' keys.
"""
skills = []
if scope in ("user", "all"):
if self.user_skills_dir.exists():
for d in self.user_skills_dir.iterdir():
if d.is_dir() and (d / "SKILL.md").exists():
skills.append(
{
"name": d.name,
"scope": "user",
"path": str(d),
}
)
if scope in ("project", "all"):
if self.project_skills_dir.exists():
for d in self.project_skills_dir.iterdir():
if d.is_dir() and (d / "SKILL.md").exists():
skills.append(
{
"name": d.name,
"scope": "project",
"path": str(d),
}
)
return skills
def is_installed(
self,
skill_name: str,
scope: Literal["user", "project", "any"] = "any",
) -> bool:
"""Check if a skill is installed.
Args:
skill_name: The skill name to check.
scope: Where to check - user, project, or any.
Returns:
True if the skill is installed.
"""
if scope in ("user", "any"):
user_skill = self.user_skills_dir / skill_name / "SKILL.md"
if user_skill.exists():
return True
if scope in ("project", "any"):
project_skill = self.project_skills_dir / skill_name / "SKILL.md"
if project_skill.exists():
return True
return False
def get_skill_path(
self,
skill_name: str,
scope: Literal["user", "project", "any"] = "any",
) -> Path | None:
"""Get the path to an installed skill.
Args:
skill_name: The skill name.
scope: Where to look - user, project, or any (project takes priority).
Returns:
Path to the skill directory, or None if not found.
"""
# Project scope takes priority when scope is "any"
if scope in ("project", "any"):
project_skill = self.project_skills_dir / skill_name
if (project_skill / "SKILL.md").exists():
return project_skill
if scope in ("user", "any"):
user_skill = self.user_skills_dir / skill_name
if (user_skill / "SKILL.md").exists():
return user_skill
return None
def install(
self,
skill_name: str,
scope: Literal["user", "project"] = "user",
overwrite: bool = False,
) -> Path:
"""Install a skill from templates.
Args:
skill_name: The skill name to install.
scope: Where to install - user or project level.
overwrite: Whether to overwrite existing installation.
Returns:
Path to the installed skill.
Raises:
ValueError: If skill template doesn't exist.
FileExistsError: If skill exists and overwrite is False.
"""
# Check template exists
template_dir = self._templates_dir / skill_name
if not template_dir.exists() or not (template_dir / "SKILL.md").exists():
raise ValueError(f"Skill template '{skill_name}' not found")
# Determine target directory
if scope == "user":
target_dir = self.user_skills_dir / skill_name
else:
target_dir = self.project_skills_dir / skill_name
# Check if already exists
if target_dir.exists():
if not overwrite:
raise FileExistsError(f"Skill '{skill_name}' already installed at {target_dir}")
shutil.rmtree(target_dir)
# Create parent directory
target_dir.parent.mkdir(parents=True, exist_ok=True)
# Copy template
shutil.copytree(template_dir, target_dir)
return target_dir
def uninstall(
self,
skill_name: str,
scope: Literal["user", "project"] = "user",
) -> bool:
"""Uninstall a skill.
Args:
skill_name: The skill name to uninstall.
scope: Where to uninstall from - user or project level.
Returns:
True if skill was uninstalled, False if it wasn't installed.
"""
if scope == "user":
skill_dir = self.user_skills_dir / skill_name
else:
skill_dir = self.project_skills_dir / skill_name
if skill_dir.exists():
shutil.rmtree(skill_dir)
return True
return False
def ensure_installed(
self,
skill_name: str,
scope: Literal["user", "project"] = "project",
) -> Path:
"""Ensure a skill is installed, installing if needed.
Args:
skill_name: The skill name.
scope: Where to install if not present (default: project).
Returns:
Path to the skill directory.
Raises:
ValueError: If skill template doesn't exist.
"""
existing = self.get_skill_path(skill_name)
if existing:
return existing
return self.install(skill_name, scope=scope)
# Global instance
_skill_installer: SkillInstaller | None = None
def get_skill_installer(project_dir: Path | None = None) -> SkillInstaller:
"""Get or create the global skill installer.
Args:
project_dir: Project directory (only used on first call).
Returns:
The SkillInstaller instance.
"""
global _skill_installer
if _skill_installer is None:
_skill_installer = SkillInstaller(project_dir=project_dir)
return _skill_installer
+166
View File
@@ -0,0 +1,166 @@
"""Syntax highlighting service for additional language support.
Textual 7.x includes built-in support for many languages when tree-sitter
packages are installed. This module provides utilities for checking and
registering additional languages.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# Languages supported by Textual's TextArea with tree-sitter packages
SUPPORTED_LANGUAGES = {
# Core web languages
"python",
"javascript",
"typescript",
"html",
"css",
"json",
# Markup/config
"markdown",
"yaml",
"toml",
"xml",
# Shell
"bash",
# SQL
"sql",
# Systems languages
"rust",
"go",
"java",
# Regex
"regex",
}
# Additional languages that may be registered if packages are available
OPTIONAL_LANGUAGES = [
"dart",
"kotlin",
"swift",
"scala",
"ruby",
"php",
"lua",
"c",
"cpp",
"csharp",
"elixir",
"haskell",
"ocaml",
"zig",
"nim",
"vue",
"svelte",
]
def register_languages() -> list[str]:
"""Register additional languages with Textual's TextArea.
In Textual 7.x, languages are automatically registered when tree-sitter
packages are installed. This function registers additional languages
that need special handling (like TypeScript which has separate functions).
Returns:
List of successfully registered language names
"""
try:
from textual.widgets import TextArea
except ImportError:
logger.warning("Textual not available")
return []
registered = []
# Register TypeScript and TSX (they have special language function names)
try:
import tree_sitter_typescript as tst
# Register TypeScript
try:
TextArea.register_language(tst.language_typescript(), "typescript")
registered.append("typescript")
logger.debug("Registered language: typescript")
except Exception as e:
logger.debug(f"Could not register typescript: {e}")
# Register TSX
try:
TextArea.register_language(tst.language_tsx(), "tsx")
registered.append("tsx")
logger.debug("Registered language: tsx")
except Exception as e:
logger.debug(f"Could not register tsx: {e}")
except ImportError:
logger.debug("tree-sitter-typescript not installed")
# Register other optional languages with standard API
for lang_name in OPTIONAL_LANGUAGES:
try:
# Try to import the tree-sitter package for this language
module_name = f"tree_sitter_{lang_name}"
module = __import__(module_name)
# Get the language function
if hasattr(module, "language"):
language = module.language()
# Try to get a highlight query if available
highlight_query = None
if hasattr(module, "HIGHLIGHTS_QUERY"):
highlight_query = module.HIGHLIGHTS_QUERY
# Register with Textual
try:
TextArea.register_language(language, lang_name, highlight_query)
registered.append(lang_name)
logger.debug(f"Registered language: {lang_name}")
except Exception as e:
logger.debug(f"Could not register language '{lang_name}': {e}")
except ImportError:
# Package not installed, skip
pass
except Exception as e:
logger.debug(f"Error processing language '{lang_name}': {e}")
return registered
def get_available_languages() -> list[str]:
"""Get list of all available languages for syntax highlighting.
Returns:
List of language names that can be used with TextArea
"""
try:
from textual.widgets import TextArea
# Create a temporary instance to check available languages
ta = TextArea()
return sorted(ta.available_languages)
except ImportError:
return sorted(SUPPORTED_LANGUAGES)
except Exception:
return sorted(SUPPORTED_LANGUAGES)
def is_syntax_highlighting_available() -> bool:
"""Check if syntax highlighting is available.
Returns:
True if tree-sitter is installed and syntax highlighting works
"""
try:
from textual.widgets import TextArea
ta = TextArea("test", language="python")
return ta.is_syntax_aware
except Exception:
return False
+244
View File
@@ -0,0 +1,244 @@
"""TODO comment scanner service."""
import re
from pathlib import Path
from clide.models.todos import ProjectTodoItem, TodoItem, TodosSummary, TodoType
from clide.services.process_service import ProcessService
class TodoScanner:
"""Service for scanning TODO/FIXME comments in code."""
# Pattern to match TODO-style comments
TODO_PATTERN = re.compile(
r"(?:#|//|/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\s*:?\s*(.+?)(?:\*/|-->)?$",
re.IGNORECASE,
)
# Pattern to match markdown checkboxes: - [ ] or - [x]
CHECKBOX_PATTERN = re.compile(r"^(\s*)-\s*\[([ xX])\]\s*(.+)$")
# File extensions to scan
SCAN_EXTENSIONS = {
".py",
".js",
".ts",
".jsx",
".tsx",
".java",
".c",
".cpp",
".h",
".go",
".rs",
".rb",
".php",
".css",
".scss",
".html",
".vue",
".svelte",
".md",
".sh",
".bash",
".yaml",
".yml",
".toml",
}
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def scan(
self,
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
"""Scan project for TODO comments and TODO.md items.
Returns:
Tuple of (code todo items, project todo items, summary)
"""
items: list[TodoItem] = []
# Use ripgrep if available for speed
result = await self._process.run(
"rg",
"--line-number",
"--no-heading",
"-e",
r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
"--type-add",
"code:*.py",
"--type-add",
"code:*.js",
"--type-add",
"code:*.ts",
"--type",
"code",
".",
)
if result.success:
items = self._parse_ripgrep_output(result.stdout)
else:
# Fallback to Python-based scanning
items = await self._scan_with_python()
# Parse TODO.md if it exists
project_items = self._parse_todo_md()
# Create summary
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
other_count = len(items) - todo_count - fixme_count - hack_count
project_todo_count = sum(1 for i in project_items if not i.checked)
project_done_count = sum(1 for i in project_items if i.checked)
summary = TodosSummary(
todo_count=todo_count,
fixme_count=fixme_count,
hack_count=hack_count,
other_count=other_count,
project_todo_count=project_todo_count,
project_done_count=project_done_count,
)
return items, project_items, summary
def _parse_todo_md(self) -> list[ProjectTodoItem]:
"""Parse TODO.md file for checkbox items.
Returns:
List of project TODO items
"""
todo_md_path = self.project_path / "TODO.md"
if not todo_md_path.exists():
return []
items: list[ProjectTodoItem] = []
current_section = "General"
current_subsection: str | None = None
try:
content = todo_md_path.read_text(encoding="utf-8")
for line_num, line in enumerate(content.split("\n"), 1):
# Check for section headers (## Section)
if line.startswith("## "):
current_section = line[3:].strip()
current_subsection = None
continue
# Check for subsection headers (### Subsection)
if line.startswith("### "):
current_subsection = line[4:].strip()
continue
# Check for checkbox items
match = self.CHECKBOX_PATTERN.match(line)
if match:
checkbox_state = match.group(2)
text = match.group(3).strip()
checked = checkbox_state.lower() == "x"
items.append(
ProjectTodoItem(
text=text,
section=current_section,
subsection=current_subsection,
line=line_num,
checked=checked,
)
)
except (OSError, UnicodeDecodeError):
pass
return items
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
"""Parse ripgrep output into TodoItems."""
items: list[TodoItem] = []
for line in output.strip().split("\n"):
if not line:
continue
# Format: path:line:content
parts = line.split(":", 2)
if len(parts) < 3:
continue
file_path = Path(parts[0])
try:
line_num = int(parts[1])
except ValueError:
continue
content = parts[2]
# Parse the TODO type and text
match = self.TODO_PATTERN.search(content)
if match:
todo_type_str = match.group(1).upper()
todo_text = match.group(2).strip()
try:
todo_type = TodoType(todo_type_str)
except ValueError:
todo_type = TodoType.TODO
items.append(
TodoItem(
file_path=file_path,
line=line_num,
column=content.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=content.strip(),
)
)
return items
async def _scan_with_python(self) -> list[TodoItem]:
"""Fallback Python-based scanning."""
items: list[TodoItem] = []
for ext in self.SCAN_EXTENSIONS:
for file_path in self.project_path.rglob(f"*{ext}"):
# Skip hidden directories and common excludes
if any(part.startswith(".") for part in file_path.parts):
continue
if "node_modules" in file_path.parts:
continue
if "__pycache__" in file_path.parts:
continue
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
for line_num, line in enumerate(content.split("\n"), 1):
match = self.TODO_PATTERN.search(line)
if match:
todo_type_str = match.group(1).upper()
todo_text = match.group(2).strip()
try:
todo_type = TodoType(todo_type_str)
except ValueError:
todo_type = TodoType.TODO
items.append(
TodoItem(
file_path=file_path.relative_to(self.project_path),
line=line_num,
column=line.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=line.strip(),
)
)
except (OSError, UnicodeDecodeError):
continue
return items
+359
View File
@@ -0,0 +1,359 @@
"""Auto-update service for Clide.
Checks for updates from the release server and handles self-updating.
User settings in ~/.clide/ are preserved across updates.
"""
from __future__ import annotations
import json
import logging
import os
import platform
import shutil
import stat
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
# Update server configuration
UPDATE_SERVER = "https://git.schweitz.net"
REPO_OWNER = "jeroen" # TODO: Update with actual owner
REPO_NAME = "clide" # TODO: Update with actual repo name
# Current version (injected at build time or read from package)
try:
from clide import __version__ as CURRENT_VERSION
except ImportError:
CURRENT_VERSION = "0.0.0"
@dataclass
class ReleaseInfo:
"""Information about a release."""
version: str
tag_name: str
download_url: str
release_notes: str
published_at: str
@dataclass
class UpdateCheckResult:
"""Result of checking for updates."""
update_available: bool
current_version: str
latest_version: str | None
release_info: ReleaseInfo | None
error: str | None = None
def get_platform_asset_name() -> str:
"""Get the expected asset name for the current platform."""
system = platform.system().lower()
machine = platform.machine().lower()
if system == "darwin":
return "macos.dmg"
elif system == "linux":
# Normalize architecture names
if machine in ("x86_64", "amd64"):
arch = "x86_64"
elif machine in ("aarch64", "arm64"):
arch = "aarch64"
else:
arch = machine
return f"linux-{arch}.AppImage"
elif system == "windows":
return "windows-setup.exe"
else:
raise RuntimeError(f"Unsupported platform: {system}")
def parse_version(version: str) -> tuple[int, ...]:
"""Parse a version string into a tuple for comparison."""
# Remove 'v' prefix if present
version = version.lstrip("v")
# Split and convert to integers
parts = []
for part in version.split("."):
# Handle versions like "1.0.0-beta"
num_part = ""
for char in part:
if char.isdigit():
num_part += char
else:
break
parts.append(int(num_part) if num_part else 0)
return tuple(parts)
def is_newer_version(current: str, latest: str) -> bool:
"""Check if latest version is newer than current."""
return parse_version(latest) > parse_version(current)
def check_for_updates() -> UpdateCheckResult:
"""Check the release server for available updates.
Returns:
UpdateCheckResult with update status and release info.
"""
try:
# Gitea API endpoint for releases
api_url = f"{UPDATE_SERVER}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
request = Request(api_url)
request.add_header("Accept", "application/json")
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
with urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
tag_name = data.get("tag_name", "")
latest_version = tag_name.lstrip("v")
# Find the download URL for current platform
platform_asset = get_platform_asset_name()
download_url = None
for asset in data.get("assets", []):
if platform_asset in asset.get("name", ""):
download_url = asset.get("browser_download_url")
break
if not download_url:
# Try constructing URL from release
download_url = f"{UPDATE_SERVER}/{REPO_OWNER}/{REPO_NAME}/releases/download/{tag_name}/Clide-{tag_name}-{platform_asset}"
release_info = ReleaseInfo(
version=latest_version,
tag_name=tag_name,
download_url=download_url,
release_notes=data.get("body", ""),
published_at=data.get("published_at", ""),
)
update_available = is_newer_version(CURRENT_VERSION, latest_version)
return UpdateCheckResult(
update_available=update_available,
current_version=CURRENT_VERSION,
latest_version=latest_version,
release_info=release_info,
)
except URLError as e:
logger.error(f"Failed to check for updates: {e}")
return UpdateCheckResult(
update_available=False,
current_version=CURRENT_VERSION,
latest_version=None,
release_info=None,
error=f"Network error: {e.reason}",
)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse update response: {e}")
return UpdateCheckResult(
update_available=False,
current_version=CURRENT_VERSION,
latest_version=None,
release_info=None,
error="Invalid response from update server",
)
except Exception as e:
logger.error(f"Unexpected error checking for updates: {e}")
return UpdateCheckResult(
update_available=False,
current_version=CURRENT_VERSION,
latest_version=None,
release_info=None,
error=str(e),
)
def download_update(release_info: ReleaseInfo, progress_callback=None) -> Path:
"""Download the update to a temporary location.
Args:
release_info: Release information with download URL.
progress_callback: Optional callback(bytes_downloaded, total_bytes).
Returns:
Path to the downloaded file.
Raises:
RuntimeError: If download fails.
"""
try:
request = Request(release_info.download_url)
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
# Create temp file with appropriate extension
suffix = Path(release_info.download_url).suffix or ""
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix="clide_update_")
os.close(fd)
with urlopen(request, timeout=300) as response:
total_size = int(response.headers.get("Content-Length", 0))
downloaded = 0
chunk_size = 8192
with open(temp_path, "wb") as f:
while True:
chunk = response.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback:
progress_callback(downloaded, total_size)
return Path(temp_path)
except Exception as e:
logger.error(f"Failed to download update: {e}")
raise RuntimeError(f"Download failed: {e}") from e
def get_executable_path() -> Path:
"""Get the path to the current executable."""
if getattr(sys, "frozen", False):
# Running as compiled executable
return Path(sys.executable)
else:
# Running as Python script
return Path(sys.argv[0]).resolve()
def apply_update(downloaded_file: Path) -> bool:
"""Apply the downloaded update.
This replaces the current executable with the new version.
On macOS/Linux, this can happen while the app is running.
On Windows, we need to use a helper script.
Args:
downloaded_file: Path to the downloaded update file.
Returns:
True if update was applied successfully.
"""
system = platform.system().lower()
current_exe = get_executable_path()
try:
if system == "darwin":
# macOS: For DMG, just inform user to install manually
# For direct binary updates, we can replace in-place
if downloaded_file.suffix == ".dmg":
logger.info(f"DMG downloaded to: {downloaded_file}")
return True # User needs to install manually
# Direct binary replacement
backup_path = current_exe.with_suffix(".backup")
shutil.copy2(current_exe, backup_path)
shutil.copy2(downloaded_file, current_exe)
current_exe.chmod(
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
)
backup_path.unlink()
return True
elif system == "linux":
# Linux: AppImage can be replaced directly
if downloaded_file.suffix == ".AppImage":
backup_path = current_exe.with_suffix(".backup")
shutil.copy2(current_exe, backup_path)
shutil.copy2(downloaded_file, current_exe)
current_exe.chmod(
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
)
backup_path.unlink()
return True
return False
elif system == "windows":
# Windows: Can't replace running executable directly
# Create a batch script to replace after exit
batch_script = current_exe.parent / "update_clide.bat"
new_exe = downloaded_file
script_content = f"""@echo off
echo Updating Clide...
timeout /t 2 /nobreak >nul
copy /y "{new_exe}" "{current_exe}"
del "{new_exe}"
del "%~f0"
echo Update complete!
"""
batch_script.write_text(script_content)
logger.info(f"Update script created: {batch_script}")
logger.info("Please restart Clide to complete the update.")
return True
return False
except Exception as e:
logger.error(f"Failed to apply update: {e}")
return False
finally:
# Clean up downloaded file if it still exists and wasn't moved
if downloaded_file.exists() and system != "windows":
try:
downloaded_file.unlink()
except Exception:
pass
def perform_update(progress_callback=None) -> tuple[bool, str]:
"""Check for and perform an update.
Args:
progress_callback: Optional callback for download progress.
Returns:
Tuple of (success, message).
"""
# Check for updates
result = check_for_updates()
if result.error:
return False, f"Failed to check for updates: {result.error}"
if not result.update_available:
return True, f"Already running the latest version ({result.current_version})"
if not result.release_info:
return False, "No release information available"
# Download update
try:
downloaded_file = download_update(result.release_info, progress_callback)
except RuntimeError as e:
return False, str(e)
# Apply update
if apply_update(downloaded_file):
system = platform.system().lower()
if system == "windows":
return True, f"Update to {result.latest_version} downloaded. Restart Clide to complete."
elif system == "darwin" and downloaded_file.suffix == ".dmg":
return (
True,
f"Update {result.latest_version} downloaded to {downloaded_file}. Please install manually.",
)
else:
return (
True,
f"Updated to {result.latest_version}. Restart Clide to use the new version.",
)
else:
return False, "Failed to apply update"
@@ -0,0 +1,31 @@
---
name: branch
description: Create, switch, or manage git branches
---
# Git Branch
Create, switch, or manage branches.
## Steps
1. If no argument, list branches with `git branch -a`
2. If branch name provided:
- Check if it exists
- If exists: `git checkout <branch>`
- If not: `git checkout -b <branch>`
3. Show current branch status after switch
## Common Operations
- List all branches: `git branch -a`
- Create and switch: `git checkout -b <name>`
- Switch to existing: `git checkout <name>`
- Delete local branch: `git branch -d <name>`
- Delete remote branch: `git push origin --delete <name>`
## Best Practices
- Use descriptive branch names (feature/*, fix/*, etc.)
- Keep branches short-lived
- Delete merged branches to keep repo clean
@@ -0,0 +1,38 @@
---
name: commit
description: Create a well-formatted git commit with staged changes
---
# Git Commit
Create a well-formatted commit with staged changes following best practices.
## Steps
1. Run `git status --porcelain` to check for changes
2. If no staged changes, show unstaged files and ask what to stage
3. Run `git diff --cached` to review staged changes
4. Generate a commit message following Conventional Commits format:
- `feat:` new feature
- `fix:` bug fix
- `docs:` documentation
- `refactor:` code restructuring
- `test:` adding tests
- `chore:` maintenance
5. Create commit with the message, adding Co-Authored-By trailer
## Commit Message Format
```
<type>(<scope>): <short description>
<body - what and why, not how>
Co-Authored-By: Claude <noreply@anthropic.com>
```
## Best Practices
- Warn about large commits (>500 lines changed)
- Suggest splitting large changes into smaller commits
- Never skip pre-commit hooks unless explicitly requested
@@ -0,0 +1,33 @@
---
name: pull
description: Pull changes from remote with rebase
---
# Git Pull
Pull changes from remote with rebase to keep history clean.
## Steps
1. Check for uncommitted changes - stash if needed
2. Run `git pull --rebase origin <current-branch>`
3. If conflicts occur:
- Show conflicting files
- Help resolve conflicts one by one
- Continue rebase after resolution
4. Pop stash if we stashed earlier
## Conflict Resolution
When conflicts are found:
1. Show the conflicting files with `git status`
2. For each file, show the conflict markers
3. Help user decide how to resolve
4. Stage resolved files with `git add`
5. Continue with `git rebase --continue`
## Best Practices
- Always use rebase for pulls to keep history clean
- Stash local changes before pulling
- Never force push after rebase on shared branches
@@ -0,0 +1,23 @@
---
name: push
description: Push current branch to remote
---
# Git Push
Push current branch to remote repository.
## Steps
1. Check if branch has upstream: `git rev-parse --abbrev-ref @{u}`
2. If no upstream, set it: `git push -u origin <branch>`
3. Otherwise: `git push`
4. If push is rejected (non-fast-forward):
- Suggest pull --rebase first
- Never force push to main/master without explicit request
## Best Practices
- Never force push to protected branches (main, master, develop)
- Always set upstream on first push with `-u` flag
- If rejected, pull with rebase first rather than force pushing
@@ -0,0 +1,27 @@
---
name: stash
description: Stash current working directory changes
---
# Git Stash
Stash current working directory changes for later use.
## Steps
1. Run `git status` to show what will be stashed
2. Ask for optional stash message
3. Run `git stash push -m "<message>"` or `git stash push` if no message
4. Confirm stash was created with `git stash list`
## Options
- Include untracked files: `git stash push -u`
- Stash specific files: `git stash push -- <files>`
## Related Commands
- `git stash list` - List all stashes
- `git stash pop` - Apply and remove most recent stash
- `git stash apply` - Apply but keep stash
- `git stash drop` - Remove a stash
+17
View File
@@ -0,0 +1,17 @@
"""Theme system for Clide."""
from clide.themes.registry import (
DEFAULT_THEME,
get_all_themes,
get_theme,
get_themes_by_category,
register_theme,
)
__all__ = [
"get_theme",
"get_all_themes",
"get_themes_by_category",
"register_theme",
"DEFAULT_THEME",
]
+51
View File
@@ -0,0 +1,51 @@
"""Built-in themes for Clide."""
from clide.themes.builtin import (
all_hallows_eve,
christmas,
dark_autumn,
dracula,
fall,
gamma,
gruvbox_dark,
gruvbox_light,
hacker_style,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
__all__ = [
"summer_night",
"summer_day",
"one_dark",
"one_dark_pro",
"one_light",
"dracula",
"nord",
"gruvbox_dark",
"gruvbox_light",
"one_dark_teal",
"gamma",
"winter_is_coming",
"monokai_winter",
"fall",
"dark_autumn",
"all_hallows_eve",
"halloween",
"christmas",
"santa_baby",
"pro_hacker",
"hacker_style",
"houston",
]
@@ -0,0 +1,21 @@
"""All Hallows' Eve Plus theme - Halloween."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="all-hallows-eve",
display_name="All Hallows' Eve",
dark=True,
colors=ThemeColors(
primary="#ff7518", # Pumpkin orange
secondary="#9932cc", # Dark orchid
accent="#ff6347",
background="#1a0a1a",
surface="#2d1a2d",
panel="#401a40",
foreground="#dda0dd",
success="#32cd32",
warning="#ff7518",
error="#dc143c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="christmas",
display_name="Christmas",
dark=True,
colors=ThemeColors(
primary="#ff0000", # Christmas red
secondary="#228b22", # Forest green
accent="#ffd700", # Gold
background="#0a1a0a",
surface="#1a2a1a",
panel="#2a3a2a",
foreground="#f0f0f0",
success="#228b22",
warning="#ffd700",
error="#ff0000",
),
)
@@ -0,0 +1,21 @@
"""Dark Autumn Frost theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dark-autumn",
display_name="Dark Autumn Frost",
dark=True,
colors=ThemeColors(
primary="#c49a6c",
secondary="#8b7355",
accent="#a0522d",
background="#1c1410",
surface="#2a1f18",
panel="#382a20",
foreground="#d2b48c",
success="#6b8e23",
warning="#b8860b",
error="#8b0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Dracula theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dracula",
display_name="Dracula",
dark=True,
colors=ThemeColors(
primary="#bd93f9",
secondary="#8be9fd",
accent="#ff79c6",
background="#282a36",
surface="#21222c",
panel="#343746",
foreground="#f8f8f2",
success="#50fa7b",
warning="#ffb86c",
error="#ff5555",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Fall theme - Autumn colors."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="fall",
display_name="Fall",
dark=True,
colors=ThemeColors(
primary="#e9967a",
secondary="#daa520",
accent="#cd853f",
background="#2d1f1f",
surface="#3d2929",
panel="#4d3333",
foreground="#f5deb3",
success="#8fbc8f",
warning="#daa520",
error="#cd5c5c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gamma theme - GitKraken Gamma style."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gamma",
display_name="Gamma",
dark=True,
colors=ThemeColors(
primary="#00d4aa",
secondary="#7c3aed",
accent="#f472b6",
background="#0f172a",
surface="#1e293b",
panel="#334155",
foreground="#e2e8f0",
success="#22c55e",
warning="#f59e0b",
error="#ef4444",
),
)
@@ -0,0 +1,21 @@
"""Gruvbox Dark theme - Retro groove color scheme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gruvbox-dark",
display_name="Gruvbox Dark",
dark=True,
colors=ThemeColors(
primary="#83a598",
secondary="#8ec07c",
accent="#d3869b",
background="#282828",
surface="#3c3836",
panel="#504945",
foreground="#ebdbb2",
success="#b8bb26",
warning="#fabd2f",
error="#fb4934",
),
)
@@ -0,0 +1,21 @@
"""Gruvbox Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gruvbox-light",
display_name="Gruvbox Light",
dark=False,
colors=ThemeColors(
primary="#076678",
secondary="#427b58",
accent="#8f3f71",
background="#fbf1c7",
surface="#ebdbb2",
panel="#d5c4a1",
foreground="#3c3836",
success="#79740e",
warning="#b57614",
error="#9d0006",
),
)
@@ -0,0 +1,21 @@
"""Hacker Style theme - Matrix inspired."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="hacker-style",
display_name="Hacker Style",
dark=True,
colors=ThemeColors(
primary="#20c20e",
secondary="#33ff33",
accent="#66ff66",
background="#0c0c0c",
surface="#121212",
panel="#1a1a1a",
foreground="#33ff33",
success="#20c20e",
warning="#c0c020",
error="#c02020",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Halloween theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="halloween",
display_name="Halloween",
dark=True,
colors=ThemeColors(
primary="#ff6600",
secondary="#8a2be2",
accent="#ff4500",
background="#0d0d0d",
surface="#1a1a1a",
panel="#262626",
foreground="#e6e6e6",
success="#00ff00",
warning="#ff6600",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Houston theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="houston",
display_name="Houston",
dark=True,
colors=ThemeColors(
primary="#ff6f00",
secondary="#00bcd4",
accent="#ff4081",
background="#17212b",
surface="#232e3c",
panel="#2e3a48",
foreground="#eeffff",
success="#4caf50",
warning="#ff9800",
error="#f44336",
),
)
@@ -0,0 +1,21 @@
"""Monokai Winter Night theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="monokai-winter",
display_name="Monokai Winter Night",
dark=True,
colors=ThemeColors(
primary="#66d9ef",
secondary="#a6e22e",
accent="#f92672",
background="#1a1a2e",
surface="#16213e",
panel="#0f3460",
foreground="#f8f8f2",
success="#a6e22e",
warning="#e6db74",
error="#f92672",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Nord theme - Arctic, north-bluish color palette."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="nord",
display_name="Nord",
dark=True,
colors=ThemeColors(
primary="#88c0d0",
secondary="#81a1c1",
accent="#b48ead",
background="#2e3440",
surface="#3b4252",
panel="#434c5e",
foreground="#eceff4",
success="#a3be8c",
warning="#ebcb8b",
error="#bf616a",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Dark theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark",
display_name="One Dark",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#98c379",
warning="#e5c07b",
error="#e06c75",
),
)
@@ -0,0 +1,21 @@
"""One Dark Pro theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark-pro",
display_name="One Dark Pro",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#1e2227",
panel="#333842",
foreground="#abb2bf",
success="#98c379",
warning="#d19a66",
error="#e06c75",
),
)
@@ -0,0 +1,21 @@
"""One Dark Teal theme - GitKraken signature teal accent."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark-teal",
display_name="One Dark Teal",
dark=True,
colors=ThemeColors(
primary="#2acf9f", # GitKraken teal
secondary="#61afef",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#2acf9f",
warning="#e5c07b",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-light",
display_name="One Light",
dark=False,
colors=ThemeColors(
primary="#4078f2",
secondary="#0184bc",
accent="#a626a4",
background="#fafafa",
surface="#f0f0f0",
panel="#e5e5e6",
foreground="#383a42",
success="#50a14f",
warning="#c18401",
error="#e45649",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Pro Hacker theme - Green on black."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="pro-hacker",
display_name="Pro Hacker",
dark=True,
colors=ThemeColors(
primary="#00ff00",
secondary="#00cc00",
accent="#00ff88",
background="#000000",
surface="#0a0a0a",
panel="#141414",
foreground="#00ff00",
success="#00ff00",
warning="#ffff00",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Santa Baby theme - Light Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="santa-baby",
display_name="Santa Baby",
dark=False,
colors=ThemeColors(
primary="#c41e3a", # Cardinal red
secondary="#228b22", # Forest green
accent="#b8860b", # Dark goldenrod
background="#fff8f0",
surface="#f0e8e0",
panel="#e0d8d0",
foreground="#2f1f1f",
success="#228b22",
warning="#daa520",
error="#c41e3a",
),
)
+24
View File
@@ -0,0 +1,24 @@
"""Summer Day theme - Light variant of Summer Night.
Inverted lightness scale with adjusted accent hues for readability.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="summer-day",
display_name="Summer Day",
dark=False,
colors=ThemeColors(
primary="#0088b0",
secondary="#008a99",
accent="#d03060",
background="#f5f7fa",
surface="#e8ebf0",
panel="#dde1e8",
foreground="#21262f",
success="#008a7a",
warning="#b06830",
error="#c04048",
),
)
@@ -0,0 +1,45 @@
"""Summer Night theme - Default dark theme.
Based on jackw01/summer-night-vscode-theme.
Vibrant colors with HCL-based monochrome scale.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
# Monochrome scale (HCL equidistant lightness)
# mono_1: #e2e8f5 - Lightest text
# mono_2: #c4c9d6 - Secondary text
# mono_3: #a6abb8 - Muted text
# mono_4: #898e9a - Comments
# mono_5: #6d727e - Subtle
# mono_6: #525762 - Borders
# mono_7: #393e48 - Surface
# mono_8: #21262f - Background
# Accent colors (HCL analogous scales)
# cyan: #00a3d2 - Primary accent
# teal: #00a9b9 - Links
# pink: #fa5f8b - Keywords
# yellow: #d3ab58 - Strings
# red: #f06c6f - Errors
# orange: #d08447 - Warnings
# coral: #e17954 - Functions
# green: #00ab9a - Success
theme = ThemeDefinition(
name="summer-night",
display_name="Summer Night",
dark=True,
colors=ThemeColors(
primary="#00a3d2",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
),
)
@@ -0,0 +1,21 @@
"""Winter Is Coming theme - Bluish, icy vibe."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="winter-is-coming",
display_name="Winter Is Coming",
dark=True,
colors=ThemeColors(
primary="#89ddff",
secondary="#82aaff",
accent="#c792ea",
background="#011627",
surface="#0d293e",
panel="#1d3b53",
foreground="#d6deeb",
success="#22da6e",
warning="#ecc48d",
error="#ef5350",
),
)
+87
View File
@@ -0,0 +1,87 @@
"""Custom theme loader for user-defined themes."""
import tomllib
from pathlib import Path
from clide.models.theme import ThemeColors, ThemeDefinition
from clide.themes.registry import register_theme
def load_custom_themes(themes_dir: Path) -> list[str]:
"""Load custom themes from a directory.
Args:
themes_dir: Directory containing .toml theme files
Returns:
List of loaded theme names
"""
loaded = []
if not themes_dir.exists():
return loaded
for theme_file in themes_dir.glob("*.toml"):
try:
theme = load_theme_file(theme_file)
if theme:
register_theme(theme, "custom")
loaded.append(theme.name)
except Exception as e:
# Log but don't crash on bad theme files
print(f"Failed to load theme {theme_file}: {e}")
return loaded
def load_theme_file(path: Path) -> ThemeDefinition | None:
"""Load a single theme from a TOML file.
Args:
path: Path to the theme TOML file
Returns:
Theme definition or None if invalid
Example TOML format:
name = "my-theme"
display_name = "My Custom Theme"
dark = true
[colors]
primary = "#007acc"
secondary = "#3c3c3c"
accent = "#0e639c"
background = "#1e1e1e"
surface = "#252526"
panel = "#2d2d2d"
foreground = "#d4d4d4"
success = "#4ec9b0"
warning = "#dcdcaa"
error = "#f14c4c"
"""
with open(path, "rb") as f:
data = tomllib.load(f)
# Validate required fields
required = ["name", "display_name", "dark", "colors"]
for field in required:
if field not in data:
raise ValueError(f"Missing required field: {field}")
colors_data = data["colors"]
color_fields = [
"primary", "secondary", "accent", "background", "surface",
"panel", "foreground", "success", "warning", "error"
]
for field in color_fields:
if field not in colors_data:
raise ValueError(f"Missing color field: {field}")
colors = ThemeColors(**colors_data)
return ThemeDefinition(
name=data["name"],
display_name=data["display_name"],
dark=data["dark"],
colors=colors,
)
+139
View File
@@ -0,0 +1,139 @@
"""Theme registry for managing available themes."""
from clide.models.theme import ThemeDefinition, ThemeMetadata
# Theme registry
_themes: dict[str, ThemeDefinition] = {}
_theme_metadata: dict[str, ThemeMetadata] = {}
DEFAULT_THEME = "summer-night"
def register_theme(
theme: ThemeDefinition,
category: str = "custom",
) -> None:
"""Register a theme in the registry.
Args:
theme: Theme definition to register
category: Theme category (core, popular, seasonal, custom)
"""
_themes[theme.name] = theme
_theme_metadata[theme.name] = ThemeMetadata(
name=theme.name,
display_name=theme.display_name,
dark=theme.dark,
category=category,
)
def get_theme(name: str) -> ThemeDefinition | None:
"""Get a theme by name.
Args:
name: Theme name
Returns:
Theme definition or None if not found
"""
return _themes.get(name)
def get_all_themes() -> list[ThemeMetadata]:
"""Get metadata for all registered themes.
Returns:
List of theme metadata sorted by category then name
"""
themes = list(_theme_metadata.values())
# Sort: core first, then alphabetically by category, then by name
category_order = {"core": 0, "popular": 1, "gitkraken": 2, "seasonal": 3, "hacker": 4, "custom": 5}
themes.sort(key=lambda t: (category_order.get(t.category, 99), t.name))
return themes
def get_themes_by_category(category: str) -> list[ThemeMetadata]:
"""Get themes filtered by category.
Args:
category: Category to filter by
Returns:
List of theme metadata in that category
"""
return [t for t in _theme_metadata.values() if t.category == category]
def _load_builtin_themes() -> None:
"""Load all built-in themes."""
# Import here to avoid circular imports
from clide.themes.builtin import (
all_hallows_eve,
christmas,
dark_autumn,
dracula,
fall,
gamma,
gruvbox_dark,
gruvbox_light,
hacker_style,
halloween,
houston,
monokai_winter,
nord,
one_dark,
one_dark_pro,
one_dark_teal,
one_light,
pro_hacker,
santa_baby,
summer_day,
summer_night,
winter_is_coming,
)
# Core themes
register_theme(summer_night.theme, "core")
register_theme(summer_day.theme, "core")
# Popular themes
register_theme(one_dark.theme, "popular")
register_theme(one_dark_pro.theme, "popular")
register_theme(one_light.theme, "popular")
register_theme(dracula.theme, "popular")
register_theme(nord.theme, "popular")
register_theme(gruvbox_dark.theme, "popular")
register_theme(gruvbox_light.theme, "popular")
# GitKraken style
register_theme(one_dark_teal.theme, "gitkraken")
register_theme(gamma.theme, "gitkraken")
# Seasonal - Winter
register_theme(winter_is_coming.theme, "seasonal")
register_theme(monokai_winter.theme, "seasonal")
# Seasonal - Fall
register_theme(fall.theme, "seasonal")
register_theme(dark_autumn.theme, "seasonal")
# Seasonal - Halloween
register_theme(all_hallows_eve.theme, "seasonal")
register_theme(halloween.theme, "seasonal")
# Seasonal - Christmas
register_theme(christmas.theme, "seasonal")
register_theme(santa_baby.theme, "seasonal")
# Hacker style
register_theme(pro_hacker.theme, "hacker")
register_theme(hacker_style.theme, "hacker")
# Bonus
register_theme(houston.theme, "popular")
# Load built-in themes on module import
_load_builtin_themes()
+1
View File
@@ -0,0 +1 @@
"""Vendored third-party libraries for Clide."""
+19
View File
@@ -0,0 +1,19 @@
pyte - LGPL License
====================
This is a vendored copy of pyte (https://github.com/selectel/pyte)
with modifications for Clide diagnostic logging.
Original copyright:
(c) 2011-2012 by Selectel.
(c) 2012-2017 by pyte authors and contributors.
This code is licensed under the GNU Lesser General Public License (LGPL).
Modifications made by the Clide project are also licensed under LGPL.
For the full LGPL license text, see:
https://www.gnu.org/licenses/lgpl-3.0.html
Modifications:
- Added diagnostic logging hooks for debugging terminal rendering issues
- Added event callback support for Claude Code integration
+67
View File
@@ -0,0 +1,67 @@
"""
pyte
~~~~
`pyte` implements a mix of VT100, VT220 and VT520 specification,
and aims to support most of the `TERM=linux` functionality.
:copyright: (c) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details.
:license: LGPL, see LICENSE for more details.
Vendored for Clide with modifications for diagnostic logging.
"""
__all__ = (
"Screen",
"DiffScreen",
"HistoryScreen",
"DebugScreen",
"Stream",
"ByteStream",
# Clide additions
"set_debug_logger",
"get_debug_logger",
"set_event_callback",
)
import io
# Re-export submodules for compatibility
from . import screens
from .screens import DebugScreen, DiffScreen, HistoryScreen, Screen
from .screens import set_debug_logger as _set_screen_logger
from .streams import ByteStream, Stream, set_event_callback
from .streams import set_debug_logger as _set_stream_logger
def set_debug_logger(logger):
"""Set debug logger for both streams and screens.
Args:
logger: A callable that accepts a string message, or None to disable.
"""
_set_stream_logger(logger)
_set_screen_logger(logger)
def get_debug_logger():
"""Get the current debug logger (if set).
Returns:
The current debug logger callable, or None if not set.
"""
return screens._debug_logger
if __debug__:
def dis(chars: bytes | str) -> None:
"""A :func:`dis.dis` for terminals."""
if isinstance(chars, str):
chars = chars.encode("utf-8")
with io.StringIO() as buf:
ByteStream(DebugScreen(to=buf)).feed(chars)
print(buf.getvalue())
+139
View File
@@ -0,0 +1,139 @@
"""
pyte.charsets
~~~~~~~~~~~~~
This module defines ``G0`` and ``G1`` charset mappings the same way
they are defined for linux terminal, see
``linux/drivers/tty/consolemap.c`` @ http://git.kernel.org
.. note:: ``VT100_MAP`` and ``IBMPC_MAP`` were taken unchanged
from linux kernel source and therefore are licensed
under **GPL**.
:copyright: (c) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details.
:license: LGPL, see LICENSE for more details.
Vendored for Clide with modifications for diagnostic logging.
"""
#: Latin1.
LAT1_MAP = "".join(map(chr, range(256)))
#: VT100 graphic character set.
VT100_MAP = "".join(chr(c) for c in [
0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x000e, 0x000f,
0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
0x0018, 0x0019, 0x001a, 0x001b, 0x001c, 0x001d, 0x001e, 0x001f,
0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
0x0028, 0x0029, 0x002a, 0x2192, 0x2190, 0x2191, 0x2193, 0x002f,
0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x00a0,
0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1,
0x2591, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba,
0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c,
0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x007f,
0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
0x0088, 0x0089, 0x008a, 0x008b, 0x008c, 0x008d, 0x008e, 0x008f,
0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
0x0098, 0x0099, 0x009a, 0x009b, 0x009c, 0x009d, 0x009e, 0x009f,
0x00a0, 0x00a1, 0x00a2, 0x00a3, 0x00a4, 0x00a5, 0x00a6, 0x00a7,
0x00a8, 0x00a9, 0x00aa, 0x00ab, 0x00ac, 0x00ad, 0x00ae, 0x00af,
0x00b0, 0x00b1, 0x00b2, 0x00b3, 0x00b4, 0x00b5, 0x00b6, 0x00b7,
0x00b8, 0x00b9, 0x00ba, 0x00bb, 0x00bc, 0x00bd, 0x00be, 0x00bf,
0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c6, 0x00c7,
0x00c8, 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf,
0x00d0, 0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d7,
0x00d8, 0x00d9, 0x00da, 0x00db, 0x00dc, 0x00dd, 0x00de, 0x00df,
0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, 0x00e5, 0x00e6, 0x00e7,
0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00ed, 0x00ee, 0x00ef,
0x00f0, 0x00f1, 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00f7,
0x00f8, 0x00f9, 0x00fa, 0x00fb, 0x00fc, 0x00fd, 0x00fe, 0x00ff
])
#: IBM Codepage 437.
IBMPC_MAP = "".join(chr(c) for c in [
0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f,
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x006f,
0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
])
#: VAX42 character set.
VAX42_MAP = "".join(chr(c) for c in [
0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c,
0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8,
0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc,
0x0020, 0x043b, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f,
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x0435,
0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f,
0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f,
0x0060, 0x0441, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
0x0435, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x043a,
0x0070, 0x0071, 0x0442, 0x0073, 0x043b, 0x0435, 0x0076, 0x0077,
0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302,
0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0
])
MAPS = {
"B": LAT1_MAP,
"0": VT100_MAP,
"U": IBMPC_MAP,
"V": VAX42_MAP
}
+77
View File
@@ -0,0 +1,77 @@
"""
pyte.control
~~~~~~~~~~~~
This module defines simple control sequences, recognized by
:class:`~pyte.streams.Stream`, the set of codes here is for
``TERM=linux`` which is a superset of VT102.
:copyright: (c) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details.
:license: LGPL, see LICENSE for more details.
Vendored for Clide with modifications for diagnostic logging.
"""
#: *Space*: Not surprisingly -- ``" "``.
SP = " "
#: *Null*: Does nothing.
NUL = "\x00"
#: *Bell*: Beeps.
BEL = "\x07"
#: *Backspace*: Backspace one column, but not past the beginning of the
#: line.
BS = "\x08"
#: *Horizontal tab*: Move cursor to the next tab stop, or to the end
#: of the line if there is no earlier tab stop.
HT = "\x09"
#: *Linefeed*: Give a line feed, and, if :data:`pyte.modes.LNM` (new
#: line mode) is set also a carriage return.
LF = "\n"
#: *Vertical tab*: Same as :data:`LF`.
VT = "\x0b"
#: *Form feed*: Same as :data:`LF`.
FF = "\x0c"
#: *Carriage return*: Move cursor to left margin on current line.
CR = "\r"
#: *Shift out*: Activate G1 character set.
SO = "\x0e"
#: *Shift in*: Activate G0 character set.
SI = "\x0f"
#: *Cancel*: Interrupt escape sequence. If received during an escape or
#: control sequence, cancels the sequence and displays substitution
#: character.
CAN = "\x18"
#: *Substitute*: Same as :data:`CAN`.
SUB = "\x1a"
#: *Escape*: Starts an escape sequence.
ESC = "\x1b"
#: *Delete*: Is ignored.
DEL = "\x7f"
#: *Control sequence introducer*.
CSI_C0 = ESC + "["
CSI_C1 = "\x9b"
CSI = CSI_C0
#: *String terminator*.
ST_C0 = ESC + "\\"
ST_C1 = "\x9c"
ST = ST_C0
#: *Operating system command*.
OSC_C0 = ESC + "]"
OSC_C1 = "\x9d"
OSC = OSC_C0
+154
View File
@@ -0,0 +1,154 @@
"""
pyte.escape
~~~~~~~~~~~
This module defines both CSI and non-CSI escape sequences, recognized
by :class:`~pyte.streams.Stream` and subclasses.
:copyright: (c) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details.
:license: LGPL, see LICENSE for more details.
Vendored for Clide with modifications for diagnostic logging.
"""
#: *Reset*.
RIS = "c"
#: *Index*: Move cursor down one line in same column. If the cursor is
#: at the bottom margin, the screen performs a scroll-up.
IND = "D"
#: *Next line*: Same as :data:`pyte.control.LF`.
NEL = "E"
#: Tabulation set: Set a horizontal tab stop at cursor position.
HTS = "H"
#: *Reverse index*: Move cursor up one line in same column. If the
#: cursor is at the top margin, the screen performs a scroll-down.
RI = "M"
#: Save cursor: Save cursor position, character attribute (graphic
#: rendition), character set, and origin mode selection (see
#: :data:`DECRC`).
DECSC = "7"
#: *Restore cursor*: Restore previously saved cursor position, character
#: attribute (graphic rendition), character set, and origin mode
#: selection. If none were saved, move cursor to home position.
DECRC = "8"
# "Sharp" escape sequences.
# -------------------------
#: *Alignment display*: Fill screen with uppercase E's for testing
#: screen focus and alignment.
DECALN = "8"
# ECMA-48 CSI sequences.
# ---------------------
#: *Insert character*: Insert the indicated # of blank characters.
ICH = "@"
#: *Cursor up*: Move cursor up the indicated # of lines in same column.
#: Cursor stops at top margin.
CUU = "A"
#: *Cursor down*: Move cursor down the indicated # of lines in same
#: column. Cursor stops at bottom margin.
CUD = "B"
#: *Cursor forward*: Move cursor right the indicated # of columns.
#: Cursor stops at right margin.
CUF = "C"
#: *Cursor back*: Move cursor left the indicated # of columns. Cursor
#: stops at left margin.
CUB = "D"
#: *Cursor next line*: Move cursor down the indicated # of lines to
#: column 1.
CNL = "E"
#: *Cursor previous line*: Move cursor up the indicated # of lines to
#: column 1.
CPL = "F"
#: *Cursor horizontal align*: Move cursor to the indicated column in
#: current line.
CHA = "G"
#: *Cursor position*: Move cursor to the indicated line, column (origin
#: at ``1, 1``).
CUP = "H"
#: *Erase data* (default: from cursor to end of line).
ED = "J"
#: *Erase in line* (default: from cursor to end of line).
EL = "K"
#: *Insert line*: Insert the indicated # of blank lines, starting from
#: the current line. Lines displayed below cursor move down. Lines moved
#: past the bottom margin are lost.
IL = "L"
#: *Delete line*: Delete the indicated # of lines, starting from the
#: current line. As lines are deleted, lines displayed below cursor
#: move up. Lines added to bottom of screen have spaces with same
#: character attributes as last line move up.
DL = "M"
#: *Delete character*: Delete the indicated # of characters on the
#: current line. When character is deleted, all characters to the right
#: of cursor move left.
DCH = "P"
#: *Erase character*: Erase the indicated # of characters on the
#: current line.
ECH = "X"
#: *Horizontal position relative*: Same as :data:`CUF`.
HPR = "a"
#: *Device Attributes*.
DA = "c"
#: *Vertical position adjust*: Move cursor to the indicated line,
#: current column.
VPA = "d"
#: *Vertical position relative*: Same as :data:`CUD`.
VPR = "e"
#: *Horizontal / Vertical position*: Same as :data:`CUP`.
HVP = "f"
#: *Tabulation clear*: Clears a horizontal tab stop at cursor position.
TBC = "g"
#: *Set mode*.
SM = "h"
#: *Reset mode*.
RM = "l"
#: *Select graphics rendition*: The terminal can display the following
#: character attributes that change the character display without
#: changing the character (see :mod:`pyte.graphics`).
SGR = "m"
#: *Device status report*.
DSR = "n"
#: *Select top and bottom margins*: Selects margins, defining the
#: scrolling region; parameters are top and bottom line. If called
#: without any arguments, whole screen is used.
DECSTBM = "r"
#: *Horizontal position adjust*: Same as :data:`CHA`.
HPA = "'"

Some files were not shown because too many files have changed in this diff Show More