Add Clide project structure and initial implementation

Set up the TUI IDE wrapper for Claude Code CLI with:
- Core app structure using Textual framework
- Panel architecture (sidebar, workspace, claude, context)
- Theme system with 22 built-in themes (Summer Night default)
- Pydantic models for configuration and data
- Makefile for development commands
- Project documentation and specs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 21:04:44 +01:00
co-authored by Claude Opus 4.5
parent 12825e15e2
commit c5f8b2e615
103 changed files with 11660 additions and 11 deletions
+7
View File
@@ -174,3 +174,10 @@ cython_debug/
# PyPI configuration file
.pypirc
# Clide specific
.config/
snapshot_report.html
# Claude Code
.claude/
+371
View File
@@ -0,0 +1,371 @@
# 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
- **VSCode-familiar**: Keybindings follow VSCode conventions
- **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 |
| Testing | pytest + pytest-asyncio + pytest-textual-snapshot | latest |
| Extensions | pluggy | latest |
## Development Commands
```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
```
## 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/
├── src/clide/
│ ├── __init__.py
│ ├── __main__.py
│ ├── app.py # Main App, layout, keybindings
│ ├── cli.py # Typer entry point
│ │
│ ├── controllers/ # Domain logic (no UI)
│ │ ├── base.py # BaseController ABC
│ │ ├── git.py # Git status, staging, branches
│ │ ├── editor.py # Open files, cursor state
│ │ ├── diff.py # Diff generation, accept/reject
│ │ ├── problems.py # Linter aggregation
│ │ ├── todos.py # TODO/FIXME scanning
│ │ └── jira.py # Jira CLI integration
│ │
│ ├── widgets/
│ │ ├── panels/ # Main layout containers
│ │ │ ├── sidebar.py # Left sidebar with tabs
│ │ │ ├── workspace.py # Editor/Diff/Terminal tabs
│ │ │ ├── claude.py # Claude interaction panel
│ │ │ └── context.py # Right context panel
│ │ │
│ │ └── components/ # Reusable UI pieces
│ │ ├── files_view.py # DirectoryTree wrapper
│ │ ├── git_changes.py # Staged/unstaged file list
│ │ ├── git_graph.py # Branch visualization
│ │ ├── branch_status.py # Current branch + popout
│ │ ├── editor_pane.py # TextArea with syntax
│ │ ├── diff_pane.py # Side-by-side/unified diff
│ │ ├── terminal_pane.py # PTY terminal
│ │ ├── problems_view.py # Linter errors list
│ │ ├── todos_view.py # TODO comments list
│ │ └── jira_view.py # Jira output display
│ │
│ ├── models/ # Pydantic data models
│ │ ├── config.py # App settings (ClideSettings)
│ │ ├── git.py # GitStatus, GitChange, GitBranch
│ │ ├── editor.py # EditorState, FileBuffer
│ │ ├── diff.py # DiffContent, DiffHunk
│ │ ├── problems.py # Problem, Severity
│ │ ├── todos.py # TodoItem, TodoType
│ │ └── theme.py # ThemeColors, ThemeDefinition
│ │
│ ├── services/ # Background task logic
│ │ ├── process_service.py # Generic subprocess mgmt
│ │ ├── git_service.py # Git command execution
│ │ ├── file_service.py # File I/O, language detection
│ │ ├── linter_service.py # Run linters, parse output
│ │ └── todo_scanner.py # Grep for TODOs
│ │
│ ├── themes/ # Theme system
│ │ ├── __init__.py
│ │ ├── registry.py # Theme registry, get_theme()
│ │ ├── loader.py # Load custom themes from .config
│ │ └── builtin/ # 22 built-in themes
│ │ ├── __init__.py # Auto-register all themes
│ │ ├── summer_night.py # DEFAULT - Summer Night dark
│ │ ├── summer_day.py # Summer Day light
│ │ ├── one_dark.py # Atom One Dark
│ │ ├── dracula.py # Dracula theme
│ │ ├── nord.py # Nord color palette
│ │ ├── gruvbox_dark.py # Gruvbox dark
│ │ ├── gruvbox_light.py # Gruvbox light
│ │ └── ... # More themes
│ │
│ ├── extensions/ # Plugin system
│ │ ├── hookspecs.py
│ │ ├── manager.py
│ │ └── builtin/
│ │
│ └── helpers/
│ ├── async_utils.py
│ ├── path_utils.py
│ └── terminal_utils.py
├── 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
│ └── ARCHITECTURE.md # Framework best practices
├── 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 (VSCode-style)
| Action | Binding |
|--------|---------|
| Command palette | `Ctrl+Shift+P` |
| Quick open | `Ctrl+P` |
| Toggle left sidebar | `Ctrl+B` |
| Toggle right sidebar | `Ctrl+Shift+B` |
| Toggle terminal | `` Ctrl+` `` |
| Focus Claude | `Ctrl+1` |
| Focus Editor | `Ctrl+2` |
| Focus Terminal | `Ctrl+3` |
| Toggle compact mode | `Ctrl+Shift+C` |
| Git panel | `Ctrl+Shift+G` |
| Problems panel | `Ctrl+Shift+M` |
| Select theme | `Ctrl+K Ctrl+T` |
## 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)
-10
View File
@@ -1,10 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>
+95
View File
@@ -0,0 +1,95 @@
.PHONY: setup run test test-single typecheck lint format build build-all clean help
PYTHON := python3.12
VENV := .venv
BIN := $(VENV)/bin
TEST ?= tests/
# Colors for output
BLUE := \033[0;34m
GREEN := \033[0;32m
RESET := \033[0m
help:
@echo "$(BLUE)Clide Development Commands$(RESET)"
@echo ""
@echo "$(GREEN)setup$(RESET) Create venv and install dependencies"
@echo "$(GREEN)run$(RESET) Run the application"
@echo "$(GREEN)test$(RESET) Run all tests"
@echo "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)"
@echo "$(GREEN)typecheck$(RESET) Run mypy type checking"
@echo "$(GREEN)lint$(RESET) Run ruff linter"
@echo "$(GREEN)format$(RESET) Run ruff formatter"
@echo "$(GREEN)build$(RESET) Build executable for current platform"
@echo "$(GREEN)clean$(RESET) Remove build artifacts and caches"
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
@echo "$(GREEN)Setup complete! Activate with: source $(VENV)/bin/activate$(RESET)"
run:
$(BIN)/python -m clide
test:
$(BIN)/pytest $(TEST)
test-single:
$(BIN)/pytest $(TEST) -v
test-cov:
$(BIN)/pytest --cov=src/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 src/
lint:
$(BIN)/ruff check src/ tests/
format:
$(BIN)/ruff format src/ tests/
$(BIN)/ruff check --fix src/ tests/
build:
$(BIN)/pyinstaller clide.spec --clean
build-onefile:
$(BIN)/pyinstaller \
--name clide \
--onefile \
--clean \
--noconfirm \
src/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 src/ tests/
mypy src/
ci-test:
pip install -e ".[dev]"
pytest --cov=src/clide --cov-report=xml
ci-build:
pip install -e ".[build]"
pyinstaller clide.spec --clean
+8 -1
View File
@@ -1,3 +1,10 @@
# clide
CLI Claude
Clide - a Claude CLI UI
A TUI cli IDE for claude code cli. Features file navigator/git panel left bar, main panel with integrated terminal running claude, right panel for integration of IMAGIN.studio img cli Atlassian JIRA/CONFLUENCE tooling.
- Python 3.12 based
- Pydantic strict data handling
- Typer as cli integration
- Textual for TUI
+415
View File
@@ -0,0 +1,415 @@
# Clide Architecture Documentation
Comprehensive documentation of architecture patterns, best practices, and implementation guidelines.
## Table of Contents
- [Textual TUI Framework](#textual-tui-framework)
- [Typer CLI Framework](#typer-cli-framework)
- [Pydantic Data Validation](#pydantic-data-validation)
- [Extension System](#extension-system)
- [Testing Strategy](#testing-strategy)
- [Build and Distribution](#build-and-distribution)
---
## Textual TUI Framework
Textual models TUIs as a reactive tree of widgets, similar to React's component tree but grid-based on character cells.
### Key Concepts
**Widgets and Containers**
- Widgets are the building blocks of the UI
- Containers are widgets that hold other widgets
- Default layout stacks widgets vertically from top of screen
**Reactive Programming**
- State changes trigger automatic UI updates
- No manual refresh loops needed
- Use reactive attributes for state management
**Event-Driven Model**
- Define callbacks for key presses, mouse clicks, timer ticks
- Actions are functions callable via keystroke or text link
### Best Practices
1. **Use Immutable Objects**
- Prefer tuples, NamedTuples, or frozen dataclasses
- Easier to reason about, cache, and test
- Enables side-effect-free code
2. **Separate Styles**
- Keep CSS in `.tcss` files, not inline
- Python code stays clean and focused on logic
3. **Async-First**
- Textual is async under the hood
- Use `async`/`await` for I/O operations
- Can integrate with async libraries if needed
### Layout Management
```python
# Grid layout example
CSS = """
Screen {
layout: grid;
grid-size: 3 1;
grid-columns: 1fr 2fr 1fr;
}
"""
```
### References
- [Textual Documentation](https://textual.textualize.io/)
- [Textual Tutorial](https://textual.textualize.io/tutorial/)
- [Real Python Textual Guide](https://realpython.com/python-textual/)
- [Textual GitHub](https://github.com/Textualize/textual)
---
## Typer CLI Framework
Typer is built on Click with Python type hints for automatic argument parsing.
### Project Structure Pattern
```
app/
├── __init__.py
├── main.py # Root Typer app
├── commands/ # Subcommand modules
│ ├── users.py
│ └── tasks.py
└── helpers/ # Shared utilities
└── validate.py
```
### Best Practices
1. **Organize Commands**
- Use `add_typer()` to group commands
- Avoid giant files with dozens of commands
- Each command function should orchestrate, not contain all logic
2. **Entry Point Support**
- Add `__main__.py` for `python -m` support
- Define entry points in pyproject.toml for CLI scripts
3. **Standard Exit Codes**
- `0` for success
- Non-zero for errors
- Crucial for CI/CD integration
4. **Type Hints for Validation**
- Use Enum for dropdown-style restrictions
- Type hints provide editor autocompletion
### Subcommand Example
```python
# commands/users.py
import typer
app = typer.Typer()
@app.command()
def create(name: str):
"""Create a new user."""
...
# main.py
from commands import users
main_app = typer.Typer()
main_app.add_typer(users.app, name="users")
```
### References
- [Typer Documentation](https://typer.tiangolo.com/)
- [Typer Subcommands](https://typer.tiangolo.com/tutorial/subcommands/)
- [Building a Package](https://typer.tiangolo.com/tutorial/package/)
---
## Pydantic Data Validation
Pydantic v2 with strict mode ensures type safety and validation.
### Strict Mode Configuration
```python
from pydantic import BaseModel, ConfigDict
class MyModel(BaseModel):
model_config = ConfigDict(strict=True, frozen=True)
name: str
count: int # Will reject "123" string
```
### Settings Management
Settings have moved to `pydantic-settings` package:
```python
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file=".env",
env_nested_delimiter="__",
)
database_url: str
debug: bool = False
```
### Best Practices
1. **Use `frozen=True` for Immutability**
- Prevents accidental mutation
- Enables hashing for use as dict keys
2. **Explicit Strict Types**
- `StrictInt`, `StrictStr` for field-level strictness
- Or use `model_config` for model-wide strictness
3. **Validation vs Parsing**
- Strict mode rejects type coercion
- JSON parsing allows some conversion (ISO8601 → datetime)
### References
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
- [Pydantic Configuration](https://docs.pydantic.dev/latest/api/config/)
- [Migration Guide](https://docs.pydantic.dev/latest/migration/)
---
## Extension System
The plugin system uses Pluggy for hook-based extensibility.
### Pluggy Concepts
1. **Hook Specifications** - Define the interface extensions implement
2. **Hook Implementations** - Extension code implementing hooks
3. **Plugin Manager** - Discovers and calls implementations
### Architecture
```python
# hookspecs.py - Define hooks
import pluggy
hookspec = pluggy.HookspecMarker("clide")
hookimpl = pluggy.HookimplMarker("clide")
class ClideHookSpec:
@hookspec
def register_panel(self) -> dict: ...
# extension.py - Implement hooks
class MyExtension:
@hookimpl
def register_panel(self) -> dict:
return {"name": "custom", "widget": CustomWidget}
```
### Distribution
Extensions can be distributed as packages using entry points:
```toml
# pyproject.toml of extension package
[project.entry-points."clide.extensions"]
my_extension = "my_package:MyExtension"
```
### Hook Execution Order
- Multiple implementations called in LIFO (Last In, First Out) order
- Use `hookimpl(tryfirst=True)` or `hookimpl(trylast=True)` for ordering
### Alternatives
- **Stevedore** - Better for driver/extension patterns, uses entry points
- Choose Pluggy for hook-based systems (like pytest uses)
### References
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
- [Stevedore Documentation](https://docs.openstack.org/stevedore/latest/)
- [Creating Plugins with Stevedore](https://docs.openstack.org/stevedore/latest/user/tutorial/creating_plugins.html)
---
## Testing Strategy
### pytest-asyncio
Configure auto mode for automatic async test discovery:
```toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
```
### Async Test Patterns
```python
import pytest
# Auto mode - no decorator needed
async def test_async_operation():
result = await some_async_function()
assert result == expected
# Async fixtures
@pytest.fixture
async def database_connection():
conn = await create_connection()
yield conn
await conn.close()
```
### Async Mocking
```python
from unittest.mock import AsyncMock
async def test_with_mock():
mock_service = AsyncMock(return_value={"status": "ok"})
result = await mock_service()
assert result["status"] == "ok"
```
### Snapshot Testing
Visual regression with pytest-textual-snapshot:
```python
def test_layout(snap_compare):
assert snap_compare("app.py", terminal_size=(120, 40))
def test_with_interaction(snap_compare):
async def setup(pilot):
await pilot.press("tab", "enter")
assert snap_compare("app.py", run_before=setup)
```
Update snapshots after intentional changes:
```bash
pytest tests/snapshots/ --snapshot-update
```
### Test Harness Pattern
Harnesses provide isolated test environments:
```python
class AppHarness:
async def start(self) -> tuple[App, Pilot]:
"""Start app with mocked dependencies."""
async def stop(self) -> None:
"""Clean shutdown."""
```
### Best Practices
1. **Always use `@pytest.mark.asyncio`** (or auto mode)
2. **Use async fixtures** for async setup/teardown
3. **Mock external services** - don't hit real APIs
4. **Choose appropriate fixture scopes** for performance
5. **Avoid blocking the event loop** in async tests
### References
- [pytest-asyncio Documentation](https://pytest-asyncio.readthedocs.io/en/latest/)
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
- [pytest Fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html)
---
## Build and Distribution
### PyInstaller Limitations
**Critical: PyInstaller cannot cross-compile.**
- Build on the target OS
- Use CI/CD for multi-platform builds
### CI/CD Multi-Platform Build
Use Gitea Actions (or compatible CI) for multi-platform builds:
```yaml
# .gitea/workflows/build.yml
name: Build
on: [push, tag]
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[build]"
- run: pyinstaller clide.spec --clean
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[build]"
- run: pyinstaller clide.spec --clean
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[build]"
- run: pyinstaller clide.spec --clean
```
### Optimization Tips
1. **Use `--onefile`** for single executable
2. **Apply `--strip`** to reduce binary size
3. **Use UPX compression** (460 MB → ~130 MB possible)
4. **Exclude unused modules** with `--exclude-module`
5. **Lazy imports** for large libraries
### Platform-Specific Output
- **Windows**: `.exe` or MSIX installer
- **macOS**: `.app` bundle in `.dmg`
- **Linux**: AppImage or native package
### Linux Compatibility
Build on the oldest target distro version. Newer systems may produce incompatible binaries.
### References
- [PyInstaller Documentation](https://pyinstaller.org/)
- [Building the Bootloader](https://pyinstaller.org/en/latest/bootloader-building.html)
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
+503
View File
@@ -0,0 +1,503 @@
# TUI IDE Specification
A terminal-based IDE built with Textual, designed to wrap Claude Code and integrate project management tooling (Jira/Confluence via CLI).
## Design Principles
- **Claude-centric**: Claude Code is the primary workspace, always visible
- **Contextual panels**: Editor/Diff/Terminal appear only when needed
- **VSCode-familiar**: Keybindings and interaction patterns follow VSCode conventions
- **Responsive**: Works on 13" laptop and widescreen monitors
- **No vim magic**: Standard keyboard navigation, no modal editing
---
## Panel Structure
### Layout Overview
```
┌─────────────────┬─────────────────────────┬──────────────────┐
│ panel-sidebar │ panel-workspace (60%) │ panel-context │
│ │ [Editor][Diff][Terminal]│ │
│ [Files][Git] │ (hidden when inactive) │ (content area) │
│ [Tree] ├─────────────────────────┤ │
│ │ │ │
│ (content area) │ panel-claude │ │
│ │ (40% when workspace │ │
│ │ visible, else 100%) │ │
├─────────────────┤ ├──────────────────┤
│ branch-status │ │[⚠ 3][✓12][Jira] │
│ ⎇ main ▾ │ │ context-tabs │
└─────────────────┴─────────────────────────┴──────────────────┘
```
### Panel Definitions
```python
PANELS = {
# Left sidebar
"sidebar": "panel-sidebar",
"sidebar-files": "panel-sidebar-files",
"sidebar-git": "panel-sidebar-git",
"sidebar-tree": "panel-sidebar-tree",
"branch-status": "panel-branch-status",
# Center
"claude": "panel-claude",
"workspace": "panel-workspace",
"editor": "panel-editor",
"diff": "panel-diff",
"terminal": "panel-terminal",
# Right context
"context": "panel-context",
"context-jira": "panel-context-jira",
"context-problems": "panel-context-problems",
"context-todos": "panel-context-todos",
}
```
---
## Left Sidebar (`panel-sidebar`)
### Tabs
| Tab | Content | Widget |
|-----|---------|--------|
| Files | Project file tree | `DirectoryTree` |
| Git | Staged/Unstaged changes | `GitChangesView` (custom) |
| Tree | Merge/branch graph | `GitGraphView` (custom) |
### Git Tab Details
Two collapsible sections:
- **Staged**: Files in index, ready to commit
- **Unstaged**: Modified/untracked files
Each file item shows:
- Status icon: `+` added, `~` modified, `-` deleted, `?` untracked, `→` renamed
- File path (relative)
**Interactions:**
- Click file → opens in Editor panel
- Double-click or keybind → stage/unstage file
- Right-click or keybind → show context menu (discard, diff, etc.)
### Tree Tab Details
Renders `git log --graph --oneline --decorate --all` with visual styling.
**Polish item**: Consider custom rendering with box-drawing characters for a cleaner look:
```
●──┬── main: Latest commit message
│ ●── feature: Feature work
●──┴── Merge branch 'feature'
◆───── Tagged release v1.0
```
Use canvas or rich text with:
```python
GRAPH_CHARS = {
'commit': '',
'merge': '',
'line': '',
'branch': '├──',
'join': '┴──',
}
```
### Branch Status Bar
Fixed at bottom of sidebar. Shows current branch with popout toggle.
```
┌─────────────────┐
│ ⎇ main ▾ │ ← Click or keybind to expand
└─────────────────┘
▼ (popout overlay)
┌─────────────────┐
│ Recent branches │
│ ○ main │
│ ○ feature/xyz │
│ ○ develop │
├─────────────────┤
│ [Checkout] [New]│
└─────────────────┘
```
---
## Center Column
### Claude Panel (`panel-claude`)
The primary workspace. Displays Claude Code interaction.
**Default state**: 100% height of center column
**With workspace**: 40% height (bottom)
**Content:**
- Streaming markdown responses (use `Markdown` or `RichLog` widget)
- Visual distinction between:
- Claude's responses
- Tool calls / file operations
- User input
- Input area at bottom
### Workspace Panel (`panel-workspace`)
Tabbed container for Editor, Diff, and Terminal. **Hidden by default.**
**Important**: Hiding is not closing. All panels retain state when hidden:
- Editor: Open files, cursor position, scroll position, unsaved changes
- Diff: Current diff content, scroll position
- Terminal: Active session, command history, output buffer
Use `display: none` for visibility, never destroy/recreate widgets.
**Visibility triggers:**
| Trigger | Result |
|---------|--------|
| Click file in sidebar | Show workspace, focus Editor tab |
| Claude proposes changes | Show workspace, focus Diff tab |
| User presses `` Ctrl+` `` | Show workspace, focus Terminal tab |
| User runs command | Show workspace, focus Terminal tab |
| Close all tabs / Escape | Hide workspace, Claude reclaims space |
**Height**: 60% of center column when visible
#### Editor Tab
- `TextArea` widget with syntax highlighting
- Language detection from file extension
- Theme: Follow terminal theme or user preference
#### Diff Tab
- Side-by-side or unified diff view
- Syntax highlighting for changed content
- Accept/Reject buttons for Claude-proposed changes
#### Terminal Tab
- Proper PTY integration for full terminal emulation
- Or simpler command runner with output display (decide based on complexity)
- Working directory tied to project root
---
## Right Sidebar (`panel-context`)
### Content Area
Switches based on selected bottom tab. Shows one of:
- Jira view (default)
- Problems view
- TODOs view
### Bottom Tab Bar (`context-tabs`)
```
┌──────────────────┐
│ [⚠ 3][✓12][Jira]│
└──────────────────┘
```
Tabs show inline counts that update reactively.
| Tab | Icon | Content |
|-----|------|---------|
| Problems | ⚠ | Linter errors, warnings (count badge) |
| TODOs | ✓ | TODO/FIXME comments from codebase (count badge) |
| Jira | Jira | Output from your CLI tool (default) |
### Jira View
Renders markdown output from your CLI tool. Refreshes on:
- Panel focus
- Manual refresh keybind
- Configurable interval
### Problems View
Aggregates from linters (eslint, ruff, etc.). Shows:
- File path
- Line number
- Severity icon
- Message
Click → opens file in Editor at that line.
### TODOs View
Grep results for `TODO`, `FIXME`, `HACK`, `XXX`. Shows:
- File path
- Line number
- Comment text
Click → opens file in Editor at that line.
---
## Responsiveness
### CSS Breakpoints
```css
/* Widescreen (default) */
#panel-sidebar { width: 20%; min-width: 25; }
#panel-context { width: 25%; min-width: 30; }
#panel-claude { width: 1fr; }
/* Medium terminals */
@media (width < 120) {
#panel-sidebar { width: 18%; }
#panel-context { width: 22%; }
}
/* Narrow terminals (laptop, split screen) */
@media (width < 100) {
#panel-sidebar { display: none; }
#panel-context { width: 25%; }
}
@media (width < 80) {
#panel-context { display: none; }
#panel-claude { width: 100%; }
}
```
### Compact Mode
Toggle with `Ctrl+Shift+C`. Hides both sidebars, maximizes Claude + workspace.
```css
.compact #panel-sidebar { display: none; }
.compact #panel-context { display: none; }
```
### Fullscreen Mode
Any panel can go fullscreen with `F11` (when focused). Press `Escape` to exit.
```css
.fullscreen {
dock: top;
width: 100%;
height: 100%;
layer: fullscreen;
}
```
---
## Keybindings
Following VSCode conventions where possible.
### Global
| Action | Binding |
|--------|---------|
| Command palette | `Ctrl+Shift+P` |
| Quick open file | `Ctrl+P` |
| Toggle left sidebar | `Ctrl+B` |
| Toggle right sidebar | `Ctrl+Shift+B` |
| Toggle terminal | `` Ctrl+` `` |
| Toggle compact mode | `Ctrl+Shift+C` |
| Fullscreen focused panel | `F11` |
| Exit fullscreen | `Escape` |
### Navigation
| Action | Binding |
|--------|---------|
| Focus Claude panel | `Ctrl+1` |
| Focus Editor | `Ctrl+2` |
| Focus Terminal | `Ctrl+3` |
| Focus sidebar | `Ctrl+0` |
| Next tab (in tabbed panels) | `Ctrl+Tab` |
| Previous tab | `Ctrl+Shift+Tab` |
| Close current tab/editor | `Ctrl+W` |
### Git
| Action | Binding |
|--------|---------|
| Open Git panel | `Ctrl+Shift+G` |
| Stage file | `Ctrl+Enter` (in git view) |
| Unstage file | `Ctrl+Backspace` (in git view) |
### Search & Problems
| Action | Binding |
|--------|---------|
| Find in file | `Ctrl+F` |
| Find in project | `Ctrl+Shift+F` |
| Go to problems | `Ctrl+Shift+M` |
| Next problem | `F8` |
| Previous problem | `Shift+F8` |
### Editor
| Action | Binding |
|--------|---------|
| Save | `Ctrl+S` |
| Undo | `Ctrl+Z` |
| Redo | `Ctrl+Shift+Z` |
| Go to line | `Ctrl+G` |
---
## Panel Communication
Panels should feel connected, like a normal IDE.
### File Navigation
```
Sidebar file click
Workspace appears (if hidden)
Editor tab focused
File loaded in Editor
```
### Problems/TODOs Navigation
```
Click problem/todo item
Workspace appears (if hidden)
Editor tab focused
File opened at specific line
Line highlighted/scrolled into view
```
### Claude Diff Flow
```
Claude proposes file changes
Workspace appears
Diff tab focused
Changes displayed with Accept/Reject
├─► Accept: Apply changes, optionally close diff
└─► Reject: Discard, close diff
```
### Git File Actions
```
Click file in Git tab
Workspace appears
Diff tab shows unstaged changes
Stage/unstage from diff view
```
---
## Implementation Notes
### Recommended Textual Widgets
| Component | Widget |
|-----------|--------|
| File browser | `DirectoryTree` |
| Claude output | `Markdown` or `RichLog` (for streaming) |
| Editor | `TextArea` (syntax highlighting built-in) |
| Tabbed panels | `TabbedContent`, `TabPane` |
| Panel switching | `ContentSwitcher` |
| Problems/TODOs list | `ListView` with `ListItem` |
| Git graph | `RichLog` or custom canvas widget |
| Command palette | `CommandPalette` (built-in) |
### Background Tasks
Use Textual's `@work` decorator for:
- Git status refresh
- Linter execution
- TODO scanning
- Jira CLI calls
```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)
```
### State Management
**Core principle**: Hiding is not closing. All panels persist state when hidden.
```python
class IDEApp(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)
```
**Panel visibility pattern** — toggle `display`, don't destroy:
```python
def toggle_workspace(self, visible: bool) -> None:
workspace = self.query_one("#panel-workspace")
workspace.display = visible # Retains all child state
# Adjust Claude panel height
claude = self.query_one("#panel-claude")
claude.styles.height = "40%" if visible else "100%"
```
**State to preserve per panel:**
| Panel | Preserved State |
|-------|-----------------|
| Editor | Open files, cursor positions, scroll, unsaved changes, undo history |
| Diff | Current diff content, scroll position, accept/reject state |
| Terminal | PTY session, command history, output buffer, working directory |
| Sidebar tabs | Scroll position, expanded/collapsed sections, selection |
| Context tabs | Scroll position, selected item |
| Git views | Expanded sections, selected files |
---
## Future Considerations
- **Session persistence**: Remember open files, panel sizes, last git state
- **Multiple projects**: Workspace switcher
- **Claude history**: Browse past conversations
- **Custom themes**: User-selectable color schemes
- **Plugin system**: User-defined panels/integrations
+111
View File
@@ -0,0 +1,111 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "clide"
version = "0.1.0"
description = "A TUI CLI IDE for Claude Code CLI"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: User Interfaces",
]
dependencies = [
"textual>=0.50.0",
"pyte>=0.8.0",
"typer>=0.12.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"pluggy>=1.4.0",
"rich>=13.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-textual-snapshot>=0.4.0",
"pytest-cov>=4.0.0",
"mypy>=1.8.0",
"ruff>=0.3.0",
"pre-commit>=3.0.0",
]
build = [
"pyinstaller>=6.0.0",
]
[project.scripts]
clide = "clide.cli:app"
[project.entry-points."clide.extensions"]
# Built-in extensions registered here
# example = "clide.extensions.builtin.example:ExampleExtension"
[tool.hatch.build.targets.wheel]
packages = ["src/clide"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = [
"-v",
"--tb=short",
]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
exclude = ["tests", "dist", "build"]
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"PTH", # flake8-use-pathlib
"ASYNC", # flake8-async
]
ignore = [
"E501", # line too long (handled by formatter)
]
[tool.ruff.lint.isort]
known-first-party = ["clide"]
[tool.coverage.run]
source = ["src/clide"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
+3
View File
@@ -0,0 +1,3 @@
"""Clide - A TUI CLI IDE for Claude Code CLI."""
__version__ = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m clide."""
from clide.cli import app
if __name__ == "__main__":
app()
+507
View File
@@ -0,0 +1,507 @@
"""Main Textual Application for Clide."""
from pathlib import Path
from typing import ClassVar
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Footer, Header
from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings
from clide.themes.registry import get_all_themes, get_theme
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
from clide.widgets.panels.sidebar import SidebarPanel
from clide.widgets.panels.workspace import WorkspacePanel
class ClideApp(App[None]):
"""Clide TUI Application - Claude Code IDE.
Panel architecture:
- Sidebar (left): Files, Git, Tree tabs + branch status
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
- Context (right): Problems, TODOs, Jira tabs
Workspace is hidden by default. Claude takes full height when
workspace is hidden, 40% when visible.
"""
TITLE = "Clide"
SUB_TITLE = "Claude Code IDE"
CSS: ClassVar[str] = """
/* Main layout */
Screen {
layout: horizontal;
}
#main-container {
width: 100%;
height: 100%;
layout: horizontal;
}
#center-column {
width: 1fr;
height: 100%;
layout: vertical;
}
/* Panel styling */
SidebarPanel {
width: 20%;
min-width: 25;
}
ContextPanel {
width: 25%;
min-width: 30;
}
/* Workspace + Claude layout */
WorkspacePanel {
height: 60%;
}
WorkspacePanel.hidden {
display: none;
}
ClaudePanel {
height: 100%;
}
ClaudePanel.with-workspace {
height: 40%;
}
/* Compact mode - applied when .compact class is on #main-container */
#main-container.compact SidebarPanel {
display: none;
}
#main-container.compact ContextPanel {
display: none;
}
/* Fullscreen mode */
.fullscreen {
dock: top;
width: 100%;
height: 100%;
layer: fullscreen;
}
"""
# VSCode-style keybindings
BINDINGS: ClassVar[list[Binding]] = [
# Global
Binding("ctrl+q", "quit", "Quit"),
Binding("ctrl+shift+p", "command_palette", "Commands"),
Binding("ctrl+p", "quick_open", "Quick Open"),
Binding("ctrl+b", "toggle_sidebar", "Toggle Sidebar"),
Binding("ctrl+shift+b", "toggle_context", "Toggle Context"),
Binding("ctrl+`", "toggle_terminal", "Toggle Terminal"),
Binding("ctrl+shift+c", "toggle_compact", "Compact Mode"),
Binding("f11", "toggle_fullscreen", "Fullscreen"),
Binding("escape", "escape", "Escape", show=False),
# Navigation
Binding("ctrl+1", "focus_claude", "Focus Claude", show=False),
Binding("ctrl+2", "focus_editor", "Focus Editor", show=False),
Binding("ctrl+3", "focus_terminal", "Focus Terminal", show=False),
Binding("ctrl+0", "focus_sidebar", "Focus Sidebar", show=False),
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
# Git
Binding("ctrl+shift+g", "open_git", "Git", show=False),
# Problems
Binding("ctrl+shift+m", "open_problems", "Problems", show=False),
Binding("f8", "next_problem", "Next Problem", show=False),
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
# Editor
Binding("ctrl+s", "save_file", "Save", show=False),
Binding("ctrl+g", "goto_line", "Go to Line", show=False),
# Theme
Binding("ctrl+k ctrl+t", "select_theme", "Select Theme", show=False),
]
# Reactive state
current_file: reactive[Path | None] = reactive(None)
workspace_visible: reactive[bool] = reactive(False)
compact_mode: reactive[bool] = reactive(False)
fullscreen_panel: reactive[str | None] = reactive(None)
def __init__(
self,
workdir: Path | None = None,
settings: ClideSettings | None = None,
test_mode: bool = False,
) -> None:
super().__init__()
self.workdir = workdir or Path.cwd()
self.settings = settings or ClideSettings()
self._test_mode = test_mode
# Extension manager
self.extension_manager = ExtensionManager()
# Controllers
self.git_controller = GitController(self.workdir)
self.editor_controller = EditorController()
self.diff_controller = DiffController(self.workdir)
self.problems_controller = ProblemsController(self.workdir)
self.todos_controller = TodosController(self.workdir)
self.jira_controller = JiraController(
enabled=self.settings.jira_enabled,
)
# Register themes
self._register_themes()
def _register_themes(self) -> None:
"""Register all themes with Textual."""
for theme_meta in get_all_themes():
theme_def = get_theme(theme_meta.name)
if theme_def:
self.register_theme(theme_def.to_textual_theme())
# Set initial theme
self.theme = self.settings.theme
def compose(self) -> ComposeResult:
"""Create the main layout."""
yield Header()
with Horizontal(id="main-container"):
# Left sidebar
yield SidebarPanel(workdir=self.workdir)
# Center column with workspace and claude
with Vertical(id="center-column"):
yield WorkspacePanel(workdir=self.workdir)
yield ClaudePanel(
workdir=self.workdir,
auto_start=not self._test_mode,
)
# Right context panel
yield ContextPanel(
jira_enabled=self.settings.jira_enabled,
)
yield Footer()
async def on_mount(self) -> None:
"""Initialize application on mount."""
# Load extensions
self.extension_manager.load_extensions()
await self.extension_manager.trigger_app_startup(self)
# Initial data refresh
await self._refresh_git()
await self._refresh_problems()
await self._refresh_todos()
if self.settings.jira_enabled:
await self._refresh_jira()
# Focus Claude panel
self.action_focus_claude()
# Reactive watchers
def watch_workspace_visible(self, visible: bool) -> None:
"""Update panels when workspace visibility changes."""
workspace = self.query_one(WorkspacePanel)
claude = self.query_one(ClaudePanel)
workspace.visible = visible
claude.workspace_visible = visible
def watch_compact_mode(self, compact: bool) -> None:
"""Toggle compact mode class."""
container = self.query_one("#main-container")
if compact:
container.add_class("compact")
else:
container.remove_class("compact")
# Data refresh methods
async def _refresh_git(self) -> None:
"""Refresh git status."""
status = await self.git_controller.get_status()
if status:
sidebar = self.query_one(SidebarPanel)
sidebar.update_git_status(status.staged, status.unstaged)
sidebar.current_branch = status.branch
branches = await self.git_controller.get_branches()
if branches:
sidebar = self.query_one(SidebarPanel)
sidebar.update_branches([b.name for b in branches])
commits = await self.git_controller.get_log(limit=50)
if commits:
sidebar = self.query_one(SidebarPanel)
sidebar.update_git_graph(commits)
async def _refresh_problems(self) -> None:
"""Refresh linter problems."""
problems = await self.problems_controller.run_all()
context = self.query_one(ContextPanel)
context.update_problems(problems)
async def _refresh_todos(self) -> None:
"""Refresh TODOs."""
todos = await self.todos_controller.scan()
context = self.query_one(ContextPanel)
context.update_todos(todos)
async def _refresh_jira(self) -> None:
"""Refresh Jira content."""
context = self.query_one(ContextPanel)
context.set_jira_loading()
content = await self.jira_controller.get_content()
if content:
context.update_jira(content)
else:
context.set_jira_error("Failed to load Jira content")
# Action methods
def action_toggle_sidebar(self) -> None:
"""Toggle sidebar visibility."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = not sidebar.visible
def action_toggle_context(self) -> None:
"""Toggle context panel visibility."""
context = self.query_one(ContextPanel)
context.visible = not context.visible
def action_toggle_terminal(self) -> None:
"""Toggle terminal (shows workspace with terminal tab)."""
workspace = self.query_one(WorkspacePanel)
if self.workspace_visible and workspace.active_tab == "terminal":
self.workspace_visible = False
else:
workspace.show_terminal()
self.workspace_visible = True
def action_toggle_compact(self) -> None:
"""Toggle compact mode."""
self.compact_mode = not self.compact_mode
def action_toggle_fullscreen(self) -> None:
"""Toggle fullscreen for focused panel."""
# TODO: Implement fullscreen toggle
pass
def action_escape(self) -> None:
"""Handle escape key."""
if self.fullscreen_panel:
self.fullscreen_panel = None
elif self.workspace_visible:
workspace = self.query_one(WorkspacePanel)
if not workspace.has_unsaved_changes():
self.workspace_visible = False
def action_focus_claude(self) -> None:
"""Focus Claude panel."""
claude = self.query_one(ClaudePanel)
claude.focus_terminal()
def action_focus_editor(self) -> None:
"""Focus editor."""
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.focus_tab("editor")
def action_focus_terminal(self) -> None:
"""Focus terminal."""
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.show_terminal()
def action_focus_sidebar(self) -> None:
"""Focus sidebar."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = True
sidebar.focus()
def action_close_tab(self) -> None:
"""Close current tab/editor."""
# TODO: Implement tab closing
pass
def action_open_git(self) -> None:
"""Open git panel."""
sidebar = self.query_one(SidebarPanel)
sidebar.visible = True
sidebar.focus_tab("sidebar-git")
def action_open_problems(self) -> None:
"""Open problems panel."""
context = self.query_one(ContextPanel)
context.visible = True
context.focus_problems()
def action_next_problem(self) -> None:
"""Go to next problem."""
# TODO: Implement problem navigation
pass
def action_prev_problem(self) -> None:
"""Go to previous problem."""
# TODO: Implement problem navigation
pass
def action_save_file(self) -> None:
"""Save current file."""
workspace = self.query_one(WorkspacePanel)
# EditorPane handles save internally
pass
def action_goto_line(self) -> None:
"""Go to line dialog."""
# TODO: Implement go to line
pass
def action_quick_open(self) -> None:
"""Quick file open."""
# TODO: Implement quick open
pass
def action_select_theme(self) -> None:
"""Open theme selector."""
# TODO: Implement theme selector via command palette
pass
# Event handlers for panel messages
async def on_sidebar_panel_file_selected(
self,
event: SidebarPanel.FileSelected,
) -> None:
"""Handle file selection from sidebar."""
self.current_file = event.path
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(event.path)
async def on_sidebar_panel_git_file_selected(
self,
event: SidebarPanel.GitFileSelected,
) -> None:
"""Handle git file selection - show diff."""
diff = await self.diff_controller.get_file_diff(
str(event.path),
staged=event.staged,
)
if diff:
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.show_diff(diff)
async def on_sidebar_panel_branch_changed(
self,
event: SidebarPanel.BranchChanged,
) -> None:
"""Handle branch change."""
success = await self.git_controller.checkout_branch(event.branch)
if success:
await self._refresh_git()
async def on_context_panel_problem_clicked(
self,
event: ContextPanel.ProblemClicked,
) -> None:
"""Handle problem click - open file at line."""
problem = event.problem
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(problem.file_path, line=problem.line)
async def on_context_panel_todo_clicked(
self,
event: ContextPanel.TodoClicked,
) -> None:
"""Handle TODO click - open file at line."""
item = event.item
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(item.file_path, line=item.line)
async def on_context_panel_jira_refresh_requested(
self,
event: ContextPanel.JiraRefreshRequested,
) -> None:
"""Handle Jira refresh request."""
await self._refresh_jira()
async def on_workspace_panel_file_saved(
self,
event: WorkspacePanel.FileSaved,
) -> None:
"""Handle file save - refresh problems and git."""
await self._refresh_git()
await self._refresh_problems()
async def on_workspace_panel_diff_accepted(
self,
event: WorkspacePanel.DiffAccepted,
) -> None:
"""Handle diff accept."""
await self.diff_controller.accept_proposal(event.file_path)
await self._refresh_git()
await self._refresh_problems()
async def on_workspace_panel_diff_rejected(
self,
event: WorkspacePanel.DiffRejected,
) -> None:
"""Handle diff reject."""
await self.diff_controller.reject_proposal(event.file_path)
workspace = self.query_one(WorkspacePanel)
workspace.clear_diff()
async def on_workspace_panel_command_submitted(
self,
event: WorkspacePanel.CommandSubmitted,
) -> None:
"""Handle terminal command - run and show output."""
from clide.services.process_service import ProcessService
workspace = self.query_one(WorkspacePanel)
result = await ProcessService.run_async(
event.command,
cwd=self.workdir,
shell=True,
)
if result.stdout:
workspace.write_terminal_output(result.stdout)
if result.stderr:
workspace.write_terminal_error(result.stderr)
# Refresh after command
await self._refresh_git()
def on_claude_panel_claude_started(
self,
event: ClaudePanel.ClaudeStarted,
) -> None:
"""Handle Claude Code started."""
self.notify("Claude Code started", severity="information")
def on_claude_panel_claude_exited(
self,
event: ClaudePanel.ClaudeExited,
) -> None:
"""Handle Claude Code exited."""
if event.return_code != 0:
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
+54
View File
@@ -0,0 +1,54 @@
"""Typer CLI entry point for Clide."""
from pathlib import Path
from typing import Annotated, Optional
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[
Optional[bool],
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
] = None,
workdir: Annotated[
Optional[Path],
typer.Option("--workdir", "-w", help="Working directory to open"),
] = None,
) -> None:
"""Launch Clide TUI application."""
if ctx.invoked_subcommand is None:
from clide.app import ClideApp
app_instance = ClideApp(workdir=workdir)
app_instance.run()
@app.command()
def config() -> None:
"""Open configuration editor."""
typer.echo("Configuration editor not yet implemented")
@app.command()
def extensions() -> None:
"""List installed extensions."""
typer.echo("Extension manager not yet implemented")
+20
View File
@@ -0,0 +1,20 @@
"""Domain controllers for Clide."""
from clide.controllers.base import controller, ControllerMixin
from clide.controllers.git import GitController
from clide.controllers.editor import EditorController
from clide.controllers.diff import DiffController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
from clide.controllers.jira import JiraController
__all__ = [
"controller",
"ControllerMixin",
"GitController",
"EditorController",
"DiffController",
"ProblemsController",
"TodosController",
"JiraController",
]
+104
View File
@@ -0,0 +1,104 @@
"""Base controller utilities using decorator pattern."""
from functools import wraps
from typing import TYPE_CHECKING, Callable, TypeVar
from textual.message import Message
if TYPE_CHECKING:
from textual.app import App
T = TypeVar("T")
def controller(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([l for l in current_hunk_lines if l.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([l for l in current_hunk_lines if l.change_type != ChangeType.ADDED]),
))
elif line.startswith(" "):
old_num = old_start + len([l for l in current_hunk_lines if l.change_type != ChangeType.ADDED])
new_num = new_start + len([l for l in current_hunk_lines if l.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,),
)
+217
View File
@@ -0,0 +1,217 @@
"""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
from clide.services.file_service import FileService
@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:
if self._state.active_buffer_index >= len(self._state.buffers):
self._state.active_buffer_index = (
len(self._state.buffers) - 1 if self._state.buffers else None
)
return True
async def save_file(self, path: Path | None = None) -> bool:
"""Save a file.
Args:
path: File path (defaults to active buffer)
Returns:
True if saved successfully
"""
if path:
buffer = self._state.get_buffer_by_path(path)
else:
buffer = self.active_buffer
if not buffer:
return False
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
return True
async def save_all(self) -> int:
"""Save all modified buffers.
Returns:
Number of files saved
"""
count = 0
for buffer in self._state.buffers:
if buffer.is_modified:
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
count += 1
return count
def update_content(self, path: Path, content: str) -> None:
"""Update buffer content.
Args:
path: File path
content: New content
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.content = content
buffer.is_modified = True
def update_cursor(self, path: Path, line: int, column: int) -> None:
"""Update cursor position.
Args:
path: File path
line: Line number (0-indexed)
column: Column number (0-indexed)
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.cursor = CursorPosition(line=line, column=column)
def set_active_by_index(self, index: int) -> None:
"""Set active buffer by index.
Args:
index: Buffer index
"""
if 0 <= index < len(self._state.buffers):
self._state.active_buffer_index = index
def _set_active_buffer(self, buffer: FileBuffer) -> None:
"""Set active buffer."""
try:
index = self._state.buffers.index(buffer)
self._state.active_buffer_index = index
except ValueError:
pass
+201
View File
@@ -0,0 +1,201 @@
"""Git controller for managing git operations."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.git import GitBranch, GitCommit, GitStatus
from clide.services.git_service import GitService
@controller
class GitController:
"""Controller for git operations."""
class StatusUpdated(Message):
"""Emitted when git status changes."""
def __init__(self, status: GitStatus) -> None:
self.status = status
super().__init__()
class BranchesUpdated(Message):
"""Emitted when branches list changes."""
def __init__(self, branches: list[GitBranch]) -> None:
self.branches = branches
super().__init__()
class LogUpdated(Message):
"""Emitted when commit log is refreshed."""
def __init__(self, commits: list[GitCommit]) -> None:
self.commits = commits
super().__init__()
class FileStaged(Message):
"""Emitted when a file is staged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
class FileUnstaged(Message):
"""Emitted when a file is unstaged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
def __init__(self, repo_path: Path) -> None:
self._service = GitService(repo_path)
self._status: GitStatus | None = None
self._branches: list[GitBranch] = []
self._commits: list[GitCommit] = []
@property
def status(self) -> GitStatus | None:
"""Get current git status."""
return self._status
@property
def branches(self) -> list[GitBranch]:
"""Get list of branches."""
return self._branches
@property
def commits(self) -> list[GitCommit]:
"""Get commit log."""
return self._commits
@property
def current_branch(self) -> str:
"""Get current branch name."""
return self._status.branch if self._status else "unknown"
async def refresh_status(self) -> GitStatus:
"""Refresh git status.
Returns:
Updated GitStatus
"""
self._status = await self._service.get_status()
return self._status
async def get_status(self) -> GitStatus | None:
"""Get git status (refreshes if needed)."""
return await self.refresh_status()
async def refresh_branches(self) -> list[GitBranch]:
"""Refresh branches list.
Returns:
Updated list of branches
"""
self._branches = await self._service.get_branches()
return self._branches
async def get_branches(self) -> list[GitBranch]:
"""Get branches list (refreshes if needed)."""
return await self.refresh_branches()
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
"""Refresh commit log.
Args:
max_count: Maximum commits to fetch
Returns:
Updated list of commits
"""
self._commits = await self._service.get_log(max_count)
return self._commits
async def get_log(self, limit: int = 50) -> list[GitCommit]:
"""Get commit log (refreshes if needed)."""
return await self.refresh_log(limit)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
success = await self._service.stage_file(path)
if success:
await self.refresh_status()
return success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
success = await self._service.unstage_file(path)
if success:
await self.refresh_status()
return success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path
Returns:
True if successful
"""
success = await self._service.discard_changes(path)
if success:
await self.refresh_status()
return success
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name
Returns:
True if successful
"""
success = await self._service.checkout_branch(branch)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def create_branch(self, name: str) -> bool:
"""Create and checkout a new branch.
Args:
name: New branch name
Returns:
True if successful
"""
success = await self._service.create_branch(name)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def get_file_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff string
"""
return await self._service.get_diff(path, staged)
+158
View File
@@ -0,0 +1,158 @@
"""Jira controller for Jira CLI integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.services.process_service import ProcessService
@controller
class JiraController:
"""Controller for Jira CLI integration."""
class JiraOutputUpdated(Message):
"""Emitted when Jira output is updated."""
def __init__(self, output: str) -> None:
self.output = output
super().__init__()
class JiraError(Message):
"""Emitted when Jira command fails."""
def __init__(self, error: str) -> None:
self.error = error
super().__init__()
def __init__(
self,
project_path: Path | None = None,
jira_cli_path: str = "jira",
enabled: bool = False,
) -> None:
self._project_path = project_path or Path.cwd()
self._jira_cli = jira_cli_path
self._enabled = enabled
self._last_output: str = ""
self._last_error: str = ""
@property
def enabled(self) -> bool:
"""Check if Jira integration is enabled."""
return self._enabled
@property
def last_output(self) -> str:
"""Get last Jira output."""
return self._last_output
@property
def last_error(self) -> str:
"""Get last error message."""
return self._last_error
def enable(self) -> None:
"""Enable Jira integration."""
self._enabled = True
def disable(self) -> None:
"""Disable Jira integration."""
self._enabled = False
async def run_command(self, *args: str) -> str:
"""Run a Jira CLI command.
Args:
*args: Command arguments
Returns:
Command output
"""
if not self._enabled:
return "Jira integration is disabled"
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, *args)
if result.success:
self._last_output = result.stdout
self._last_error = ""
return result.stdout
else:
self._last_error = result.stderr
return f"Error: {result.stderr}"
async def list_issues(self, project: str | None = None) -> str:
"""List Jira issues.
Args:
project: Optional project key
Returns:
Formatted issue list
"""
args = ["issue", "list"]
if project:
args.extend(["--project", project])
return await self.run_command(*args)
async def get_issue(self, issue_key: str) -> str:
"""Get a specific issue.
Args:
issue_key: Issue key (e.g., PROJ-123)
Returns:
Issue details
"""
return await self.run_command("issue", "view", issue_key)
async def get_my_issues(self) -> str:
"""Get issues assigned to current user.
Returns:
Formatted issue list
"""
return await self.run_command("issue", "list", "--assignee", "@me")
async def get_sprint_issues(self) -> str:
"""Get issues in current sprint.
Returns:
Formatted issue list
"""
return await self.run_command("sprint", "list", "--current")
async def refresh(self) -> str:
"""Refresh Jira data (get my issues).
Returns:
Updated output
"""
return await self.get_my_issues()
async def check_available(self) -> bool:
"""Check if Jira CLI is available.
Returns:
True if available
"""
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, "--version")
return result.success
async def get_content(self) -> str | None:
"""Get Jira content for display.
Returns:
Markdown content or None
"""
if not self._enabled:
return None
try:
return await self.get_my_issues()
except Exception:
return None
+180
View File
@@ -0,0 +1,180 @@
"""Problems controller for linter integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
from clide.services.linter_service import LinterService
@controller
class ProblemsController:
"""Controller for problems/diagnostics from linters."""
class ProblemsUpdated(Message):
"""Emitted when problems list is updated."""
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
self.problems = problems
self.summary = summary
super().__init__()
class ProblemSelected(Message):
"""Emitted when a problem is selected."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
self._service = LinterService(project_path)
self._linters = linters or ["ruff"]
self._state = ProblemsState()
@property
def state(self) -> ProblemsState:
"""Get problems state."""
return self._state
@property
def problems(self) -> list[Problem]:
"""Get list of problems."""
return self._state.problems
@property
def summary(self) -> ProblemsSummary:
"""Get problems summary."""
return self._state.summary
@property
def error_count(self) -> int:
"""Get error count."""
return self._state.summary.errors
@property
def warning_count(self) -> int:
"""Get warning count."""
return self._state.summary.warnings
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
"""Refresh problems from all linters.
Returns:
Tuple of (problems, summary)
"""
problems, summary = await self._service.run_all(self._linters)
self._state.problems = problems
self._state.summary = summary
return problems, summary
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
"""Filter problems by severity.
Args:
severity: Severity to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_severity = severity
if severity is None:
return self._state.problems
return [p for p in self._state.problems if p.severity == severity]
def filter_by_source(self, source: str | None) -> list[Problem]:
"""Filter problems by source linter.
Args:
source: Source to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_source = source
if source is None:
return self._state.problems
return [p for p in self._state.problems if p.source == source]
def get_problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file.
Args:
path: File path
Returns:
List of problems for that file
"""
return self._state.problems_for_file(path)
def select_problem(self, index: int) -> Problem | None:
"""Select a problem by index.
Args:
index: Problem index
Returns:
Selected problem or None
"""
if 0 <= index < len(self._state.problems):
self._state.selected_index = index
return self._state.problems[index]
return None
def next_problem(self) -> Problem | None:
"""Select next problem.
Returns:
Next problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = 0
else:
self._state.selected_index = (
self._state.selected_index + 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def prev_problem(self) -> Problem | None:
"""Select previous problem.
Returns:
Previous problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = len(self._state.problems) - 1
else:
self._state.selected_index = (
self._state.selected_index - 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def clear(self) -> None:
"""Clear all problems."""
self._state.problems = []
self._state.summary = ProblemsSummary()
self._state.selected_index = None
async def run_all(self) -> list[Problem]:
"""Run all linters and return problems.
Returns:
List of problems
"""
problems, _ = await self.refresh()
return problems
+138
View File
@@ -0,0 +1,138 @@
"""TODOs controller for tracking TODO comments."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
from clide.services.todo_scanner import TodoScanner
@controller
class TodosController:
"""Controller for TODO/FIXME comment tracking."""
class TodosUpdated(Message):
"""Emitted when TODOs list is updated."""
def __init__(self, items: list[TodoItem], summary: TodosSummary) -> None:
self.items = items
self.summary = summary
super().__init__()
class TodoSelected(Message):
"""Emitted when a TODO is selected."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
def __init__(self, project_path: Path) -> None:
self._scanner = TodoScanner(project_path)
self._state = TodosState()
@property
def state(self) -> TodosState:
"""Get TODOs state."""
return self._state
@property
def items(self) -> list[TodoItem]:
"""Get list of TODO items."""
return self._state.items
@property
def summary(self) -> TodosSummary:
"""Get TODOs summary."""
return self._state.summary
@property
def total_count(self) -> int:
"""Get total TODO count."""
return self._state.summary.total
async def refresh(self) -> tuple[list[TodoItem], TodosSummary]:
"""Refresh TODOs from project.
Returns:
Tuple of (items, summary)
"""
items, summary = await self._scanner.scan()
self._state.items = items
self._state.summary = summary
return items, summary
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
"""Filter TODOs by type.
Args:
todo_type: Type to filter by, or None for all
Returns:
Filtered list of TODOs
"""
self._state.filter_type = todo_type
if todo_type is None:
return self._state.items
return self._state.items_by_type(todo_type)
def get_items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODOs for a specific file.
Args:
path: File path
Returns:
List of TODOs for that file
"""
return self._state.items_for_file(path)
def select_item(self, index: int) -> TodoItem | None:
"""Select a TODO by index.
Args:
index: Item index
Returns:
Selected item or None
"""
if 0 <= index < len(self._state.items):
self._state.selected_index = index
return self._state.items[index]
return None
def toggle_group_by_file(self) -> bool:
"""Toggle grouping by file.
Returns:
New group_by_file value
"""
self._state.group_by_file = not self._state.group_by_file
return self._state.group_by_file
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
"""Get TODOs grouped by file.
Returns:
Dictionary mapping file paths to TODO lists
"""
grouped: dict[Path, list[TodoItem]] = {}
for item in self._state.items:
if item.file_path not in grouped:
grouped[item.file_path] = []
grouped[item.file_path].append(item)
return grouped
async def scan(self) -> list[TodoItem]:
"""Scan for TODOs and return items.
Returns:
List of TODO items
"""
items, _ = await self.refresh()
return items
+6
View File
@@ -0,0 +1,6 @@
"""Extension system for Clide using pluggy."""
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
from clide.extensions.manager import ExtensionManager
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
+1
View File
@@ -0,0 +1 @@
"""Built-in extensions for Clide."""
+77
View File
@@ -0,0 +1,77 @@
"""Pluggy hook specifications for Clide extensions."""
from typing import TYPE_CHECKING, Any
import pluggy
if TYPE_CHECKING:
from textual.app import App
from textual.widget import Widget
hookspec = pluggy.HookspecMarker("clide")
hookimpl = pluggy.HookimplMarker("clide")
class ClideHookSpec:
"""Hook specifications for Clide extensions.
Extensions implement these hooks to extend functionality.
"""
@hookspec
def clide_register_panel(self) -> dict[str, Any] | None:
"""Register a custom panel for the UI.
Returns:
Dictionary with panel configuration:
- name: Panel identifier
- widget: Widget class to instantiate
- position: "left", "right", or "bottom"
- keybinding: Optional keyboard shortcut
"""
@hookspec
def clide_register_commands(self) -> list[dict[str, Any]] | None:
"""Register custom commands for the command palette.
Returns:
List of command dictionaries:
- name: Command display name
- callback: Async callable to execute
- description: Help text
"""
@hookspec
def clide_on_app_startup(self, app: "App[object]") -> None:
"""Called when the application starts.
Args:
app: The Clide application instance
"""
@hookspec
def clide_on_app_shutdown(self, app: "App[object]") -> None:
"""Called when the application is shutting down.
Args:
app: The Clide application instance
"""
@hookspec
def clide_on_file_open(self, path: str) -> None:
"""Called when a file is opened in the file browser.
Args:
path: Absolute path to the opened file
"""
@hookspec
def clide_modify_widget(self, widget: "Widget") -> "Widget":
"""Modify a widget before it's mounted.
Args:
widget: The widget about to be mounted
Returns:
The modified (or original) widget
"""
+80
View File
@@ -0,0 +1,80 @@
"""Extension manager for loading and managing Clide extensions."""
from importlib.metadata import entry_points
from typing import TYPE_CHECKING
import pluggy
from clide.extensions.hookspecs import ClideHookSpec
if TYPE_CHECKING:
from textual.app import App
EXTENSION_NAMESPACE = "clide.extensions"
class ExtensionManager:
"""Manages loading and lifecycle of Clide extensions."""
def __init__(self) -> None:
self._pm = pluggy.PluginManager("clide")
self._pm.add_hookspecs(ClideHookSpec)
self._loaded: list[str] = []
@property
def hook(self) -> pluggy.HookRelay:
"""Access the hook relay for calling hooks."""
return self._pm.hook
def load_extensions(self) -> None:
"""Load all extensions from entry points."""
eps = entry_points(group=EXTENSION_NAMESPACE)
for ep in eps:
try:
plugin = ep.load()
self._pm.register(plugin, name=ep.name)
self._loaded.append(ep.name)
except Exception as e:
# Log but don't crash on extension load failure
print(f"Failed to load extension {ep.name}: {e}")
def register_plugin(self, plugin: object, name: str) -> None:
"""Manually register a plugin instance.
Args:
plugin: Plugin object with hookimpl methods
name: Unique name for the plugin
"""
self._pm.register(plugin, name=name)
self._loaded.append(name)
def unregister_plugin(self, name: str) -> None:
"""Unregister a plugin by name.
Args:
name: Name of the plugin to unregister
"""
plugin = self._pm.get_plugin(name)
if plugin:
self._pm.unregister(plugin)
self._loaded.remove(name)
def list_extensions(self) -> list[str]:
"""Get list of loaded extension names."""
return self._loaded.copy()
async def trigger_app_startup(self, app: "App[object]") -> None:
"""Trigger startup hooks for all extensions.
Args:
app: The Clide application instance
"""
self.hook.clide_on_app_startup(app=app)
async def trigger_app_shutdown(self, app: "App[object]") -> None:
"""Trigger shutdown hooks for all extensions.
Args:
app: The Clide application instance
"""
self.hook.clide_on_app_shutdown(app=app)
+1
View File
@@ -0,0 +1 @@
"""Shared utility functions and helpers for Clide."""
+54
View File
@@ -0,0 +1,54 @@
"""Pydantic models for Clide."""
from clide.models.config import ClideSettings, PanelConfig
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitGraph,
GitStatus,
)
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
__all__ = [
# Config
"ClideSettings",
"PanelConfig",
# Git
"ChangeStatus",
"GitBranch",
"GitChange",
"GitCommit",
"GitGraph",
"GitStatus",
# Editor
"CursorPosition",
"EditorState",
"FileBuffer",
"Selection",
# Diff
"ChangeType",
"DiffContent",
"DiffHunk",
"DiffLine",
"DiffViewState",
# Problems
"Problem",
"ProblemsSummary",
"ProblemsState",
"Severity",
# Todos
"TodoItem",
"TodosSummary",
"TodosState",
"TodoType",
# Theme
"ThemeColors",
"ThemeDefinition",
"ThemeMetadata",
]
+94
View File
@@ -0,0 +1,94 @@
"""Configuration models using Pydantic Settings."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from pydantic_settings import BaseSettings, SettingsConfigDict
class ClideSettings(BaseSettings):
"""Main application settings loaded from environment and config files."""
model_config = SettingsConfigDict(
env_prefix="CLIDE_",
env_file=".config/.env",
env_file_encoding="utf-8",
extra="ignore",
)
# Appearance
theme: str = "summer-night" # Default to Summer Night
# Paths
claude_path: str = "claude"
default_workdir: Path = Path.cwd()
jira_cli_path: str = "jira"
# Behavior
auto_save: bool = True
confirm_exit: bool = True
# Integrations
jira_enabled: bool = False
confluence_enabled: bool = False
imagin_enabled: bool = False
# Linters
linters: list[str] = ["ruff"]
class PanelConfig(BaseModel):
"""Panel visibility and layout configuration."""
model_config = ConfigDict(strict=True, frozen=True)
sidebar_visible: bool = True
context_visible: bool = True
workspace_visible: bool = False # Hidden by default
sidebar_width_percent: int = 20
context_width_percent: int = 25
class KeybindingsConfig(BaseModel):
"""Keybinding configuration."""
model_config = ConfigDict(strict=True)
# Global
command_palette: str = "ctrl+shift+p"
quick_open: str = "ctrl+p"
toggle_sidebar: str = "ctrl+b"
toggle_context: str = "ctrl+shift+b"
toggle_terminal: str = "ctrl+`"
toggle_compact: str = "ctrl+shift+c"
fullscreen: str = "f11"
# Navigation
focus_claude: str = "ctrl+1"
focus_editor: str = "ctrl+2"
focus_terminal: str = "ctrl+3"
focus_sidebar: str = "ctrl+0"
next_tab: str = "ctrl+tab"
prev_tab: str = "ctrl+shift+tab"
close_tab: str = "ctrl+w"
# Git
git_panel: str = "ctrl+shift+g"
stage_file: str = "ctrl+enter"
unstage_file: str = "ctrl+backspace"
# Search
find_in_file: str = "ctrl+f"
find_in_project: str = "ctrl+shift+f"
problems_panel: str = "ctrl+shift+m"
next_problem: str = "f8"
prev_problem: str = "shift+f8"
# Editor
save: str = "ctrl+s"
undo: str = "ctrl+z"
redo: str = "ctrl+shift+z"
go_to_line: str = "ctrl+g"
# Theme
select_theme: str = "ctrl+k ctrl+t"
+66
View File
@@ -0,0 +1,66 @@
"""Diff-related Pydantic models."""
from enum import Enum
from pydantic import BaseModel, ConfigDict
class ChangeType(str, Enum):
"""Type of change in a diff line."""
ADDED = "added"
REMOVED = "removed"
CONTEXT = "context"
HEADER = "header"
class DiffLine(BaseModel):
"""A single line in a diff."""
model_config = ConfigDict(strict=True, frozen=True)
change_type: ChangeType
content: str
old_line_num: int | None = None
new_line_num: int | None = None
class DiffHunk(BaseModel):
"""A hunk (section) of a diff."""
model_config = ConfigDict(strict=True, frozen=True)
header: str
old_start: int
old_count: int
new_start: int
new_count: int
lines: tuple[DiffLine, ...]
class DiffContent(BaseModel):
"""Complete diff for a file."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: str
old_path: str | None = None # For renames
hunks: tuple[DiffHunk, ...]
is_binary: bool = False
is_new_file: bool = False
is_deleted: bool = False
class DiffViewState(BaseModel):
"""State of the diff viewer."""
model_config = ConfigDict(strict=True)
diff: DiffContent | None = None
scroll_offset: int = 0
selected_hunk_index: int | None = None
side_by_side: bool = True
# For Claude-proposed changes
is_proposal: bool = False
accepted_hunks: set[int] = set()
rejected_hunks: set[int] = set()
+72
View File
@@ -0,0 +1,72 @@
"""Editor-related Pydantic models."""
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class CursorPosition(BaseModel):
"""Cursor position in editor."""
model_config = ConfigDict(strict=True, frozen=True)
line: int
column: int
class Selection(BaseModel):
"""Text selection range."""
model_config = ConfigDict(strict=True, frozen=True)
start: CursorPosition
end: CursorPosition
class FileBuffer(BaseModel):
"""A file buffer in the editor."""
model_config = ConfigDict(strict=True)
path: Path
content: str
language: str | None = None
is_modified: bool = False
cursor: CursorPosition = CursorPosition(line=0, column=0)
selection: Selection | None = None
scroll_offset: int = 0
@property
def filename(self) -> str:
"""Get the filename from path."""
return self.path.name
@property
def display_name(self) -> str:
"""Get display name with modification indicator."""
prefix = "" if self.is_modified else ""
return f"{prefix}{self.filename}"
class EditorState(BaseModel):
"""State of the editor panel."""
model_config = ConfigDict(strict=True)
buffers: list[FileBuffer] = []
active_buffer_index: int | None = None
recent_files: list[Path] = []
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
if self.active_buffer_index is not None and self.buffers:
return self.buffers[self.active_buffer_index]
return None
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
"""Find a buffer by its file path."""
for buffer in self.buffers:
if buffer.path == path:
return buffer
return None
+81
View File
@@ -0,0 +1,81 @@
"""Git-related Pydantic models."""
from enum import Enum
from typing import Literal
from pydantic import BaseModel, ConfigDict
class ChangeStatus(str, Enum):
"""Git file change status."""
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
RENAMED = "renamed"
COPIED = "copied"
UNTRACKED = "untracked"
IGNORED = "ignored"
UNMERGED = "unmerged"
class GitChange(BaseModel):
"""A single file change in git."""
model_config = ConfigDict(strict=True, frozen=True)
path: str
status: ChangeStatus
staged: bool
old_path: str | None = None # For renames
class GitStatus(BaseModel):
"""Current git repository status."""
model_config = ConfigDict(strict=True, frozen=True)
branch: str
ahead: int = 0
behind: int = 0
staged: tuple[GitChange, ...]
unstaged: tuple[GitChange, ...]
untracked: tuple[str, ...] = ()
has_conflicts: bool = False
class GitBranch(BaseModel):
"""Git branch information."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
is_current: bool = False
is_remote: bool = False
tracking: str | None = None
commit_hash: str | None = None
commit_message: str | None = None
class GitCommit(BaseModel):
"""Git commit information for graph view."""
model_config = ConfigDict(strict=True, frozen=True)
hash: str
short_hash: str
message: str
author: str
date: str
is_merge: bool = False
refs: tuple[str, ...] = () # branch names, tags
parents: tuple[str, ...] = ()
class GitGraph(BaseModel):
"""Git log graph data."""
model_config = ConfigDict(strict=True, frozen=True)
commits: tuple[GitCommit, ...]
branches: tuple[GitBranch, ...]
+88
View File
@@ -0,0 +1,88 @@
"""Problems (linter errors) Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class Severity(str, Enum):
"""Problem severity level."""
ERROR = "error"
WARNING = "warning"
INFO = "info"
HINT = "hint"
class Problem(BaseModel):
"""A single linter problem/diagnostic."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
end_line: int | None = None
end_column: int | None = None
severity: Severity
message: str
source: str # e.g., "ruff", "mypy", "eslint"
code: str | None = None # e.g., "E501", "W0612"
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}:{self.column}"
@property
def severity_icon(self) -> str:
"""Icon for severity level."""
icons = {
Severity.ERROR: "",
Severity.WARNING: "",
Severity.INFO: "",
Severity.HINT: "💡",
}
return icons[self.severity]
class ProblemsSummary(BaseModel):
"""Summary of problems in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
errors: int = 0
warnings: int = 0
infos: int = 0
hints: int = 0
@property
def total(self) -> int:
"""Total number of problems."""
return self.errors + self.warnings + self.infos + self.hints
@property
def display_text(self) -> str:
"""Text for tab badge."""
if self.errors:
return f"{self.errors}"
if self.warnings:
return f"{self.warnings}"
return f"{self.total}"
class ProblemsState(BaseModel):
"""State of the problems panel."""
model_config = ConfigDict(strict=True)
problems: list[Problem] = []
summary: ProblemsSummary = ProblemsSummary()
filter_severity: Severity | None = None
filter_source: str | None = None
selected_index: int | None = None
def problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file."""
return [p for p in self.problems if p.file_path == path]
+102
View File
@@ -0,0 +1,102 @@
"""Theme-related Pydantic models."""
import re
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, field_validator
if TYPE_CHECKING:
from textual.theme import Theme
def validate_hex_color(value: str) -> str:
"""Validate hex color format."""
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
raise ValueError(f"Invalid hex color: {value}")
return value.lower()
class ThemeColors(BaseModel):
"""Color definitions for a theme."""
model_config = ConfigDict(strict=True, frozen=True)
# Core colors
primary: str
secondary: str
accent: str
# Backgrounds
background: str
surface: str
panel: str
# Text
foreground: str
# Status
success: str
warning: str
error: str
@field_validator("*", mode="before")
@classmethod
def validate_colors(cls, v: str) -> str:
"""Validate all color fields are valid hex."""
return validate_hex_color(v)
class ThemeDefinition(BaseModel):
"""Complete theme definition."""
model_config = ConfigDict(strict=True)
name: str # e.g., "summer-night"
display_name: str # e.g., "Summer Night"
dark: bool # True for dark themes
colors: ThemeColors
def to_textual_colors(self) -> dict[str, str]:
"""Convert to Textual theme color dict."""
return {
"primary": self.colors.primary,
"secondary": self.colors.secondary,
"accent": self.colors.accent,
"background": self.colors.background,
"surface": self.colors.surface,
"panel": self.colors.panel,
"foreground": self.colors.foreground,
"success": self.colors.success,
"warning": self.colors.warning,
"error": self.colors.error,
}
def to_textual_theme(self) -> "Theme":
"""Convert to a Textual Theme object."""
from textual.theme import Theme
return Theme(
name=self.name,
primary=self.colors.primary,
secondary=self.colors.secondary,
accent=self.colors.accent,
background=self.colors.background,
surface=self.colors.surface,
panel=self.colors.panel,
foreground=self.colors.foreground,
success=self.colors.success,
warning=self.colors.warning,
error=self.colors.error,
dark=self.dark,
)
class ThemeMetadata(BaseModel):
"""Theme metadata for listing themes."""
model_config = ConfigDict(strict=True, frozen=True)
name: str
display_name: str
dark: bool
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
+93
View File
@@ -0,0 +1,93 @@
"""TODO comments Pydantic models."""
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, ConfigDict
class TodoType(str, Enum):
"""Type of TODO comment."""
TODO = "TODO"
FIXME = "FIXME"
HACK = "HACK"
XXX = "XXX"
NOTE = "NOTE"
BUG = "BUG"
OPTIMIZE = "OPTIMIZE"
REVIEW = "REVIEW"
class TodoItem(BaseModel):
"""A single TODO comment found in code."""
model_config = ConfigDict(strict=True, frozen=True)
file_path: Path
line: int
column: int
todo_type: TodoType
text: str
context_line: str # The full line containing the TODO
@property
def location(self) -> str:
"""Human-readable location string."""
return f"{self.file_path}:{self.line}"
@property
def type_icon(self) -> str:
"""Icon for TODO type."""
icons = {
TodoType.TODO: "",
TodoType.FIXME: "🔧",
TodoType.HACK: "",
TodoType.XXX: "",
TodoType.NOTE: "📝",
TodoType.BUG: "🐛",
TodoType.OPTIMIZE: "",
TodoType.REVIEW: "👀",
}
return icons[self.todo_type]
class TodosSummary(BaseModel):
"""Summary of TODOs in the workspace."""
model_config = ConfigDict(strict=True, frozen=True)
todo_count: int = 0
fixme_count: int = 0
hack_count: int = 0
other_count: int = 0
@property
def total(self) -> int:
"""Total number of TODOs."""
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
@property
def display_text(self) -> str:
"""Text for tab badge."""
return f"{self.total}"
class TodosState(BaseModel):
"""State of the TODOs panel."""
model_config = ConfigDict(strict=True)
items: list[TodoItem] = []
summary: TodosSummary = TodosSummary()
filter_type: TodoType | None = None
selected_index: int | None = None
group_by_file: bool = True
def items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODO items for a specific file."""
return [item for item in self.items if item.file_path == path]
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Get TODO items of a specific type."""
return [item for item in self.items if item.todo_type == todo_type]
+13
View File
@@ -0,0 +1,13 @@
"""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.todo_scanner import TodoScanner
__all__ = [
"GitService",
"LinterService",
"ProcessService",
"TodoScanner",
]
+124
View File
@@ -0,0 +1,124 @@
"""File operations service."""
from pathlib import Path
class FileService:
"""Service for file I/O operations."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
async def read_file(self, path: Path) -> str:
"""Read file contents.
Args:
path: Path to file (relative or absolute)
Returns:
File contents as string
"""
full_path = self._resolve_path(path)
return full_path.read_text(encoding="utf-8")
async def write_file(self, path: Path, content: str) -> None:
"""Write content to file.
Args:
path: Path to file
content: Content to write
"""
full_path = self._resolve_path(path)
full_path.write_text(content, encoding="utf-8")
async def file_exists(self, path: Path) -> bool:
"""Check if file exists.
Args:
path: Path to check
Returns:
True if file exists
"""
full_path = self._resolve_path(path)
return full_path.exists() and full_path.is_file()
async def get_language(self, path: Path) -> str | None:
"""Detect language from file extension.
Args:
path: File path
Returns:
Language identifier or None
"""
extension_map = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".jsx": "jsx",
".tsx": "tsx",
".html": "html",
".css": "css",
".scss": "scss",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".md": "markdown",
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".rb": "ruby",
".php": "php",
".sh": "bash",
".bash": "bash",
".sql": "sql",
".xml": "xml",
".vue": "vue",
".svelte": "svelte",
}
return extension_map.get(path.suffix.lower())
def _resolve_path(self, path: Path) -> Path:
"""Resolve path relative to project root.
Args:
path: Path to resolve
Returns:
Absolute path
"""
if path.is_absolute():
return path
return self.project_path / path
def list_directory(self, path: Path | None = None) -> list[Path]:
"""List directory contents.
Args:
path: Directory path (defaults to project root)
Returns:
List of paths in directory
"""
dir_path = self._resolve_path(path) if path else self.project_path
if not dir_path.is_dir():
return []
entries = []
for entry in sorted(dir_path.iterdir()):
# Skip hidden files and common excludes
if entry.name.startswith("."):
continue
if entry.name in ("__pycache__", "node_modules", ".git"):
continue
entries.append(entry)
# Sort: directories first, then files
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
return entries
+256
View File
@@ -0,0 +1,256 @@
"""Git operations service."""
from pathlib import Path
from clide.models.git import (
ChangeStatus,
GitBranch,
GitChange,
GitCommit,
GitStatus,
)
from clide.services.process_service import ProcessService
class GitService:
"""Service for git operations."""
def __init__(self, repo_path: Path) -> None:
self.repo_path = repo_path
self._process = ProcessService(cwd=repo_path)
async def get_status(self) -> GitStatus:
"""Get current git status.
Returns:
GitStatus with staged/unstaged changes
"""
# Get porcelain status
result = await self._process.run("git", "status", "--porcelain", "-z")
staged: list[GitChange] = []
unstaged: list[GitChange] = []
untracked: list[str] = []
if result.success and result.stdout:
entries = result.stdout.split("\0")
for entry in entries:
if not entry or len(entry) < 3:
continue
index_status = entry[0]
worktree_status = entry[1]
path = entry[3:]
# Parse status
if index_status == "?":
untracked.append(path)
else:
if index_status != " ":
staged.append(GitChange(
path=path,
status=self._parse_status(index_status),
staged=True,
))
if worktree_status != " ":
unstaged.append(GitChange(
path=path,
status=self._parse_status(worktree_status),
staged=False,
))
# Get current branch
branch_result = await self._process.run(
"git", "branch", "--show-current"
)
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
# Get ahead/behind
ahead, behind = await self._get_ahead_behind(branch)
return GitStatus(
branch=branch,
ahead=ahead,
behind=behind,
staged=tuple(staged),
unstaged=tuple(unstaged),
untracked=tuple(untracked),
)
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
"""Get commits ahead/behind upstream."""
result = await self._process.run(
"git", "rev-list", "--left-right", "--count",
f"{branch}...@{{upstream}}"
)
if result.success:
parts = result.stdout.strip().split()
if len(parts) == 2:
return int(parts[0]), int(parts[1])
return 0, 0
def _parse_status(self, char: str) -> ChangeStatus:
"""Parse git status character to ChangeStatus."""
mapping = {
"A": ChangeStatus.ADDED,
"M": ChangeStatus.MODIFIED,
"D": ChangeStatus.DELETED,
"R": ChangeStatus.RENAMED,
"C": ChangeStatus.COPIED,
"?": ChangeStatus.UNTRACKED,
"!": ChangeStatus.IGNORED,
"U": ChangeStatus.UNMERGED,
}
return mapping.get(char, ChangeStatus.MODIFIED)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
result = await self._process.run("git", "add", path)
return result.success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
result = await self._process.run("git", "restore", "--staged", path)
return result.success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path to discard
Returns:
True if successful
"""
result = await self._process.run("git", "restore", path)
return result.success
async def get_branches(self) -> list[GitBranch]:
"""Get list of branches.
Returns:
List of GitBranch objects
"""
result = await self._process.run(
"git", "branch", "-a", "--format",
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)"
)
branches: list[GitBranch] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
is_current = line.startswith("*")
parts = line[1:].split("|")
if len(parts) >= 4:
name = parts[0].strip()
branches.append(GitBranch(
name=name,
is_current=is_current,
is_remote=name.startswith("remotes/"),
tracking=parts[1] or None,
commit_hash=parts[2],
commit_message=parts[3],
))
return branches
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name to checkout
Returns:
True if successful
"""
result = await self._process.run("git", "checkout", branch)
return result.success
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
"""Create a new branch.
Args:
name: New branch name
start_point: Optional starting commit/branch
Returns:
True if successful
"""
args = ["git", "checkout", "-b", name]
if start_point:
args.append(start_point)
result = await self._process.run(*args)
return result.success
async def get_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff output string
"""
args = ["git", "diff"]
if staged:
args.append("--cached")
args.append("--")
args.append(path)
result = await self._process.run(*args)
return result.stdout if result.success else ""
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
"""Get commit log.
Args:
max_count: Maximum number of commits to return
Returns:
List of GitCommit objects
"""
result = await self._process.run(
"git", "log",
f"--max-count={max_count}",
"--format=%H|%h|%s|%an|%ar|%P|%D",
"--all",
)
commits: list[GitCommit] = []
if result.success:
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|")
if len(parts) >= 7:
parents = tuple(parts[5].split()) if parts[5] else ()
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
commits.append(GitCommit(
hash=parts[0],
short_hash=parts[1],
message=parts[2],
author=parts[3],
date=parts[4],
is_merge=len(parents) > 1,
parents=parents,
refs=refs,
))
return commits
+127
View File
@@ -0,0 +1,127 @@
"""Linter integration service."""
import json
from pathlib import Path
from clide.models.problems import Problem, ProblemsSummary, Severity
from clide.services.process_service import ProcessService
class LinterService:
"""Service for running linters and parsing output."""
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def run_ruff(self) -> list[Problem]:
"""Run ruff linter.
Returns:
List of problems found
"""
result = await self._process.run(
"ruff", "check", "--output-format=json", "."
)
problems: list[Problem] = []
if result.stdout:
try:
data = json.loads(result.stdout)
for item in data:
severity = self._ruff_severity(item.get("code", ""))
problems.append(Problem(
file_path=Path(item["filename"]),
line=item["location"]["row"],
column=item["location"]["column"],
end_line=item.get("end_location", {}).get("row"),
end_column=item.get("end_location", {}).get("column"),
severity=severity,
message=item["message"],
source="ruff",
code=item.get("code"),
))
except json.JSONDecodeError:
pass
return problems
def _ruff_severity(self, code: str) -> Severity:
"""Map ruff code to severity."""
if code.startswith("E") or code.startswith("F"):
return Severity.ERROR
if code.startswith("W"):
return Severity.WARNING
return Severity.INFO
async def run_mypy(self) -> list[Problem]:
"""Run mypy type checker.
Returns:
List of problems found
"""
result = await self._process.run(
"mypy", "--output=json", "."
)
problems: list[Problem] = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
try:
data = json.loads(line)
severity = self._mypy_severity(data.get("severity", "error"))
problems.append(Problem(
file_path=Path(data["file"]),
line=data["line"],
column=data.get("column", 1),
severity=severity,
message=data["message"],
source="mypy",
code=data.get("code"),
))
except (json.JSONDecodeError, KeyError):
continue
return problems
def _mypy_severity(self, severity: str) -> Severity:
"""Map mypy severity to Severity enum."""
mapping = {
"error": Severity.ERROR,
"warning": Severity.WARNING,
"note": Severity.INFO,
}
return mapping.get(severity, Severity.ERROR)
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
"""Run all configured linters.
Args:
linters: List of linter names to run
Returns:
Tuple of (problems list, summary)
"""
all_problems: list[Problem] = []
for linter in linters:
if linter == "ruff":
all_problems.extend(await self.run_ruff())
elif linter == "mypy":
all_problems.extend(await self.run_mypy())
# Create summary
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
summary = ProblemsSummary(
errors=errors,
warnings=warnings,
infos=infos,
hints=hints,
)
return all_problems, summary
+124
View File
@@ -0,0 +1,124 @@
"""Generic subprocess management service."""
import asyncio
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CommandResult:
"""Result of a command execution."""
returncode: int
stdout: str
stderr: str
@property
def success(self) -> bool:
"""Check if command succeeded."""
return self.returncode == 0
class ProcessService:
"""Service for running subprocess commands."""
def __init__(self, cwd: Path | None = None) -> None:
self.cwd = cwd or Path.cwd()
async def run(
self,
*args: str,
cwd: Path | None = None,
timeout: float | None = 30.0,
env: dict[str, str] | None = None,
) -> CommandResult:
"""Run a command asynchronously.
Args:
*args: Command and arguments
cwd: Working directory (defaults to service cwd)
timeout: Timeout in seconds
env: Environment variables to add
Returns:
CommandResult with stdout, stderr, and returncode
"""
working_dir = cwd or self.cwd
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
env=env,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
return CommandResult(
returncode=process.returncode or 0,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
)
except asyncio.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),
)
+151
View File
@@ -0,0 +1,151 @@
"""TODO comment scanner service."""
import re
from pathlib import Path
from clide.models.todos import TodoItem, TodosSummary, TodoType
from clide.services.process_service import ProcessService
class TodoScanner:
"""Service for scanning TODO/FIXME comments in code."""
# Pattern to match TODO-style comments
TODO_PATTERN = re.compile(
r"(?:#|//|/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\s*:?\s*(.+?)(?:\*/|-->)?$",
re.IGNORECASE,
)
# File extensions to scan
SCAN_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
".go", ".rs", ".rb", ".php", ".css", ".scss", ".html", ".vue",
".svelte", ".md", ".sh", ".bash", ".yaml", ".yml", ".toml",
}
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def scan(self) -> tuple[list[TodoItem], TodosSummary]:
"""Scan project for TODO comments.
Returns:
Tuple of (todo items, summary)
"""
items: list[TodoItem] = []
# Use ripgrep if available for speed
result = await self._process.run(
"rg", "--line-number", "--no-heading",
"-e", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
"--type-add", "code:*.py",
"--type-add", "code:*.js",
"--type-add", "code:*.ts",
"--type", "code",
".",
)
if result.success:
items = self._parse_ripgrep_output(result.stdout)
else:
# Fallback to Python-based scanning
items = await self._scan_with_python()
# Create summary
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
other_count = len(items) - todo_count - fixme_count - hack_count
summary = TodosSummary(
todo_count=todo_count,
fixme_count=fixme_count,
hack_count=hack_count,
other_count=other_count,
)
return items, summary
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
"""Parse ripgrep output into TodoItems."""
items: list[TodoItem] = []
for line in output.strip().split("\n"):
if not line:
continue
# Format: path:line:content
parts = line.split(":", 2)
if len(parts) < 3:
continue
file_path = Path(parts[0])
try:
line_num = int(parts[1])
except ValueError:
continue
content = parts[2]
# Parse the TODO type and text
match = self.TODO_PATTERN.search(content)
if match:
todo_type_str = match.group(1).upper()
todo_text = match.group(2).strip()
try:
todo_type = TodoType(todo_type_str)
except ValueError:
todo_type = TodoType.TODO
items.append(TodoItem(
file_path=file_path,
line=line_num,
column=content.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=content.strip(),
))
return items
async def _scan_with_python(self) -> list[TodoItem]:
"""Fallback Python-based scanning."""
items: list[TodoItem] = []
for ext in self.SCAN_EXTENSIONS:
for file_path in self.project_path.rglob(f"*{ext}"):
# Skip hidden directories and common excludes
if any(part.startswith(".") for part in file_path.parts):
continue
if "node_modules" in file_path.parts:
continue
if "__pycache__" in file_path.parts:
continue
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
for line_num, line in enumerate(content.split("\n"), 1):
match = self.TODO_PATTERN.search(line)
if match:
todo_type_str = match.group(1).upper()
todo_text = match.group(2).strip()
try:
todo_type = TodoType(todo_type_str)
except ValueError:
todo_type = TodoType.TODO
items.append(TodoItem(
file_path=file_path.relative_to(self.project_path),
line=line_num,
column=line.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=line.strip(),
))
except (OSError, UnicodeDecodeError):
continue
return items
+17
View File
@@ -0,0 +1,17 @@
"""Theme system for Clide."""
from clide.themes.registry import (
get_theme,
get_all_themes,
get_themes_by_category,
register_theme,
DEFAULT_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 (
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,
)
__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",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Dark Autumn Frost theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dark-autumn",
display_name="Dark Autumn Frost",
dark=True,
colors=ThemeColors(
primary="#c49a6c",
secondary="#8b7355",
accent="#a0522d",
background="#1c1410",
surface="#2a1f18",
panel="#382a20",
foreground="#d2b48c",
success="#6b8e23",
warning="#b8860b",
error="#8b0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Dracula theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="dracula",
display_name="Dracula",
dark=True,
colors=ThemeColors(
primary="#bd93f9",
secondary="#8be9fd",
accent="#ff79c6",
background="#282a36",
surface="#21222c",
panel="#343746",
foreground="#f8f8f2",
success="#50fa7b",
warning="#ffb86c",
error="#ff5555",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Fall theme - Autumn colors."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="fall",
display_name="Fall",
dark=True,
colors=ThemeColors(
primary="#e9967a",
secondary="#daa520",
accent="#cd853f",
background="#2d1f1f",
surface="#3d2929",
panel="#4d3333",
foreground="#f5deb3",
success="#8fbc8f",
warning="#daa520",
error="#cd5c5c",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gamma theme - GitKraken Gamma style."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gamma",
display_name="Gamma",
dark=True,
colors=ThemeColors(
primary="#00d4aa",
secondary="#7c3aed",
accent="#f472b6",
background="#0f172a",
surface="#1e293b",
panel="#334155",
foreground="#e2e8f0",
success="#22c55e",
warning="#f59e0b",
error="#ef4444",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gruvbox Dark theme - Retro groove color scheme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gruvbox-dark",
display_name="Gruvbox Dark",
dark=True,
colors=ThemeColors(
primary="#83a598",
secondary="#8ec07c",
accent="#d3869b",
background="#282828",
surface="#3c3836",
panel="#504945",
foreground="#ebdbb2",
success="#b8bb26",
warning="#fabd2f",
error="#fb4934",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Gruvbox Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="gruvbox-light",
display_name="Gruvbox Light",
dark=False,
colors=ThemeColors(
primary="#076678",
secondary="#427b58",
accent="#8f3f71",
background="#fbf1c7",
surface="#ebdbb2",
panel="#d5c4a1",
foreground="#3c3836",
success="#79740e",
warning="#b57614",
error="#9d0006",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Hacker Style theme - Matrix inspired."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="hacker-style",
display_name="Hacker Style",
dark=True,
colors=ThemeColors(
primary="#20c20e",
secondary="#33ff33",
accent="#66ff66",
background="#0c0c0c",
surface="#121212",
panel="#1a1a1a",
foreground="#33ff33",
success="#20c20e",
warning="#c0c020",
error="#c02020",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Halloween theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="halloween",
display_name="Halloween",
dark=True,
colors=ThemeColors(
primary="#ff6600",
secondary="#8a2be2",
accent="#ff4500",
background="#0d0d0d",
surface="#1a1a1a",
panel="#262626",
foreground="#e6e6e6",
success="#00ff00",
warning="#ff6600",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Houston theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="houston",
display_name="Houston",
dark=True,
colors=ThemeColors(
primary="#ff6f00",
secondary="#00bcd4",
accent="#ff4081",
background="#17212b",
surface="#232e3c",
panel="#2e3a48",
foreground="#eeffff",
success="#4caf50",
warning="#ff9800",
error="#f44336",
),
)
@@ -0,0 +1,21 @@
"""Monokai Winter Night theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="monokai-winter",
display_name="Monokai Winter Night",
dark=True,
colors=ThemeColors(
primary="#66d9ef",
secondary="#a6e22e",
accent="#f92672",
background="#1a1a2e",
surface="#16213e",
panel="#0f3460",
foreground="#f8f8f2",
success="#a6e22e",
warning="#e6db74",
error="#f92672",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Nord theme - Arctic, north-bluish color palette."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="nord",
display_name="Nord",
dark=True,
colors=ThemeColors(
primary="#88c0d0",
secondary="#81a1c1",
accent="#b48ead",
background="#2e3440",
surface="#3b4252",
panel="#434c5e",
foreground="#eceff4",
success="#a3be8c",
warning="#ebcb8b",
error="#bf616a",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Dark theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark",
display_name="One Dark",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#98c379",
warning="#e5c07b",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""One Dark Pro theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark-pro",
display_name="One Dark Pro",
dark=True,
colors=ThemeColors(
primary="#61afef",
secondary="#56b6c2",
accent="#c678dd",
background="#282c34",
surface="#1e2227",
panel="#333842",
foreground="#abb2bf",
success="#98c379",
warning="#d19a66",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""One Dark Teal theme - GitKraken signature teal accent."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-dark-teal",
display_name="One Dark Teal",
dark=True,
colors=ThemeColors(
primary="#2acf9f", # GitKraken teal
secondary="#61afef",
accent="#c678dd",
background="#282c34",
surface="#21252b",
panel="#2c313a",
foreground="#abb2bf",
success="#2acf9f",
warning="#e5c07b",
error="#e06c75",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Atom One Light theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="one-light",
display_name="One Light",
dark=False,
colors=ThemeColors(
primary="#4078f2",
secondary="#0184bc",
accent="#a626a4",
background="#fafafa",
surface="#f0f0f0",
panel="#e5e5e6",
foreground="#383a42",
success="#50a14f",
warning="#c18401",
error="#e45649",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Pro Hacker theme - Green on black."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="pro-hacker",
display_name="Pro Hacker",
dark=True,
colors=ThemeColors(
primary="#00ff00",
secondary="#00cc00",
accent="#00ff88",
background="#000000",
surface="#0a0a0a",
panel="#141414",
foreground="#00ff00",
success="#00ff00",
warning="#ffff00",
error="#ff0000",
),
)
+21
View File
@@ -0,0 +1,21 @@
"""Santa Baby theme - Light Christmas theme."""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="santa-baby",
display_name="Santa Baby",
dark=False,
colors=ThemeColors(
primary="#c41e3a", # Cardinal red
secondary="#228b22", # Forest green
accent="#b8860b", # Dark goldenrod
background="#fff8f0",
surface="#f0e8e0",
panel="#e0d8d0",
foreground="#2f1f1f",
success="#228b22",
warning="#daa520",
error="#c41e3a",
),
)
+24
View File
@@ -0,0 +1,24 @@
"""Summer Day theme - Light variant of Summer Night.
Inverted lightness scale with adjusted accent hues for readability.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
theme = ThemeDefinition(
name="summer-day",
display_name="Summer Day",
dark=False,
colors=ThemeColors(
primary="#0088b0",
secondary="#008a99",
accent="#d03060",
background="#f5f7fa",
surface="#e8ebf0",
panel="#dde1e8",
foreground="#21262f",
success="#008a7a",
warning="#b06830",
error="#c04048",
),
)
+45
View File
@@ -0,0 +1,45 @@
"""Summer Night theme - Default dark theme.
Based on jackw01/summer-night-vscode-theme.
Vibrant colors with HCL-based monochrome scale.
"""
from clide.models.theme import ThemeColors, ThemeDefinition
# Monochrome scale (HCL equidistant lightness)
# mono_1: #e2e8f5 - Lightest text
# mono_2: #c4c9d6 - Secondary text
# mono_3: #a6abb8 - Muted text
# mono_4: #898e9a - Comments
# mono_5: #6d727e - Subtle
# mono_6: #525762 - Borders
# mono_7: #393e48 - Surface
# mono_8: #21262f - Background
# Accent colors (HCL analogous scales)
# cyan: #00a3d2 - Primary accent
# teal: #00a9b9 - Links
# pink: #fa5f8b - Keywords
# yellow: #d3ab58 - Strings
# red: #f06c6f - Errors
# orange: #d08447 - Warnings
# coral: #e17954 - Functions
# green: #00ab9a - Success
theme = ThemeDefinition(
name="summer-night",
display_name="Summer Night",
dark=True,
colors=ThemeColors(
primary="#00a3d2",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
),
)
@@ -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",
),
)
+88
View File
@@ -0,0 +1,88 @@
"""Custom theme loader for user-defined themes."""
from pathlib import Path
import tomllib
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,
)
+140
View File
@@ -0,0 +1,140 @@
"""Theme registry for managing available themes."""
from typing import Callable
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 (
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,
)
# Core themes
register_theme(summer_night.theme, "core")
register_theme(summer_day.theme, "core")
# Popular themes
register_theme(one_dark.theme, "popular")
register_theme(one_dark_pro.theme, "popular")
register_theme(one_light.theme, "popular")
register_theme(dracula.theme, "popular")
register_theme(nord.theme, "popular")
register_theme(gruvbox_dark.theme, "popular")
register_theme(gruvbox_light.theme, "popular")
# GitKraken style
register_theme(one_dark_teal.theme, "gitkraken")
register_theme(gamma.theme, "gitkraken")
# Seasonal - Winter
register_theme(winter_is_coming.theme, "seasonal")
register_theme(monokai_winter.theme, "seasonal")
# Seasonal - Fall
register_theme(fall.theme, "seasonal")
register_theme(dark_autumn.theme, "seasonal")
# Seasonal - Halloween
register_theme(all_hallows_eve.theme, "seasonal")
register_theme(halloween.theme, "seasonal")
# Seasonal - Christmas
register_theme(christmas.theme, "seasonal")
register_theme(santa_baby.theme, "seasonal")
# Hacker style
register_theme(pro_hacker.theme, "hacker")
register_theme(hacker_style.theme, "hacker")
# Bonus
register_theme(houston.theme, "popular")
# Load built-in themes on module import
_load_builtin_themes()
+25
View File
@@ -0,0 +1,25 @@
"""Textual widgets for Clide UI."""
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
from clide.widgets.components.git_graph import GitGraphView
from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.terminal_pane import TerminalPane
from clide.widgets.components.todos_view import TodosView
__all__ = [
"BranchStatus",
"DiffPane",
"EditorPane",
"FilesView",
"GitChangesView",
"GitGraphView",
"JiraView",
"ProblemsView",
"TerminalPane",
"TodosView",
]
+25
View File
@@ -0,0 +1,25 @@
"""Reusable UI components for Clide."""
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
from clide.widgets.components.git_graph import GitGraphView
from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.terminal_pane import TerminalPane
from clide.widgets.components.todos_view import TodosView
__all__ = [
"BranchStatus",
"DiffPane",
"EditorPane",
"FilesView",
"GitChangesView",
"GitGraphView",
"JiraView",
"ProblemsView",
"TerminalPane",
"TodosView",
]
@@ -0,0 +1,161 @@
"""Branch status bar component."""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widgets import Button, Label, ListItem, ListView, Static
from clide.models.git import GitBranch
class BranchStatus(Vertical):
"""Branch status bar with popout branch selector."""
DEFAULT_CSS = """
BranchStatus {
height: auto;
dock: bottom;
}
BranchStatus .status-bar {
height: 1;
background: $surface;
padding: 0 1;
}
BranchStatus .branch-icon {
width: 2;
}
BranchStatus .branch-name {
width: 1fr;
}
BranchStatus .popout {
display: none;
height: auto;
max-height: 15;
background: $panel;
border: solid $primary;
layer: popout;
}
BranchStatus .popout.visible {
display: block;
}
BranchStatus .popout-header {
background: $surface;
padding: 0 1;
text-style: bold;
}
BranchStatus .popout-actions {
height: 1;
padding: 0 1;
}
"""
class BranchChanged(Message):
"""Emitted when branch is changed."""
def __init__(self, branch: str) -> None:
self.branch = branch
super().__init__()
# Alias for backwards compatibility
BranchChangeRequested = BranchChanged
class NewBranchRequested(Message):
"""Emitted when new branch creation is requested."""
pass
def __init__(
self,
current_branch: str = "main",
branches: list[GitBranch] | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._current = current_branch
self._branches = branches or []
self._popout_visible = False
def compose(self) -> ComposeResult:
with Horizontal(classes="status-bar"):
yield Static("", classes="branch-icon")
yield Static(self._current, classes="branch-name", id="branch-name")
yield Static("", classes="toggle-icon")
with Vertical(classes="popout", id="branch-popout"):
yield Label("Recent branches", classes="popout-header")
yield ListView(
*[ListItem(Label(b.name)) for b in self._branches[:5]],
id="branch-list",
)
with Horizontal(classes="popout-actions"):
yield Button("Checkout", id="btn-checkout", variant="primary")
yield Button("New", id="btn-new")
@property
def branch(self) -> str:
"""Get current branch."""
return self._current
@branch.setter
def branch(self, value: str) -> None:
"""Set current branch."""
self._current = value
try:
self.query_one("#branch-name", Static).update(value)
except Exception:
pass
def update_branch(self, branch: str) -> None:
"""Update current branch display."""
self.branch = branch
def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
"""Update branches list."""
self._branches = branches # type: ignore
try:
branch_list = self.query_one("#branch-list", ListView)
branch_list.clear()
for branch in branches[:5]:
if isinstance(branch, str):
name = branch
is_current = name == self._current
else:
name = branch.name
is_current = branch.is_current
marker = "" if is_current else ""
branch_list.append(ListItem(Label(f"{marker}{name}")))
except Exception:
pass
def toggle_popout(self) -> None:
"""Toggle popout visibility."""
self._popout_visible = not self._popout_visible
popout = self.query_one("#branch-popout")
if self._popout_visible:
popout.add_class("visible")
else:
popout.remove_class("visible")
def on_click(self) -> None:
"""Handle click on status bar."""
self.toggle_popout()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "btn-checkout":
branch_list = self.query_one("#branch-list", ListView)
if branch_list.highlighted_child:
# Get selected branch name
label = branch_list.highlighted_child.query_one(Label)
branch = label.renderable.plain.lstrip("● ○ ")
self.post_message(self.BranchChangeRequested(branch))
self.toggle_popout()
elif event.button.id == "btn-new":
self.post_message(self.NewBranchRequested())
self.toggle_popout()
+148
View File
@@ -0,0 +1,148 @@
"""Diff pane component for viewing diffs."""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widgets import Button, RichLog, Static
from clide.models.diff import ChangeType, DiffContent, DiffHunk
class DiffPane(Vertical):
"""Diff viewer pane with accept/reject for proposals."""
DEFAULT_CSS = """
DiffPane {
height: 100%;
}
DiffPane .diff-header {
height: 1;
background: $surface;
padding: 0 1;
}
DiffPane .diff-content {
height: 1fr;
}
DiffPane .diff-actions {
height: auto;
padding: 1;
background: $panel;
}
DiffPane .added {
background: #1e3a1e;
color: #4ec9b0;
}
DiffPane .removed {
background: #3a1e1e;
color: #f14c4c;
}
DiffPane .hunk-header {
color: $accent;
text-style: bold;
}
"""
class AcceptClicked(Message):
"""Emitted when accept is clicked."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
class RejectClicked(Message):
"""Emitted when reject is clicked."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
def __init__(
self,
diff: DiffContent | None = None,
is_proposal: bool = False,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._diff = diff
self._is_proposal = is_proposal
def compose(self) -> ComposeResult:
if self._diff:
yield Static(f"Diff: {self._diff.file_path}", classes="diff-header")
else:
yield Static("No diff loaded", classes="diff-header")
yield RichLog(id="diff-log", highlight=True, markup=True, classes="diff-content")
if self._is_proposal:
with Horizontal(classes="diff-actions"):
yield Button("Accept", id="btn-accept", variant="success")
yield Button("Reject", id="btn-reject", variant="error")
def on_mount(self) -> None:
"""Render diff on mount."""
self._render_diff()
def load_diff(self, diff: DiffContent, is_proposal: bool = False) -> None:
"""Load a diff into the viewer."""
self._diff = diff
self._is_proposal = is_proposal
# Update header
header = self.query_one(".diff-header", Static)
header.update(f"Diff: {diff.file_path}")
# Show/hide action buttons
try:
actions = self.query_one(".diff-actions")
actions.display = is_proposal
except Exception:
pass
self._render_diff()
def _render_diff(self) -> None:
"""Render the diff content."""
log = self.query_one("#diff-log", RichLog)
log.clear()
if not self._diff:
log.write("[dim]No diff to display[/]")
return
for hunk in self._diff.hunks:
# Hunk header
log.write(f"[hunk-header]{hunk.header}[/]")
for line in hunk.lines:
if line.change_type == ChangeType.ADDED:
log.write(f"[green]+{line.content}[/]")
elif line.change_type == ChangeType.REMOVED:
log.write(f"[red]-{line.content}[/]")
else:
log.write(f" {line.content}")
def clear(self) -> None:
"""Clear the diff viewer."""
self._diff = None
log = self.query_one("#diff-log", RichLog)
log.clear()
header = self.query_one(".diff-header", Static)
header.update("No diff loaded")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if not self._diff:
return
if event.button.id == "btn-accept":
self.post_message(self.AcceptClicked(self._diff.file_path))
elif event.button.id == "btn-reject":
self.post_message(self.RejectClicked(self._diff.file_path))
+199
View File
@@ -0,0 +1,199 @@
"""Editor pane component with syntax highlighting."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Label, Static, TextArea
from clide.models.editor import CursorPosition, FileBuffer
class EditorPane(Vertical):
"""Editor pane with TextArea and status bar."""
DEFAULT_CSS = """
EditorPane {
height: 100%;
}
EditorPane TextArea {
height: 1fr;
}
EditorPane .editor-status {
height: 1;
background: $surface;
padding: 0 1;
}
EditorPane .file-tab {
height: 1;
background: $panel;
}
EditorPane .modified {
color: $warning;
}
"""
class ContentChanged(Message):
"""Emitted when content changes."""
def __init__(self, path: Path, content: str) -> None:
self.path = path
self.content = content
super().__init__()
class CursorMoved(Message):
"""Emitted when cursor moves."""
def __init__(self, path: Path, line: int, column: int) -> None:
self.path = path
self.line = line
self.column = column
super().__init__()
class SaveRequested(Message):
"""Emitted when save is requested."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileSaved(Message):
"""Emitted when file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
def __init__(self, buffer: FileBuffer | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._buffer = buffer
def compose(self) -> ComposeResult:
if self._buffer:
yield Static(self._buffer.display_name, classes="file-tab")
yield TextArea(
self._buffer.content,
language=self._buffer.language,
id="editor-textarea",
show_line_numbers=True,
)
yield Static(
self._get_status_text(),
classes="editor-status",
id="editor-status",
)
else:
yield Static("No file open", classes="file-tab")
yield TextArea(id="editor-textarea", show_line_numbers=True)
yield Static("", classes="editor-status", id="editor-status")
def load_buffer(self, buffer: FileBuffer) -> None:
"""Load a file buffer into the editor."""
self._buffer = buffer
textarea = self.query_one("#editor-textarea", TextArea)
textarea.load_text(buffer.content)
textarea.language = buffer.language
# Update tab
tab = self.query_one(".file-tab", Static)
tab.update(buffer.display_name)
# Update status
self._update_status()
# Set cursor position
if buffer.cursor:
textarea.cursor_location = (buffer.cursor.line, buffer.cursor.column)
def get_content(self) -> str:
"""Get current editor content."""
textarea = self.query_one("#editor-textarea", TextArea)
return textarea.text
def _get_status_text(self) -> str:
"""Generate status bar text."""
if not self._buffer:
return ""
line = self._buffer.cursor.line + 1 if self._buffer.cursor else 1
col = self._buffer.cursor.column + 1 if self._buffer.cursor else 1
lang = self._buffer.language or "plain text"
return f"Ln {line}, Col {col} | {lang}"
def _update_status(self) -> None:
"""Update status bar."""
status = self.query_one("#editor-status", Static)
status.update(self._get_status_text())
def on_text_area_changed(self, event: TextArea.Changed) -> None:
"""Handle text changes."""
if self._buffer:
self._buffer.content = event.text_area.text
self._buffer.is_modified = True
# Update tab to show modified indicator
tab = self.query_one(".file-tab", Static)
tab.update(self._buffer.display_name)
self.post_message(self.ContentChanged(self._buffer.path, event.text_area.text))
def on_text_area_selection_changed(self, event: TextArea.SelectionChanged) -> None:
"""Handle cursor movement."""
if self._buffer:
line, col = event.selection.end
# CursorPosition is frozen, so create new one
self._buffer.cursor = CursorPosition(line=line, column=col)
self._update_status()
self.post_message(self.CursorMoved(self._buffer.path, line, col))
@property
def current_file(self) -> Path | None:
"""Get currently open file path."""
return self._buffer.path if self._buffer else None
@property
def modified(self) -> bool:
"""Check if buffer has unsaved changes."""
return self._buffer.is_modified if self._buffer else False
def load_file(self, path: Path, goto_line: int | None = None) -> None:
"""Load a file from disk into the editor."""
from clide.services.file_service import FileService
content = FileService.read_file(path)
language = FileService.detect_language(path)
buffer = FileBuffer(
path=path,
content=content,
language=language,
is_modified=False,
)
self.load_buffer(buffer)
if goto_line is not None:
textarea = self.query_one("#editor-textarea", TextArea)
textarea.cursor_location = (goto_line - 1, 0)
def save(self) -> bool:
"""Save current buffer to disk."""
if not self._buffer:
return False
from clide.services.file_service import FileService
success = FileService.write_file(self._buffer.path, self._buffer.content)
if success:
self._buffer.is_modified = False
# Update tab
tab = self.query_one(".file-tab", Static)
tab.update(self._buffer.display_name)
self.post_message(self.FileSaved(self._buffer.path))
return success
@@ -0,0 +1,87 @@
"""File browser component using DirectoryTree."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich.text import Text
from textual.message import Message
from textual.widgets import DirectoryTree
if TYPE_CHECKING:
from rich.style import Style
from textual.widgets._directory_tree import DirEntry
from textual.widgets._tree import TreeNode
# Minimal Unicode icons (works with any font)
ICON_FOLDER_OPEN = ""
ICON_FOLDER_CLOSED = ""
ICON_FILE = ""
class FilesView(DirectoryTree):
"""File browser widget wrapping DirectoryTree."""
class FileSelected(Message):
"""Emitted when a file is selected."""
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
self.node = node
self.path = path
super().__init__()
class DirectorySelected(Message):
"""Emitted when a directory is selected."""
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
self.node = node
self.path = path
super().__init__()
def __init__(
self,
path: Path,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(
path,
name=name,
id=id,
classes=classes,
)
def render_label(
self, node: TreeNode[DirEntry], base_style: Style, style: Style
) -> Text:
"""Render a label with minimal Unicode icons."""
path = node.data.path
if path.is_dir():
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED
icon_style = "bold cyan"
else:
icon = ICON_FILE
icon_style = "dim"
label = Text()
label.append(f"{icon} ", style=icon_style)
label.append(path.name, style=style)
return label
def filter_paths(self, paths: list[Path]) -> list[Path]:
"""Filter out hidden and ignored paths."""
return [
p for p in paths
if not p.name.startswith(".")
and p.name not in ("__pycache__", "node_modules", ".git", ".venv", "venv")
]
def refresh_tree(self) -> None:
"""Refresh the directory tree."""
self.reload()
+129
View File
@@ -0,0 +1,129 @@
"""Git changes view component."""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Label, ListItem, ListView, Static
from clide.models.git import ChangeStatus, GitChange
class GitChangeItem(ListItem):
"""A single git change item."""
STATUS_ICONS = {
ChangeStatus.ADDED: "+",
ChangeStatus.MODIFIED: "~",
ChangeStatus.DELETED: "-",
ChangeStatus.RENAMED: "",
ChangeStatus.UNTRACKED: "?",
ChangeStatus.COPIED: "C",
ChangeStatus.UNMERGED: "!",
ChangeStatus.IGNORED: "I",
}
def __init__(self, change: GitChange) -> None:
super().__init__()
self.change = change
def compose(self) -> ComposeResult:
icon = self.STATUS_ICONS.get(self.change.status, "?")
status_class = self.change.status.value
yield Static(
f"[{status_class}]{icon}[/] {self.change.path}",
markup=True,
)
class GitChangesView(Vertical):
"""View for staged and unstaged git changes."""
DEFAULT_CSS = """
GitChangesView {
height: 100%;
}
GitChangesView .section-header {
background: $surface;
padding: 0 1;
text-style: bold;
}
GitChangesView ListView {
height: auto;
max-height: 50%;
}
GitChangesView .added { color: $success; }
GitChangesView .modified { color: $warning; }
GitChangesView .deleted { color: $error; }
GitChangesView .untracked { color: $accent; }
"""
class FileClicked(Message):
"""Emitted when a file is clicked."""
def __init__(self, change: GitChange) -> None:
self.change = change
super().__init__()
class StageRequested(Message):
"""Emitted when staging is requested."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
class UnstageRequested(Message):
"""Emitted when unstaging is requested."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
def __init__(
self,
staged: list[GitChange] | None = None,
unstaged: list[GitChange] | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._staged = staged or []
self._unstaged = unstaged or []
def compose(self) -> ComposeResult:
yield Label("Staged Changes", classes="section-header")
yield ListView(
*[GitChangeItem(c) for c in self._staged],
id="staged-list",
)
yield Label("Changes", classes="section-header")
yield ListView(
*[GitChangeItem(c) for c in self._unstaged],
id="unstaged-list",
)
def update_changes(
self,
staged: list[GitChange],
unstaged: list[GitChange],
) -> None:
"""Update the changes lists."""
self._staged = staged
self._unstaged = unstaged
staged_list = self.query_one("#staged-list", ListView)
unstaged_list = self.query_one("#unstaged-list", ListView)
staged_list.clear()
for change in staged:
staged_list.append(GitChangeItem(change))
unstaged_list.clear()
for change in unstaged:
unstaged_list.append(GitChangeItem(change))
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, GitChangeItem):
self.post_message(self.FileClicked(event.item.change))
+90
View File
@@ -0,0 +1,90 @@
"""Git graph visualization component."""
from textual.app import ComposeResult
from textual.message import Message
from textual.widgets import RichLog, Static
from textual.containers import Vertical
from clide.models.git import GitCommit
class GitGraphView(Vertical):
"""View for git commit graph visualization."""
DEFAULT_CSS = """
GitGraphView {
height: 100%;
}
GitGraphView RichLog {
height: 100%;
scrollbar-size: 1 1;
}
GitGraphView .commit-line {
height: auto;
}
"""
# Graph drawing characters
COMMIT = ""
MERGE = ""
LINE = ""
BRANCH = ""
JOIN = ""
class CommitSelected(Message):
"""Emitted when a commit is selected."""
def __init__(self, commit: GitCommit) -> None:
self.commit = commit
super().__init__()
def __init__(self, commits: list[GitCommit] | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._commits = commits or []
def compose(self) -> ComposeResult:
yield RichLog(id="graph-log", highlight=True, markup=True)
def on_mount(self) -> None:
"""Render initial graph."""
self._render_graph()
def update_commits(self, commits: list[GitCommit]) -> None:
"""Update the commit list."""
self._commits = commits
self._render_graph()
def _render_graph(self) -> None:
"""Render the commit graph."""
log = self.query_one("#graph-log", RichLog)
log.clear()
for commit in self._commits:
line = self._format_commit_line(commit)
log.write(line)
def _format_commit_line(self, commit: GitCommit) -> str:
"""Format a single commit line."""
# Choose commit symbol
symbol = self.MERGE if commit.is_merge else self.COMMIT
# Format refs (branches, tags)
refs_str = ""
if commit.refs:
refs = ", ".join(commit.refs)
refs_str = f" [bold cyan]({refs})[/]"
# Truncate message
message = commit.message[:50]
if len(commit.message) > 50:
message += "..."
return (
f"[bold yellow]{symbol}[/] "
f"[dim]{commit.short_hash}[/]"
f"{refs_str} "
f"{message} "
f"[dim]- {commit.author}, {commit.date}[/]"
)
+96
View File
@@ -0,0 +1,96 @@
"""Jira view component for Jira CLI output."""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Button, Markdown, Static
class JiraView(Vertical):
"""View for Jira CLI output."""
DEFAULT_CSS = """
JiraView {
height: 100%;
}
JiraView .jira-header {
height: 1;
background: $surface;
padding: 0 1;
}
JiraView Markdown {
height: 1fr;
padding: 1;
}
JiraView .jira-actions {
height: auto;
padding: 0 1;
background: $panel;
}
JiraView .disabled-message {
padding: 2;
text-align: center;
color: $warning;
}
"""
class RefreshRequested(Message):
"""Emitted when refresh is requested."""
pass
class IssueClicked(Message):
"""Emitted when an issue is clicked."""
def __init__(self, issue_key: str) -> None:
self.issue_key = issue_key
super().__init__()
def __init__(
self,
content: str = "",
enabled: bool = True,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._content = content
self._enabled = enabled
def compose(self) -> ComposeResult:
yield Static("Jira", classes="jira-header")
if self._enabled:
yield Markdown(self._content or "*Loading...*", id="jira-content")
yield Button("↻ Refresh", id="btn-refresh", classes="jira-actions")
else:
yield Static(
"Jira integration is disabled.\n\n"
"Enable it in settings with CLIDE_JIRA_ENABLED=true",
classes="disabled-message",
)
def update_content(self, content: str) -> None:
"""Update Jira output content."""
self._content = content
try:
markdown = self.query_one("#jira-content", Markdown)
markdown.update(content)
except Exception:
pass
def set_loading(self) -> None:
"""Show loading state."""
self.update_content("*Loading...*")
def set_error(self, error: str) -> None:
"""Show error state."""
self.update_content(f"**Error:** {error}")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "btn-refresh":
self.set_loading()
self.post_message(self.RefreshRequested())
@@ -0,0 +1,110 @@
"""Problems view component for linter errors."""
from pathlib import Path
from textual.app import ComposeResult
from textual.message import Message
from textual.widgets import Label, ListItem, ListView, Static
from textual.containers import Vertical
from clide.models.problems import Problem, Severity
class ProblemItem(ListItem):
"""A single problem item."""
def __init__(self, problem: Problem) -> None:
super().__init__()
self.problem = problem
def compose(self) -> ComposeResult:
icon = self.problem.severity_icon
severity_class = self.problem.severity.value
yield Static(
f"[{severity_class}]{icon}[/] "
f"[dim]{self.problem.file_path}:{self.problem.line}[/] "
f"{self.problem.message}",
markup=True,
)
class ProblemsView(Vertical):
"""View for linter problems/diagnostics."""
DEFAULT_CSS = """
ProblemsView {
height: 100%;
}
ProblemsView .problems-header {
height: 1;
background: $surface;
padding: 0 1;
}
ProblemsView ListView {
height: 1fr;
}
ProblemsView .error { color: $error; }
ProblemsView .warning { color: $warning; }
ProblemsView .info { color: $primary; }
ProblemsView .hint { color: $secondary; }
ProblemsView .empty-message {
padding: 2;
text-align: center;
color: $success;
}
"""
class ProblemClicked(Message):
"""Emitted when a problem is clicked."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, problems: list[Problem] | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._problems = problems or []
def compose(self) -> ComposeResult:
count = len(self._problems)
yield Static(f"Problems ({count})", classes="problems-header", id="problems-header")
if self._problems:
yield ListView(
*[ProblemItem(p) for p in self._problems],
id="problems-list",
)
else:
yield Static("No problems found ✓", classes="empty-message")
def update_problems(self, problems: list[Problem]) -> None:
"""Update the problems list."""
self._problems = problems
# Update header
header = self.query_one("#problems-header", Static)
header.update(f"Problems ({len(problems)})")
# Update list
try:
problems_list = self.query_one("#problems-list", ListView)
problems_list.clear()
for problem in problems:
problems_list.append(ProblemItem(problem))
except Exception:
# List might not exist yet, will be created on next compose
pass
def filter_by_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file."""
return [p for p in self._problems if p.file_path == path]
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, ProblemItem):
self.post_message(self.ProblemClicked(event.item.problem))
@@ -0,0 +1,140 @@
"""Terminal pane component."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Input, RichLog, Static
class TerminalPane(Vertical):
"""Simple terminal/command runner pane."""
DEFAULT_CSS = """
TerminalPane {
height: 100%;
}
TerminalPane .terminal-header {
height: 1;
background: $surface;
padding: 0 1;
}
TerminalPane RichLog {
height: 1fr;
background: $background;
}
TerminalPane Input {
dock: bottom;
height: 1;
}
TerminalPane .prompt {
color: $primary;
}
TerminalPane .output {
color: $foreground;
}
TerminalPane .error {
color: $error;
}
"""
class CommandSubmitted(Message):
"""Emitted when a command is submitted."""
def __init__(self, command: str) -> None:
self.command = command
super().__init__()
def __init__(self, cwd: Path | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._cwd = cwd or Path.cwd()
self._history: list[str] = []
self._history_index = 0
def compose(self) -> ComposeResult:
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
yield RichLog(id="terminal-log", highlight=True, markup=True)
yield Input(placeholder="Enter command...", id="terminal-input")
@property
def cwd(self) -> Path:
"""Get current working directory."""
return self._cwd
@cwd.setter
def cwd(self, path: Path) -> None:
"""Set current working directory."""
self._cwd = path
header = self.query_one(".terminal-header", Static)
header.update(f"Terminal - {path}")
def write_output(self, text: str, style: str = "output") -> None:
"""Write output to terminal.
Args:
text: Text to write
style: Style class (output, error, prompt)
"""
log = self.query_one("#terminal-log", RichLog)
if style == "error":
log.write(f"[red]{text}[/]")
elif style == "prompt":
log.write(f"[bold cyan]$ {text}[/]")
else:
log.write(text)
def write_command(self, command: str) -> None:
"""Write a command to terminal (with prompt)."""
self.write_output(command, "prompt")
def write_error(self, error: str) -> None:
"""Write an error to terminal."""
self.write_output(error, "error")
def clear(self) -> None:
"""Clear terminal output."""
log = self.query_one("#terminal-log", RichLog)
log.clear()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle command submission."""
command = event.value.strip()
if not command:
return
# Add to history
self._history.append(command)
self._history_index = len(self._history)
# Clear input
event.input.clear()
# Write command to output
self.write_command(command)
# Post message for handling
self.post_message(self.CommandSubmitted(command))
def history_up(self) -> None:
"""Navigate history up."""
if self._history and self._history_index > 0:
self._history_index -= 1
input_widget = self.query_one("#terminal-input", Input)
input_widget.value = self._history[self._history_index]
def history_down(self) -> None:
"""Navigate history down."""
input_widget = self.query_one("#terminal-input", Input)
if self._history_index < len(self._history) - 1:
self._history_index += 1
input_widget.value = self._history[self._history_index]
else:
self._history_index = len(self._history)
input_widget.clear()
+111
View File
@@ -0,0 +1,111 @@
"""TODOs view component."""
from pathlib import Path
from textual.app import ComposeResult
from textual.message import Message
from textual.widgets import Label, ListItem, ListView, Static
from textual.containers import Vertical
from clide.models.todos import TodoItem, TodoType
class TodoListItem(ListItem):
"""A single TODO item."""
def __init__(self, item: TodoItem) -> None:
super().__init__()
self.item = item
def compose(self) -> ComposeResult:
icon = self.item.type_icon
type_class = self.item.todo_type.value.lower()
yield Static(
f"[{type_class}]{icon} {self.item.todo_type.value}[/] "
f"[dim]{self.item.file_path}:{self.item.line}[/] "
f"{self.item.text}",
markup=True,
)
class TodosView(Vertical):
"""View for TODO/FIXME comments."""
DEFAULT_CSS = """
TodosView {
height: 100%;
}
TodosView .todos-header {
height: 1;
background: $surface;
padding: 0 1;
}
TodosView ListView {
height: 1fr;
}
TodosView .todo { color: $primary; }
TodosView .fixme { color: $warning; }
TodosView .hack { color: $error; }
TodosView .xxx { color: $error; }
TodosView .note { color: $secondary; }
TodosView .bug { color: $error; }
TodosView .empty-message {
padding: 2;
text-align: center;
color: $success;
}
"""
class TodoClicked(Message):
"""Emitted when a TODO is clicked."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
def __init__(self, items: list[TodoItem] | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._items = items or []
def compose(self) -> ComposeResult:
count = len(self._items)
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header")
if self._items:
yield ListView(
*[TodoListItem(item) for item in self._items],
id="todos-list",
)
else:
yield Static("No TODOs found ✓", classes="empty-message")
def update_items(self, items: list[TodoItem]) -> None:
"""Update the TODOs list."""
self._items = items
# Update header
header = self.query_one("#todos-header", Static)
header.update(f"TODOs ({len(items)})")
# Update list
try:
todos_list = self.query_one("#todos-list", ListView)
todos_list.clear()
for item in items:
todos_list.append(TodoListItem(item))
except Exception:
pass
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Filter by TODO type."""
return [i for i in self._items if i.todo_type == todo_type]
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, TodoListItem):
self.post_message(self.TodoClicked(event.item.item))
+13
View File
@@ -0,0 +1,13 @@
"""Main layout panels for Clide."""
from clide.widgets.panels.claude import ClaudePanel
from clide.widgets.panels.context import ContextPanel
from clide.widgets.panels.sidebar import SidebarPanel
from clide.widgets.panels.workspace import WorkspacePanel
__all__ = [
"ClaudePanel",
"ContextPanel",
"SidebarPanel",
"WorkspacePanel",
]
+418
View File
@@ -0,0 +1,418 @@
"""Claude panel with embedded terminal running Claude Code."""
from __future__ import annotations
import asyncio
import fcntl
import os
import pty
import shutil
import struct
import termios
from pathlib import Path
from typing import TYPE_CHECKING
import pyte
from rich.text import Text
from textual.containers import Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.strip import Strip
from textual.widget import Widget
if TYPE_CHECKING:
from textual.app import ComposeResult
class TerminalDisplay(Widget, can_focus=True):
"""A terminal emulator widget using pyte."""
DEFAULT_CSS = """
TerminalDisplay {
height: 100%;
width: 100%;
background: $background;
}
"""
def __init__(
self,
cols: int = 80,
rows: int = 24,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._cols = cols
self._rows = rows
self._screen = pyte.Screen(cols, rows)
self._stream = pyte.Stream(self._screen)
self._master_fd: int | None = None
self._pid: int | None = None
self._read_task: asyncio.Task | None = None
def on_resize(self, event) -> None:
"""Handle terminal resize."""
# Get new size from widget
new_cols = max(event.size.width, 20)
new_rows = max(event.size.height, 5)
if new_cols != self._cols or new_rows != self._rows:
self._cols = new_cols
self._rows = new_rows
self._screen.resize(new_rows, new_cols)
# Update PTY size if running
if self._master_fd is not None:
self._set_pty_size(self._master_fd, new_rows, new_cols)
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
"""Set the PTY window size."""
try:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
except OSError:
pass
def start(self, command: str, cwd: str) -> None:
"""Start a process in the terminal."""
# Fork a PTY
pid, master_fd = pty.fork()
if pid == 0:
# Child process
os.chdir(cwd)
os.environ["TERM"] = "xterm-256color"
os.environ["COLORTERM"] = "truecolor"
os.execlp(command, command)
else:
# Parent process
self._pid = pid
self._master_fd = master_fd
# Set non-blocking
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Set initial size
self._set_pty_size(master_fd, self._rows, self._cols)
# Start reading
self._read_task = asyncio.create_task(self._read_output())
async def _read_output(self) -> None:
"""Read output from the PTY."""
if self._master_fd is None:
return
loop = asyncio.get_event_loop()
while True:
try:
# Wait for data to be available
await asyncio.sleep(0.01)
try:
data = os.read(self._master_fd, 65536)
if not data:
break
# Feed data to pyte
self._stream.feed(data.decode("utf-8", errors="replace"))
self.refresh()
except BlockingIOError:
# No data available
continue
except OSError:
# PTY closed
break
except asyncio.CancelledError:
break
def stop(self) -> None:
"""Stop the terminal process."""
if self._read_task:
self._read_task.cancel()
self._read_task = None
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
if self._pid is not None:
try:
os.kill(self._pid, 9)
os.waitpid(self._pid, 0)
except (OSError, ChildProcessError):
pass
self._pid = None
def is_running(self) -> bool:
"""Check if the process is still running."""
if self._pid is None:
return False
try:
pid, status = os.waitpid(self._pid, os.WNOHANG)
if pid == 0:
return True # Still running
else:
self._pid = None
return False
except ChildProcessError:
self._pid = None
return False
def send(self, data: str) -> None:
"""Send data to the terminal."""
if self._master_fd is not None:
try:
os.write(self._master_fd, data.encode("utf-8"))
except OSError:
pass
def render_line(self, y: int) -> Strip:
"""Render a line of the terminal."""
if y >= self._rows:
return Strip.blank(self._cols)
line = self._screen.buffer[y]
text = Text()
for x in range(self._cols):
char = line[x]
char_data = char.data if char.data else " "
# Build style from pyte character attributes
style_parts = []
if char.fg and char.fg != "default":
style_parts.append(f"color({char.fg})" if char.fg.startswith("#") else char.fg)
if char.bg and char.bg != "default":
style_parts.append(f"on color({char.bg})" if char.bg.startswith("#") else f"on {char.bg}")
if char.bold:
style_parts.append("bold")
if char.italics:
style_parts.append("italic")
if char.underscore:
style_parts.append("underline")
if char.reverse:
style_parts.append("reverse")
style = " ".join(style_parts) if style_parts else None
text.append(char_data, style=style)
# Render text to segments for Strip
segments = list(text.render(self.app.console))
return Strip(segments)
def on_key(self, event) -> None:
"""Handle key presses."""
# Map special keys
key_map = {
"enter": "\r",
"tab": "\t",
"backspace": "\x7f",
"delete": "\x1b[3~",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"home": "\x1b[H",
"end": "\x1b[F",
"pageup": "\x1b[5~",
"pagedown": "\x1b[6~",
"escape": "\x1b",
}
if event.key in key_map:
self.send(key_map[event.key])
event.prevent_default()
event.stop()
elif event.key == "ctrl+c":
self.send("\x03")
event.prevent_default()
event.stop()
elif event.key == "ctrl+d":
self.send("\x04")
event.prevent_default()
event.stop()
elif event.key == "ctrl+z":
self.send("\x1a")
event.prevent_default()
event.stop()
elif event.key == "ctrl+l":
self.send("\x0c")
event.prevent_default()
event.stop()
elif event.character and len(event.character) == 1:
self.send(event.character)
event.prevent_default()
event.stop()
class ClaudePanel(Vertical):
"""Terminal panel running Claude Code CLI.
Automatically starts Claude Code and restarts when it exits.
Takes 100% height when workspace is hidden, 40% when visible.
"""
DEFAULT_CSS = """
ClaudePanel {
height: 100%;
background: $background;
}
ClaudePanel.with-workspace {
height: 40%;
}
ClaudePanel TerminalDisplay {
height: 100%;
}
"""
class ClaudeExited(Message):
"""Emitted when Claude Code process exits."""
def __init__(self, return_code: int) -> None:
self.return_code = return_code
super().__init__()
class ClaudeStarted(Message):
"""Emitted when Claude Code process starts."""
pass
# Reactive state
workspace_visible: reactive[bool] = reactive(False)
def __init__(
self,
workdir: Path | None = None,
auto_start: bool = True,
restart_on_exit: bool = True,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.id = "panel-claude"
self._workdir = workdir or Path.cwd()
self._auto_start = auto_start
self._restart_on_exit = restart_on_exit
self._claude_command = self._find_claude_command()
self._terminal: TerminalDisplay | None = None
self._monitor_task: asyncio.Task | None = None
def _find_claude_command(self) -> str:
"""Find the Claude Code CLI command."""
# Check common locations
claude_paths = [
"claude", # In PATH
str(Path.home() / ".claude" / "local" / "claude"),
str(Path.home() / ".local" / "bin" / "claude"),
"/usr/local/bin/claude",
]
for path in claude_paths:
if path and shutil.which(path):
return path
# Fallback to 'claude' and hope it's in PATH
return "claude"
def compose(self) -> ComposeResult:
self._terminal = TerminalDisplay(id="claude-terminal")
yield self._terminal
def on_mount(self) -> None:
"""Start Claude Code when mounted."""
if self._auto_start:
self.call_later(self.start_claude)
def watch_workspace_visible(self, visible: bool) -> None:
"""Adjust height based on workspace visibility."""
if visible:
self.add_class("with-workspace")
else:
self.remove_class("with-workspace")
def start_claude(self) -> None:
"""Start the Claude Code CLI in the terminal."""
if self._terminal is None:
return
# Start Claude Code with the working directory
self._terminal.start(
self._claude_command,
str(self._workdir),
)
self.post_message(self.ClaudeStarted())
# Start monitoring for exit
if self._monitor_task:
self._monitor_task.cancel()
self._monitor_task = asyncio.create_task(self._monitor_claude())
async def _monitor_claude(self) -> None:
"""Monitor Claude process and restart if needed."""
if self._terminal is None:
return
# Wait for the terminal process to exit
while True:
await asyncio.sleep(1)
# Check if process has exited
if not self._terminal.is_running():
self.post_message(self.ClaudeExited(0))
if self._restart_on_exit:
# Wait a moment before restarting
await asyncio.sleep(0.5)
self.start_claude()
break
def stop_claude(self) -> None:
"""Stop the Claude Code CLI."""
if self._terminal:
self._terminal.stop()
if self._monitor_task:
self._monitor_task.cancel()
self._monitor_task = None
def send_input(self, text: str) -> None:
"""Send input to the Claude terminal."""
if self._terminal:
self._terminal.send(text)
def send_interrupt(self) -> None:
"""Send Ctrl+C interrupt to Claude."""
if self._terminal:
self._terminal.send("\x03") # Ctrl+C
def focus_terminal(self) -> None:
"""Focus the terminal."""
if self._terminal:
self._terminal.focus()
@property
def workdir(self) -> Path:
"""Get the working directory."""
return self._workdir
@workdir.setter
def workdir(self, path: Path) -> None:
"""Set the working directory (requires restart)."""
self._workdir = path
def set_restart_on_exit(self, restart: bool) -> None:
"""Set whether to restart Claude when it exits."""
self._restart_on_exit = restart
+213
View File
@@ -0,0 +1,213 @@
"""Context panel with Problems, TODOs, and Jira tabs."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Static, TabbedContent, TabPane
from clide.models.problems import Problem
from clide.models.todos import TodoItem
from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.todos_view import TodosView
class ContextPanel(Vertical):
"""Right context panel with Problems, TODOs, and Jira integration."""
DEFAULT_CSS = """
ContextPanel {
width: 25%;
min-width: 30;
height: 100%;
background: $surface;
}
ContextPanel TabbedContent {
height: 1fr;
}
ContextPanel .context-tab-bar {
dock: bottom;
height: 1;
background: $panel;
padding: 0 1;
}
ContextPanel .tab-count {
margin: 0 1;
}
ContextPanel .error-count {
color: $error;
}
ContextPanel .warning-count {
color: $warning;
}
ContextPanel .success-count {
color: $success;
}
"""
class ProblemClicked(Message):
"""Emitted when a problem is clicked."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
class TodoClicked(Message):
"""Emitted when a TODO is clicked."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
class JiraRefreshRequested(Message):
"""Emitted when Jira refresh is requested."""
pass
# Reactive state with counts for tab badges
problem_count: reactive[int] = reactive(0)
todo_count: reactive[int] = reactive(0)
visible: reactive[bool] = reactive(True)
def __init__(
self,
jira_enabled: bool = True,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._jira_enabled = jira_enabled
self.id = "panel-context"
def compose(self) -> ComposeResult:
with TabbedContent(id="context-tabs"):
with TabPane("Problems", id="context-problems"):
yield ProblemsView(id="problems-view")
with TabPane("TODOs", id="context-todos"):
yield TodosView(id="todos-view")
with TabPane("Jira", id="context-jira"):
yield JiraView(enabled=self._jira_enabled, id="jira-view")
# Tab bar with counts at bottom
with Horizontal(classes="context-tab-bar"):
yield Static("", id="tab-counts")
def on_mount(self) -> None:
"""Initialize tab counts."""
self._update_tab_counts()
def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes."""
self.display = visible
def watch_problem_count(self, count: int) -> None:
"""Update problem count display."""
self._update_tab_counts()
def watch_todo_count(self, count: int) -> None:
"""Update todo count display."""
self._update_tab_counts()
def _update_tab_counts(self) -> None:
"""Update the tab counts display."""
try:
counts = self.query_one("#tab-counts", Static)
problem_style = "error-count" if self.problem_count > 0 else "success-count"
todo_style = "warning-count" if self.todo_count > 0 else "success-count"
# Build count display
parts = []
if self.problem_count > 0:
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
else:
parts.append(f"[{problem_style}]✓ 0[/]")
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
counts.update("".join(parts))
except Exception:
pass
def update_problems(self, problems: list[Problem]) -> None:
"""Update problems view and count."""
self.problem_count = len(problems)
try:
view = self.query_one("#problems-view", ProblemsView)
view.update_problems(problems)
except Exception:
pass
def update_todos(self, items: list[TodoItem]) -> None:
"""Update TODOs view and count."""
self.todo_count = len(items)
try:
view = self.query_one("#todos-view", TodosView)
view.update_items(items)
except Exception:
pass
def update_jira(self, content: str) -> None:
"""Update Jira view content."""
try:
view = self.query_one("#jira-view", JiraView)
view.update_content(content)
except Exception:
pass
def set_jira_loading(self) -> None:
"""Set Jira view to loading state."""
try:
view = self.query_one("#jira-view", JiraView)
view.set_loading()
except Exception:
pass
def set_jira_error(self, error: str) -> None:
"""Set Jira view to error state."""
try:
view = self.query_one("#jira-view", JiraView)
view.set_error(error)
except Exception:
pass
def focus_tab(self, tab_id: str) -> None:
"""Focus a specific tab."""
tabs = self.query_one("#context-tabs", TabbedContent)
tabs.active = f"context-{tab_id}"
def focus_problems(self) -> None:
"""Focus problems tab."""
self.focus_tab("problems")
def focus_todos(self) -> None:
"""Focus TODOs tab."""
self.focus_tab("todos")
def focus_jira(self) -> None:
"""Focus Jira tab."""
self.focus_tab("jira")
# Event forwarding
def on_problems_view_problem_clicked(
self,
event: ProblemsView.ProblemClicked,
) -> None:
"""Forward problem click."""
self.post_message(self.ProblemClicked(event.problem))
def on_todos_view_todo_clicked(self, event: TodosView.TodoClicked) -> None:
"""Forward todo click."""
self.post_message(self.TodoClicked(event.item))
def on_jira_view_refresh_requested(
self,
event: JiraView.RefreshRequested,
) -> None:
"""Forward Jira refresh request."""
self.post_message(self.JiraRefreshRequested())
+158
View File
@@ -0,0 +1,158 @@
"""Sidebar panel with Files, Git, and Tree tabs."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Static, TabbedContent, TabPane
from clide.widgets.components.branch_status import BranchStatus
from clide.widgets.components.files_view import FilesView
from clide.widgets.components.git_changes import GitChangesView
from clide.widgets.components.git_graph import GitGraphView
class SidebarPanel(Vertical):
"""Left sidebar panel with file browser, git changes, and git graph."""
DEFAULT_CSS = """
SidebarPanel {
width: 20%;
min-width: 25;
height: 100%;
background: $surface;
}
SidebarPanel TabbedContent {
height: 1fr;
}
SidebarPanel .sidebar-content {
height: 1fr;
}
SidebarPanel BranchStatus {
dock: bottom;
height: auto;
}
"""
class FileSelected(Message):
"""Emitted when a file is selected from the file browser."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class GitFileSelected(Message):
"""Emitted when a file is selected from git changes."""
def __init__(self, path: Path, staged: bool) -> None:
self.path = path
self.staged = staged
super().__init__()
class BranchChanged(Message):
"""Emitted when the branch is changed."""
def __init__(self, branch: str) -> None:
self.branch = branch
super().__init__()
# Reactive state
current_branch: reactive[str] = reactive("main")
visible: reactive[bool] = reactive(True)
def __init__(
self,
workdir: Path | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._workdir = workdir or Path.cwd()
self.id = "panel-sidebar"
def compose(self) -> ComposeResult:
with Container(classes="sidebar-content"):
with TabbedContent(id="sidebar-tabs"):
with TabPane("Files", id="sidebar-files"):
yield FilesView(path=self._workdir)
with TabPane("Git", id="sidebar-git"):
yield GitChangesView()
with TabPane("Tree", id="sidebar-tree"):
yield GitGraphView()
yield BranchStatus(current_branch=self.current_branch)
def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes."""
self.display = visible
def watch_current_branch(self, branch: str) -> None:
"""Update branch status when branch changes."""
try:
branch_status = self.query_one(BranchStatus)
branch_status.branch = branch
except Exception:
pass
def update_git_status(
self,
staged: list,
unstaged: list,
) -> None:
"""Update git changes view."""
try:
git_view = self.query_one(GitChangesView)
git_view.update_changes(staged, unstaged)
except Exception:
pass
def update_git_graph(self, commits: list) -> None:
"""Update git graph view."""
try:
graph = self.query_one(GitGraphView)
graph.update_graph(commits)
except Exception:
pass
def update_branches(self, branches: list[str]) -> None:
"""Update available branches."""
try:
branch_status = self.query_one(BranchStatus)
branch_status.update_branches(branches)
except Exception:
pass
def refresh_files(self) -> None:
"""Refresh file browser."""
try:
files_view = self.query_one(FilesView)
files_view.reload()
except Exception:
pass
def focus_tab(self, tab_id: str) -> None:
"""Focus a specific tab."""
tabs = self.query_one("#sidebar-tabs", TabbedContent)
tabs.active = tab_id
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
"""Forward file selection."""
self.post_message(self.FileSelected(event.path))
def on_git_changes_view_file_clicked(
self,
event: GitChangesView.FileClicked,
) -> None:
"""Forward git file selection."""
self.post_message(self.GitFileSelected(event.path, event.staged))
def on_branch_status_branch_changed(
self,
event: BranchStatus.BranchChanged,
) -> None:
"""Forward branch change."""
self.current_branch = event.branch
self.post_message(self.BranchChanged(event.branch))
+236
View File
@@ -0,0 +1,236 @@
"""Workspace panel with Editor, Diff, and Terminal tabs."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import TabbedContent, TabPane
from clide.models.diff import DiffContent
from clide.widgets.components.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.terminal_pane import TerminalPane
class WorkspacePanel(Vertical):
"""Center workspace panel with Editor, Diff, and Terminal tabs.
Hidden by default. Shows when:
- File is opened
- Diff is displayed
- Terminal is activated
"""
DEFAULT_CSS = """
WorkspacePanel {
height: 60%;
background: $background;
}
WorkspacePanel.hidden {
display: none;
}
WorkspacePanel TabbedContent {
height: 100%;
}
WorkspacePanel TabPane {
height: 100%;
padding: 0;
}
"""
class FileSaved(Message):
"""Emitted when a file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class DiffAccepted(Message):
"""Emitted when a diff is accepted."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
class DiffRejected(Message):
"""Emitted when a diff is rejected."""
def __init__(self, file_path: str) -> None:
self.file_path = file_path
super().__init__()
class CommandSubmitted(Message):
"""Emitted when a terminal command is submitted."""
def __init__(self, command: str) -> None:
self.command = command
super().__init__()
class CloseRequested(Message):
"""Emitted when workspace should be hidden."""
pass
# Reactive state - persisted when hidden
visible: reactive[bool] = reactive(False)
active_tab: reactive[str] = reactive("editor")
def __init__(
self,
workdir: Path | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._workdir = workdir or Path.cwd()
self.id = "panel-workspace"
def compose(self) -> ComposeResult:
with TabbedContent(id="workspace-tabs"):
with TabPane("Editor", id="workspace-editor"):
yield EditorPane(id="editor-pane")
with TabPane("Diff", id="workspace-diff"):
yield DiffPane(id="diff-pane")
with TabPane("Terminal", id="workspace-terminal"):
yield TerminalPane(cwd=self._workdir, id="terminal-pane")
def on_mount(self) -> None:
"""Set initial visibility."""
self._update_visibility()
def watch_visible(self, visible: bool) -> None:
"""Handle visibility changes - hide, don't destroy."""
self._update_visibility()
def _update_visibility(self) -> None:
"""Update display based on visibility state."""
if self.visible:
self.remove_class("hidden")
else:
self.add_class("hidden")
def show(self, tab: str | None = None) -> None:
"""Show workspace, optionally focusing a specific tab."""
self.visible = True
if tab:
self.focus_tab(tab)
def hide(self) -> None:
"""Hide workspace (state is preserved)."""
self.visible = False
def toggle(self) -> None:
"""Toggle workspace visibility."""
self.visible = not self.visible
def focus_tab(self, tab_id: str) -> None:
"""Focus a specific tab."""
tabs = self.query_one("#workspace-tabs", TabbedContent)
tabs.active = f"workspace-{tab_id}"
self.active_tab = tab_id
# Editor methods
def open_file(self, path: Path, line: int | None = None) -> None:
"""Open a file in the editor tab."""
self.show("editor")
try:
editor = self.query_one("#editor-pane", EditorPane)
editor.load_file(path, goto_line=line)
except Exception:
pass
def get_current_file(self) -> Path | None:
"""Get the currently open file."""
try:
editor = self.query_one("#editor-pane", EditorPane)
return editor.current_file
except Exception:
return None
def has_unsaved_changes(self) -> bool:
"""Check if editor has unsaved changes."""
try:
editor = self.query_one("#editor-pane", EditorPane)
return editor.modified
except Exception:
return False
# Diff methods
def show_diff(
self,
diff: DiffContent,
is_proposal: bool = False,
) -> None:
"""Show a diff in the diff tab."""
self.show("diff")
try:
diff_pane = self.query_one("#diff-pane", DiffPane)
diff_pane.load_diff(diff, is_proposal)
except Exception:
pass
def clear_diff(self) -> None:
"""Clear the diff view."""
try:
diff_pane = self.query_one("#diff-pane", DiffPane)
diff_pane.clear()
except Exception:
pass
# Terminal methods
def show_terminal(self) -> None:
"""Show and focus the terminal tab."""
self.show("terminal")
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal_input = terminal.query_one("#terminal-input")
terminal_input.focus()
except Exception:
pass
def write_terminal_output(self, text: str, style: str = "output") -> None:
"""Write output to terminal."""
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal.write_output(text, style)
except Exception:
pass
def write_terminal_error(self, error: str) -> None:
"""Write error to terminal."""
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal.write_error(error)
except Exception:
pass
def clear_terminal(self) -> None:
"""Clear terminal output."""
try:
terminal = self.query_one("#terminal-pane", TerminalPane)
terminal.clear()
except Exception:
pass
# Event forwarding
def on_editor_pane_file_saved(self, event: EditorPane.FileSaved) -> None:
"""Forward file save event."""
self.post_message(self.FileSaved(event.path))
def on_diff_pane_accept_clicked(self, event: DiffPane.AcceptClicked) -> None:
"""Forward diff accept event."""
self.post_message(self.DiffAccepted(event.file_path))
def on_diff_pane_reject_clicked(self, event: DiffPane.RejectClicked) -> None:
"""Forward diff reject event."""
self.post_message(self.DiffRejected(event.file_path))
def on_terminal_pane_command_submitted(
self,
event: TerminalPane.CommandSubmitted,
) -> None:
"""Forward terminal command event."""
self.post_message(self.CommandSubmitted(event.command))
+1
View File
@@ -0,0 +1 @@
"""Clide test suite."""
+81
View File
@@ -0,0 +1,81 @@
"""Shared pytest fixtures for Clide tests."""
from pathlib import Path
from typing import AsyncGenerator, Generator
from unittest.mock import MagicMock
import pytest
from textual.pilot import Pilot
from clide.app import ClideApp
from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings
from tests.harnesses.app_harness import AppHarness
from tests.harnesses.controller_harness import ControllerHarness
@pytest.fixture
def temp_workdir(tmp_path: Path) -> Path:
"""Create a temporary working directory with sample files."""
# Create sample directory structure
(tmp_path / "src").mkdir()
(tmp_path / "tests").mkdir()
(tmp_path / "src" / "main.py").write_text("# Main file")
(tmp_path / "README.md").write_text("# Test Project")
# Initialize git repo
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def mock_settings() -> ClideSettings:
"""Create test settings with defaults."""
return ClideSettings(
theme="dark",
claude_path="/usr/bin/echo", # Safe mock
auto_save=False,
confirm_exit=False,
)
@pytest.fixture
def mock_extension_manager() -> ExtensionManager:
"""Create an extension manager without loading external extensions."""
manager = ExtensionManager()
# Don't load entry points in tests
return manager
@pytest.fixture
def app_harness(
temp_workdir: Path,
mock_settings: ClideSettings,
mock_extension_manager: ExtensionManager,
) -> Generator[AppHarness, None, None]:
"""Create a full application test harness."""
harness = AppHarness(
workdir=temp_workdir,
settings=mock_settings,
extension_manager=mock_extension_manager,
)
yield harness
@pytest.fixture
async def running_app(
app_harness: AppHarness,
) -> AsyncGenerator[tuple[ClideApp, Pilot], None]:
"""Start the app and yield (app, pilot) for interaction."""
app, pilot = await app_harness.start()
try:
yield app, pilot
finally:
await app_harness.stop()
@pytest.fixture
def controller_harness() -> ControllerHarness:
"""Create an isolated controller test harness."""
return ControllerHarness()
+6
View File
@@ -0,0 +1,6 @@
"""Test harnesses for Clide testing."""
from tests.harnesses.app_harness import AppHarness
from tests.harnesses.controller_harness import ControllerHarness
__all__ = ["AppHarness", "ControllerHarness"]
+84
View File
@@ -0,0 +1,84 @@
"""Full application test harness for Clide."""
from pathlib import Path
from typing import Optional
from textual.pilot import Pilot
from clide.app import ClideApp
from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings
class AppHarness:
"""Test harness for running the full Clide application.
Provides a controlled environment for integration testing with
mocked services and isolated file systems.
Usage:
harness = AppHarness(workdir=tmp_path)
app, pilot = await harness.start()
await pilot.press("ctrl+q")
await harness.stop()
"""
def __init__(
self,
workdir: Path,
settings: Optional[ClideSettings] = None,
extension_manager: Optional[ExtensionManager] = None,
) -> None:
self.workdir = workdir
self.settings = settings or ClideSettings()
self.extension_manager = extension_manager or ExtensionManager()
self._app: Optional[ClideApp] = None
self._pilot: Optional[Pilot] = None
async def start(self) -> tuple[ClideApp, Pilot]:
"""Start the application and return app and pilot for testing.
Returns:
Tuple of (ClideApp instance, Pilot for simulating input)
"""
self._app = ClideApp(workdir=self.workdir)
# Inject test dependencies
self._app.extension_manager = self.extension_manager
# Start app in test mode
async with self._app.run_test() as pilot:
self._pilot = pilot
return self._app, pilot
async def stop(self) -> None:
"""Clean shutdown of the application."""
if self._app:
await self._app.action_quit()
self._app = None
self._pilot = None
@property
def app(self) -> ClideApp:
"""Get the running app instance."""
if self._app is None:
raise RuntimeError("App not started. Call start() first.")
return self._app
@property
def pilot(self) -> Pilot:
"""Get the pilot for simulating user input."""
if self._pilot is None:
raise RuntimeError("App not started. Call start() first.")
return self._pilot
async def press_keys(self, *keys: str) -> None:
"""Simulate pressing a sequence of keys."""
await self.pilot.press(*keys)
async def click(self, selector: str) -> None:
"""Click on a widget by CSS selector."""
await self.pilot.click(selector)
async def wait_for_animation(self) -> None:
"""Wait for any running animations to complete."""
await self.pilot.pause()
+97
View File
@@ -0,0 +1,97 @@
"""Controller isolation test harness for Clide."""
from typing import TYPE_CHECKING, Any, Optional
from unittest.mock import AsyncMock, MagicMock
from textual.message import Message
if TYPE_CHECKING:
from clide.controllers.base import BaseController
class MockApp:
"""Minimal mock of a Textual App for controller testing."""
def __init__(self) -> None:
self.messages: list[Message] = []
self.post_message = MagicMock(side_effect=self._capture_message)
def _capture_message(self, message: Message) -> None:
self.messages.append(message)
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
"""Get captured messages, optionally filtered by type."""
if message_type is None:
return self.messages.copy()
return [m for m in self.messages if isinstance(m, message_type)]
def clear_messages(self) -> None:
"""Clear captured messages."""
self.messages.clear()
class ControllerHarness:
"""Test harness for isolated controller testing.
Provides a mock app environment for testing controllers without
the full Textual application overhead.
Usage:
harness = ControllerHarness()
controller = GitController(harness.mock_app)
await controller.initialize()
await controller.refresh_status()
messages = harness.get_messages()
"""
def __init__(self) -> None:
self._mock_app = MockApp()
self._controllers: list["BaseController"] = []
self._mocks: dict[str, Any] = {}
@property
def mock_app(self) -> MockApp:
"""Get the mock app for controller injection."""
return self._mock_app
def register_controller(self, controller: "BaseController") -> None:
"""Register a controller for lifecycle management."""
self._controllers.append(controller)
async def initialize_all(self) -> None:
"""Initialize all registered controllers."""
for controller in self._controllers:
await controller.initialize()
async def shutdown_all(self) -> None:
"""Shutdown all registered controllers."""
for controller in self._controllers:
await controller.shutdown()
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
"""Get messages posted to the mock app."""
return self._mock_app.get_messages(message_type)
def clear_messages(self) -> None:
"""Clear all captured messages."""
self._mock_app.clear_messages()
def add_mock(self, name: str, mock: Any) -> None:
"""Add a named mock for dependency injection.
Args:
name: Identifier for the mock
mock: Mock object or AsyncMock
"""
self._mocks[name] = mock
def get_mock(self, name: str) -> Any:
"""Retrieve a named mock."""
return self._mocks.get(name)
def create_async_mock(self, return_value: Any = None) -> AsyncMock:
"""Create an AsyncMock with optional return value."""
mock = AsyncMock()
if return_value is not None:
mock.return_value = return_value
return mock
+1
View File
@@ -0,0 +1 @@
"""Integration tests for Clide."""
+150
View File
@@ -0,0 +1,150 @@
"""Integration tests for FilesView widget."""
from pathlib import Path
import pytest
from textual.app import App, ComposeResult
from textual.widgets import Static
from clide.widgets.components.files_view import FilesView
class FilesViewTestApp(App):
"""Test app for FilesView."""
def __init__(self, path: Path) -> None:
super().__init__()
self.test_path = path
self.selected_files: list[Path] = []
self.selected_dirs: list[Path] = []
def compose(self) -> ComposeResult:
yield FilesView(self.test_path, id="files")
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
self.selected_files.append(event.path)
def on_files_view_directory_selected(self, event: FilesView.DirectorySelected) -> None:
self.selected_dirs.append(event.path)
@pytest.fixture
def test_directory(tmp_path: Path) -> Path:
"""Create a test directory structure."""
# Create directories
(tmp_path / "src").mkdir()
(tmp_path / "src" / "components").mkdir()
(tmp_path / "tests").mkdir()
# Create files
(tmp_path / "README.md").write_text("# Test")
(tmp_path / "src" / "main.py").write_text("print('hello')")
(tmp_path / "src" / "components" / "button.py").write_text("class Button: pass")
(tmp_path / "tests" / "test_main.py").write_text("def test_main(): pass")
return tmp_path
async def test_files_view_renders(test_directory: Path):
"""Test that FilesView renders without errors."""
app = FilesViewTestApp(test_directory)
async with app.run_test() as pilot:
files_view = app.query_one("#files", FilesView)
assert files_view is not None
assert files_view.path == test_directory
async def test_files_view_shows_files(test_directory: Path):
"""Test that FilesView shows files in the directory."""
app = FilesViewTestApp(test_directory)
async with app.run_test() as pilot:
files_view = app.query_one("#files", FilesView)
# The root should be loaded
assert files_view.root is not None
async def test_directory_click_expands(test_directory: Path):
"""Test that clicking a directory expands it and emits event."""
app = FilesViewTestApp(test_directory)
async with app.run_test() as pilot:
files_view = app.query_one("#files", FilesView)
# Wait for initial load
await pilot.pause()
# Find the src directory node and click it
for node in files_view.root.children:
if node.data and node.data.path.name == "src":
files_view.select_node(node)
await pilot.pause()
break
# Check that directory was selected
assert len(app.selected_dirs) >= 1
assert any(p.name == "src" for p in app.selected_dirs)
async def test_file_click_emits_event(test_directory: Path):
"""Test that clicking a file emits FileSelected event."""
app = FilesViewTestApp(test_directory)
async with app.run_test() as pilot:
files_view = app.query_one("#files", FilesView)
# Wait for initial load
await pilot.pause()
# Find and click README.md
for node in files_view.root.children:
if node.data and node.data.path.name == "README.md":
files_view.select_node(node)
await pilot.pause()
break
# Check that file was selected
assert len(app.selected_files) >= 1
assert any(p.name == "README.md" for p in app.selected_files)
async def test_filter_paths_hides_hidden_files(test_directory: Path):
"""Test that hidden files are filtered out."""
# Create hidden files/dirs
(test_directory / ".git").mkdir()
(test_directory / ".hidden_file").write_text("hidden")
(test_directory / "__pycache__").mkdir()
app = FilesViewTestApp(test_directory)
async with app.run_test() as pilot:
files_view = app.query_one("#files", FilesView)
# Wait for initial load
await pilot.pause()
# Check that hidden items are not in the tree
visible_names = {
node.data.path.name
for node in files_view.root.children
if node.data
}
assert ".git" not in visible_names
assert ".hidden_file" not in visible_names
assert "__pycache__" not in visible_names
assert "src" in visible_names
assert "README.md" in visible_names
async def test_render_label_shows_icons(test_directory: Path):
"""Test that render_label produces proper icons."""
app = FilesViewTestApp(test_directory)
async with app.run_test() as pilot:
files_view = app.query_one("#files", FilesView)
# Wait for initial load
await pilot.pause()
# Check that nodes have labels rendered (icons are part of label)
for node in files_view.root.children:
if node.data:
# The label should contain the filename
label_text = str(node.label)
assert node.data.path.name in label_text
+1
View File
@@ -0,0 +1 @@
"""Snapshot (visual regression) tests for Clide."""
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

+37
View File
@@ -0,0 +1,37 @@
"""Snapshot tests for Clide UI."""
from clide.app import ClideApp
def test_initial_layout(snap_compare) -> None:
"""Test the initial application layout renders correctly."""
app = ClideApp(test_mode=True)
assert snap_compare(app, terminal_size=(120, 40))
def test_layout_without_right_panel(snap_compare) -> None:
"""Test layout with right panel hidden."""
async def hide_right_panel(pilot):
await pilot.press("f2")
app = ClideApp(test_mode=True)
assert snap_compare(
app,
terminal_size=(120, 40),
run_before=hide_right_panel,
)
def test_layout_without_left_panel(snap_compare) -> None:
"""Test layout with left panel hidden."""
async def hide_left_panel(pilot):
await pilot.press("f1")
app = ClideApp(test_mode=True)
assert snap_compare(
app,
terminal_size=(120, 40),
run_before=hide_left_panel,
)
+1
View File
@@ -0,0 +1 @@
"""Unit tests for Clide."""
+187
View File
@@ -0,0 +1,187 @@
"""Tests for ClideApp."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from clide.models.config import ClideSettings
class TestClideAppInit:
"""Tests for ClideApp initialization."""
def test_default_initialization(self):
"""Test default initialization."""
from clide.app import ClideApp
app = ClideApp()
assert app.workdir == Path.cwd()
assert isinstance(app.settings, ClideSettings)
# Note: reactive properties can't be tested directly without running the app
# because watchers try to query the DOM
def test_initialization_with_workdir(self, tmp_path: Path):
"""Test initialization with custom workdir."""
from clide.app import ClideApp
app = ClideApp(workdir=tmp_path)
assert app.workdir == tmp_path
def test_initialization_with_settings(self):
"""Test initialization with custom settings."""
from clide.app import ClideApp
settings = ClideSettings(theme="dracula", jira_enabled=True)
app = ClideApp(settings=settings)
assert app.settings.theme == "dracula"
assert app.settings.jira_enabled is True
def test_controllers_initialized(self, tmp_path: Path):
"""Test that all controllers are initialized."""
from clide.app import ClideApp
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
app = ClideApp(workdir=tmp_path)
assert isinstance(app.git_controller, GitController)
assert isinstance(app.editor_controller, EditorController)
assert isinstance(app.diff_controller, DiffController)
assert isinstance(app.problems_controller, ProblemsController)
assert isinstance(app.todos_controller, TodosController)
assert isinstance(app.jira_controller, JiraController)
def test_jira_controller_enabled_from_settings(self):
"""Test Jira controller uses settings."""
from clide.app import ClideApp
settings = ClideSettings(jira_enabled=True)
app = ClideApp(settings=settings)
assert app.jira_controller.enabled is True
settings_disabled = ClideSettings(jira_enabled=False)
app_disabled = ClideApp(settings=settings_disabled)
assert app_disabled.jira_controller.enabled is False
class TestClideAppBindings:
"""Tests for ClideApp keybindings."""
def test_bindings_defined(self):
"""Test that keybindings are defined."""
from clide.app import ClideApp
app = ClideApp()
bindings = {b.key for b in app.BINDINGS}
# Check key bindings exist
assert "ctrl+q" in bindings
assert "ctrl+b" in bindings
assert "ctrl+shift+p" in bindings
assert "ctrl+`" in bindings
assert "ctrl+1" in bindings
assert "f11" in bindings
assert "escape" in bindings
class TestClideAppMeta:
"""Tests for ClideApp metadata."""
def test_title(self):
"""Test app title."""
from clide.app import ClideApp
app = ClideApp()
assert app.TITLE == "Clide"
assert app.SUB_TITLE == "Claude Code IDE"
def test_css_defined(self):
"""Test CSS is defined."""
from clide.app import ClideApp
assert ClideApp.CSS
assert "#main-container" in ClideApp.CSS
assert "SidebarPanel" in ClideApp.CSS
assert "ContextPanel" in ClideApp.CSS
assert "WorkspacePanel" in ClideApp.CSS
assert "ClaudePanel" in ClideApp.CSS
class TestClideAppReactive:
"""Tests for ClideApp reactive properties.
Note: Most reactive property tests require running the app
because accessing them triggers watchers that query the DOM.
These tests verify the property definitions exist.
"""
def test_reactive_properties_defined(self):
"""Test that reactive properties are defined."""
from clide.app import ClideApp
# Check the reactive descriptors exist on the class
assert hasattr(ClideApp, "workspace_visible")
assert hasattr(ClideApp, "compact_mode")
assert hasattr(ClideApp, "fullscreen_panel")
assert hasattr(ClideApp, "current_file")
class TestClideAppThemes:
"""Tests for ClideApp theme registration."""
def test_themes_registered(self):
"""Test that themes are registered."""
from clide.app import ClideApp
app = ClideApp()
# Default theme should be set
assert app.theme == "summer-night"
def test_custom_theme_from_settings(self):
"""Test that custom theme from settings is applied."""
from clide.app import ClideApp
settings = ClideSettings(theme="dracula")
app = ClideApp(settings=settings)
assert app.theme == "dracula"
class TestClideAppActions:
"""Tests for ClideApp action methods.
Note: Action methods that modify reactive properties or
query the DOM cannot be fully tested without running the app.
"""
def test_action_methods_exist(self):
"""Test that action methods are defined."""
from clide.app import ClideApp
app = ClideApp()
# Verify action methods exist
assert hasattr(app, "action_toggle_compact")
assert hasattr(app, "action_toggle_sidebar")
assert hasattr(app, "action_toggle_context")
assert hasattr(app, "action_toggle_terminal")
assert hasattr(app, "action_focus_claude")
assert callable(app.action_toggle_compact)
class TestClideSettings:
"""Tests for ClideSettings."""
def test_default_settings(self):
"""Test default settings values."""
settings = ClideSettings()
assert settings.theme == "summer-night"
assert settings.jira_enabled is False
def test_custom_settings(self):
"""Test custom settings values."""
settings = ClideSettings(theme="nord", jira_enabled=True)
assert settings.theme == "nord"
assert settings.jira_enabled is True
+75
View File
@@ -0,0 +1,75 @@
"""Tests for configuration models."""
import pytest
from pydantic import ValidationError
from clide.models.config import ClideSettings, KeybindingsConfig, PanelConfig
class TestClideSettings:
"""Tests for ClideSettings model."""
def test_default_settings(self) -> None:
"""Default settings should be valid."""
settings = ClideSettings()
assert settings.theme == "summer-night"
assert settings.auto_save is True
assert settings.confirm_exit is True
assert settings.jira_enabled is False
def test_custom_settings(self) -> None:
"""Custom settings should be applied."""
settings = ClideSettings(
theme="dracula",
claude_path="/custom/path/claude",
auto_save=False,
jira_enabled=True,
)
assert settings.theme == "dracula"
assert settings.claude_path == "/custom/path/claude"
assert settings.auto_save is False
assert settings.jira_enabled is True
class TestPanelConfig:
"""Tests for PanelConfig model."""
def test_default_values(self) -> None:
"""Default values should be set correctly."""
config = PanelConfig()
assert config.sidebar_visible is True
assert config.context_visible is True
assert config.workspace_visible is False
assert config.sidebar_width_percent == 20
assert config.context_width_percent == 25
def test_frozen_model(self) -> None:
"""PanelConfig should be immutable."""
config = PanelConfig()
with pytest.raises(ValidationError):
config.sidebar_visible = False # type: ignore
def test_strict_mode(self) -> None:
"""PanelConfig should enforce strict types."""
with pytest.raises(ValidationError):
PanelConfig(sidebar_width_percent="30") # type: ignore
class TestKeybindingsConfig:
"""Tests for KeybindingsConfig model."""
def test_default_keybindings(self) -> None:
"""Default keybindings should be valid."""
config = KeybindingsConfig()
assert config.toggle_sidebar == "ctrl+b"
assert config.toggle_terminal == "ctrl+`"
assert config.focus_claude == "ctrl+1"
def test_custom_keybindings(self) -> None:
"""Custom keybindings should be applied."""
config = KeybindingsConfig(
toggle_sidebar="ctrl+shift+s",
save="cmd+s",
)
assert config.toggle_sidebar == "ctrl+shift+s"
assert config.save == "cmd+s"
+419
View File
@@ -0,0 +1,419 @@
"""Tests for controller classes."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine
from clide.models.editor import CursorPosition, FileBuffer
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitStatus
from clide.models.problems import Problem, ProblemsSummary, Severity
from clide.models.todos import TodoItem, TodosSummary, TodoType
from clide.services.git_service import GitService
from clide.services.process_service import CommandResult
class TestGitController:
"""Tests for GitController."""
@pytest.fixture
def controller(self, tmp_path: Path) -> GitController:
return GitController(tmp_path)
@pytest.mark.asyncio
async def test_get_status(self, controller: GitController):
mock_status = GitStatus(
branch="main",
staged=(GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True),),
unstaged=(),
)
with patch.object(controller._service, "get_status", return_value=mock_status):
status = await controller.get_status()
assert status.branch == "main"
assert len(status.staged) == 1
@pytest.mark.asyncio
async def test_get_branches(self, controller: GitController):
mock_branches = [
GitBranch(name="main", is_current=True, is_remote=False),
GitBranch(name="develop", is_current=False, is_remote=False),
]
with patch.object(controller._service, "get_branches", return_value=mock_branches):
branches = await controller.get_branches()
assert len(branches) == 2
assert branches[0].is_current is True
@pytest.mark.asyncio
async def test_get_log(self, controller: GitController):
mock_commits = [
GitCommit(
hash="abc123def456",
short_hash="abc123",
message="Initial commit",
author="Test",
date="2024-01-01",
)
]
with patch.object(controller._service, "get_log", return_value=mock_commits):
commits = await controller.get_log(limit=10)
assert len(commits) == 1
assert commits[0].message == "Initial commit"
@pytest.mark.asyncio
async def test_stage_file(self, controller: GitController):
mock_status = GitStatus(branch="main", staged=(), unstaged=())
with patch.object(controller._service, "stage_file", return_value=True):
with patch.object(controller._service, "get_status", return_value=mock_status):
result = await controller.stage_file("test.py")
assert result is True
@pytest.mark.asyncio
async def test_unstage_file(self, controller: GitController):
mock_status = GitStatus(branch="main", staged=(), unstaged=())
with patch.object(controller._service, "unstage_file", return_value=True):
with patch.object(controller._service, "get_status", return_value=mock_status):
result = await controller.unstage_file("test.py")
assert result is True
@pytest.mark.asyncio
async def test_checkout_branch(self, controller: GitController):
mock_status = GitStatus(branch="develop", staged=(), unstaged=())
with patch.object(controller._service, "checkout_branch", return_value=True):
with patch.object(controller._service, "get_status", return_value=mock_status):
with patch.object(controller._service, "get_branches", return_value=[]):
result = await controller.checkout_branch("develop")
assert result is True
@pytest.mark.asyncio
async def test_current_branch_property(self, controller: GitController):
mock_status = GitStatus(branch="feature", staged=(), unstaged=())
with patch.object(controller._service, "get_status", return_value=mock_status):
await controller.get_status() # Populate _status
assert controller.current_branch == "feature"
def test_current_branch_unknown(self, controller: GitController):
assert controller.current_branch == "unknown"
class TestEditorController:
"""Tests for EditorController."""
@pytest.fixture
def controller(self) -> EditorController:
return EditorController()
def test_initial_state(self, controller: EditorController):
assert controller.active_buffer is None
assert controller.open_files == []
assert controller.has_unsaved_changes is False
def test_update_content(self, controller: EditorController):
buffer = FileBuffer(path=Path("/test.py"), content="original")
controller._state.buffers.append(buffer)
controller.update_content(Path("/test.py"), "modified")
assert buffer.content == "modified"
assert buffer.is_modified is True
def test_update_cursor(self, controller: EditorController):
buffer = FileBuffer(path=Path("/test.py"), content="test")
controller._state.buffers.append(buffer)
controller.update_cursor(Path("/test.py"), 5, 10)
assert buffer.cursor.line == 5
assert buffer.cursor.column == 10
def test_set_active_by_index(self, controller: EditorController):
buffer1 = FileBuffer(path=Path("/a.py"), content="a")
buffer2 = FileBuffer(path=Path("/b.py"), content="b")
controller._state.buffers = [buffer1, buffer2]
controller.set_active_by_index(1)
assert controller._state.active_buffer_index == 1
def test_set_active_invalid_index(self, controller: EditorController):
controller.set_active_by_index(10) # Should not crash
assert controller._state.active_buffer_index is None
class TestDiffController:
"""Tests for DiffController."""
@pytest.fixture
def controller(self, tmp_path: Path) -> DiffController:
return DiffController(tmp_path)
def test_initial_state(self, controller: DiffController):
assert controller.diff is None
assert controller.is_proposal is False
def test_load_proposal(self, controller: DiffController):
old_content = "line1\nline2\n"
new_content = "line1\nmodified\n"
diff = controller.load_proposal("test.py", old_content, new_content)
assert diff.file_path == "test.py"
assert controller.is_proposal is True
def test_accept_hunk(self, controller: DiffController):
controller._state.diff = DiffContent(file_path="t.py", hunks=())
controller.accept_hunk(0)
assert 0 in controller._state.accepted_hunks
assert 0 not in controller._state.rejected_hunks
def test_reject_hunk(self, controller: DiffController):
controller._state.diff = DiffContent(file_path="t.py", hunks=())
controller.reject_hunk(0)
assert 0 in controller._state.rejected_hunks
assert 0 not in controller._state.accepted_hunks
def test_accept_all(self, controller: DiffController):
hunk = DiffHunk(
header="@@",
old_start=1,
old_count=1,
new_start=1,
new_count=1,
lines=(),
)
controller._state.diff = DiffContent(file_path="t.py", hunks=(hunk, hunk))
controller.accept_all()
assert len(controller._state.accepted_hunks) == 2
def test_reject_all(self, controller: DiffController):
hunk = DiffHunk(
header="@@",
old_start=1,
old_count=1,
new_start=1,
new_count=1,
lines=(),
)
controller._state.diff = DiffContent(file_path="t.py", hunks=(hunk,))
controller.reject_all()
assert len(controller._state.rejected_hunks) == 1
def test_clear(self, controller: DiffController):
controller._state.diff = DiffContent(file_path="t.py", hunks=())
controller._state.is_proposal = True
controller.clear()
assert controller.diff is None
assert controller.is_proposal is False
def test_toggle_side_by_side(self, controller: DiffController):
assert controller._state.side_by_side is True # Default is True
result = controller.toggle_side_by_side()
assert result is False # Toggled to False
assert controller._state.side_by_side is False
class TestProblemsController:
"""Tests for ProblemsController."""
@pytest.fixture
def controller(self, tmp_path: Path) -> ProblemsController:
return ProblemsController(tmp_path)
def test_initial_state(self, controller: ProblemsController):
assert controller.problems == []
assert controller.error_count == 0
assert controller.warning_count == 0
def test_filter_by_severity(self, controller: ProblemsController):
problems = [
Problem(
file_path=Path("/a.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="err",
source="test",
),
Problem(
file_path=Path("/b.py"),
line=1,
column=1,
severity=Severity.WARNING,
message="warn",
source="test",
),
]
controller._state.problems = problems
errors = controller.filter_by_severity(Severity.ERROR)
assert len(errors) == 1
assert errors[0].severity == Severity.ERROR
def test_filter_by_source(self, controller: ProblemsController):
problems = [
Problem(
file_path=Path("/a.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="err",
source="ruff",
),
Problem(
file_path=Path("/b.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="err",
source="mypy",
),
]
controller._state.problems = problems
ruff_problems = controller.filter_by_source("ruff")
assert len(ruff_problems) == 1
def test_next_problem(self, controller: ProblemsController):
problems = [
Problem(
file_path=Path("/a.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="1",
source="test",
),
Problem(
file_path=Path("/b.py"),
line=2,
column=1,
severity=Severity.ERROR,
message="2",
source="test",
),
]
controller._state.problems = problems
p1 = controller.next_problem()
assert p1.message == "1"
p2 = controller.next_problem()
assert p2.message == "2"
# Should wrap around
p3 = controller.next_problem()
assert p3.message == "1"
def test_prev_problem(self, controller: ProblemsController):
problems = [
Problem(
file_path=Path("/a.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="1",
source="test",
),
Problem(
file_path=Path("/b.py"),
line=2,
column=1,
severity=Severity.ERROR,
message="2",
source="test",
),
]
controller._state.problems = problems
p = controller.prev_problem()
assert p.message == "2"
def test_clear(self, controller: ProblemsController):
controller._state.problems = [
Problem(
file_path=Path("/a.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="err",
source="test",
)
]
controller.clear()
assert controller.problems == []
class TestTodosController:
"""Tests for TodosController."""
@pytest.fixture
def controller(self, tmp_path: Path) -> TodosController:
return TodosController(tmp_path)
def test_initial_state(self, controller: TodosController):
assert controller.items == []
assert controller.total_count == 0
def test_filter_by_type(self, controller: TodosController):
items = [
TodoItem(file_path=Path("/a.py"), line=1, column=1, todo_type=TodoType.TODO, text="1", context_line="# TODO: 1"),
TodoItem(file_path=Path("/b.py"), line=2, column=1, todo_type=TodoType.FIXME, text="2", context_line="# FIXME: 2"),
]
controller._state.items = items
todos = controller.filter_by_type(TodoType.TODO)
assert len(todos) == 1
assert todos[0].todo_type == TodoType.TODO
def test_get_grouped_items(self, controller: TodosController):
items = [
TodoItem(file_path=Path("/a.py"), line=1, column=1, todo_type=TodoType.TODO, text="1", context_line="# TODO: 1"),
TodoItem(file_path=Path("/a.py"), line=5, column=1, todo_type=TodoType.FIXME, text="2", context_line="# FIXME: 2"),
TodoItem(file_path=Path("/b.py"), line=1, column=1, todo_type=TodoType.TODO, text="3", context_line="# TODO: 3"),
]
controller._state.items = items
grouped = controller.get_grouped_items()
assert len(grouped) == 2
assert len(grouped[Path("/a.py")]) == 2
assert len(grouped[Path("/b.py")]) == 1
def test_toggle_group_by_file(self, controller: TodosController):
assert controller._state.group_by_file is True # Default is True per TodosState model
result = controller.toggle_group_by_file()
assert result is False
assert controller._state.group_by_file is False
class TestJiraController:
"""Tests for JiraController."""
@pytest.fixture
def controller(self) -> JiraController:
return JiraController(enabled=True)
@pytest.fixture
def disabled_controller(self) -> JiraController:
return JiraController(enabled=False)
def test_initial_enabled_state(self, controller: JiraController):
assert controller.enabled is True
def test_initial_disabled_state(self, disabled_controller: JiraController):
assert disabled_controller.enabled is False
def test_enable_disable(self, disabled_controller: JiraController):
disabled_controller.enable()
assert disabled_controller.enabled is True
disabled_controller.disable()
assert disabled_controller.enabled is False
@pytest.mark.asyncio
async def test_run_command_when_disabled(self, disabled_controller: JiraController):
result = await disabled_controller.run_command("issue", "list")
assert "disabled" in result.lower()
@pytest.mark.asyncio
async def test_get_content_when_disabled(self, disabled_controller: JiraController):
result = await disabled_controller.get_content()
assert result is None
+52
View File
@@ -0,0 +1,52 @@
"""Tests for extension system."""
import pytest
from clide.extensions import hookimpl
from clide.extensions.manager import ExtensionManager
class SampleExtension:
"""Sample extension for testing."""
@hookimpl
def clide_on_app_startup(self, app: object) -> None:
"""Track that startup was called."""
self.startup_called = True
self.received_app = app
class TestExtensionManager:
"""Tests for ExtensionManager."""
def test_register_plugin(self) -> None:
"""Plugins can be registered manually."""
manager = ExtensionManager()
extension = SampleExtension()
manager.register_plugin(extension, "sample")
assert "sample" in manager.list_extensions()
def test_unregister_plugin(self) -> None:
"""Plugins can be unregistered."""
manager = ExtensionManager()
extension = SampleExtension()
manager.register_plugin(extension, "sample")
manager.unregister_plugin("sample")
assert "sample" not in manager.list_extensions()
@pytest.mark.asyncio
async def test_trigger_startup_hook(self) -> None:
"""Startup hooks are triggered for all extensions."""
manager = ExtensionManager()
extension = SampleExtension()
manager.register_plugin(extension, "sample")
mock_app = object()
await manager.trigger_app_startup(mock_app)
assert extension.startup_called is True
assert extension.received_app is mock_app
+437
View File
@@ -0,0 +1,437 @@
"""Tests for Pydantic models."""
from pathlib import Path
import pytest
from pydantic import ValidationError
from clide.models.config import ClideSettings, KeybindingsConfig, PanelConfig
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitGraph, GitStatus
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
class TestCursorPosition:
"""Tests for CursorPosition model."""
def test_create_cursor(self):
cursor = CursorPosition(line=10, column=5)
assert cursor.line == 10
assert cursor.column == 5
def test_cursor_is_frozen(self):
cursor = CursorPosition(line=0, column=0)
with pytest.raises(ValidationError):
cursor.line = 1
class TestFileBuffer:
"""Tests for FileBuffer model."""
def test_create_buffer(self):
buffer = FileBuffer(
path=Path("/test/file.py"),
content="print('hello')",
language="python",
)
assert buffer.path == Path("/test/file.py")
assert buffer.content == "print('hello')"
assert buffer.language == "python"
assert buffer.is_modified is False
def test_buffer_display_name(self):
buffer = FileBuffer(path=Path("/test/file.py"), content="")
assert buffer.display_name == "file.py"
def test_buffer_modified_display_name(self):
buffer = FileBuffer(path=Path("/test/file.py"), content="", is_modified=True)
assert buffer.display_name == "● file.py"
def test_buffer_filename(self):
buffer = FileBuffer(path=Path("/some/deep/path/script.js"), content="")
assert buffer.filename == "script.js"
class TestEditorState:
"""Tests for EditorState model."""
def test_empty_state(self):
state = EditorState()
assert state.buffers == []
assert state.active_buffer_index is None
assert state.active_buffer is None
def test_active_buffer(self):
buffer1 = FileBuffer(path=Path("/a.py"), content="a")
buffer2 = FileBuffer(path=Path("/b.py"), content="b")
state = EditorState(buffers=[buffer1, buffer2], active_buffer_index=1)
assert state.active_buffer == buffer2
def test_get_buffer_by_path(self):
buffer = FileBuffer(path=Path("/test.py"), content="test")
state = EditorState(buffers=[buffer])
assert state.get_buffer_by_path(Path("/test.py")) == buffer
assert state.get_buffer_by_path(Path("/other.py")) is None
class TestGitChange:
"""Tests for GitChange model."""
def test_create_change(self):
from clide.models.git import ChangeStatus
change = GitChange(path="src/main.py", status=ChangeStatus.MODIFIED, staged=True)
assert change.path == "src/main.py"
assert change.status == ChangeStatus.MODIFIED
assert change.staged is True
def test_change_is_frozen(self):
from clide.models.git import ChangeStatus
change = GitChange(path="f", status=ChangeStatus.ADDED, staged=False)
with pytest.raises(ValidationError):
change.path = "new.py"
class TestGitStatus:
"""Tests for GitStatus model."""
def test_create_status(self):
staged = (GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True),)
unstaged = (GitChange(path="b.py", status=ChangeStatus.MODIFIED, staged=False),)
status = GitStatus(branch="main", staged=staged, unstaged=unstaged)
assert status.branch == "main"
assert len(status.staged) == 1
assert len(status.unstaged) == 1
def test_empty_status(self):
status = GitStatus(branch="main", staged=(), unstaged=())
assert status.branch == "main"
assert len(status.staged) == 0
assert len(status.unstaged) == 0
class TestGitBranch:
"""Tests for GitBranch model."""
def test_create_branch(self):
branch = GitBranch(name="feature/test", is_current=True, is_remote=False)
assert branch.name == "feature/test"
assert branch.is_current is True
assert branch.is_remote is False
class TestGitCommit:
"""Tests for GitCommit model."""
def test_create_commit(self):
commit = GitCommit(
hash="abc123def456789",
short_hash="abc123",
message="Test commit",
author="Test Author",
date="2024-01-01",
)
assert commit.hash == "abc123def456789"
assert commit.short_hash == "abc123"
assert commit.message == "Test commit"
class TestDiffLine:
"""Tests for DiffLine model."""
def test_added_line(self):
line = DiffLine(change_type=ChangeType.ADDED, content="new line", new_line_num=10)
assert line.change_type == ChangeType.ADDED
assert line.content == "new line"
def test_removed_line(self):
line = DiffLine(change_type=ChangeType.REMOVED, content="old line", old_line_num=5)
assert line.change_type == ChangeType.REMOVED
def test_context_line(self):
line = DiffLine(
change_type=ChangeType.CONTEXT,
content="unchanged",
old_line_num=5,
new_line_num=5,
)
assert line.change_type == ChangeType.CONTEXT
class TestDiffHunk:
"""Tests for DiffHunk model."""
def test_create_hunk(self):
lines = (
DiffLine(change_type=ChangeType.REMOVED, content="old", old_line_num=1),
DiffLine(change_type=ChangeType.ADDED, content="new", new_line_num=1),
)
hunk = DiffHunk(
header="@@ -1,1 +1,1 @@",
old_start=1,
old_count=1,
new_start=1,
new_count=1,
lines=lines,
)
assert len(hunk.lines) == 2
class TestDiffContent:
"""Tests for DiffContent model."""
def test_create_diff(self):
hunk = DiffHunk(
header="@@ -1 +1 @@",
old_start=1,
old_count=1,
new_start=1,
new_count=1,
lines=(),
)
diff = DiffContent(file_path="test.py", hunks=(hunk,))
assert diff.file_path == "test.py"
assert len(diff.hunks) == 1
class TestProblem:
"""Tests for Problem model."""
def test_create_problem(self):
problem = Problem(
file_path=Path("/test.py"),
line=10,
column=5,
severity=Severity.ERROR,
message="Syntax error",
source="ruff",
code="E999",
)
assert problem.line == 10
assert problem.severity == Severity.ERROR
def test_severity_icon(self):
error = Problem(
file_path=Path("/t.py"),
line=1,
column=1,
severity=Severity.ERROR,
message="err",
source="test",
)
assert error.severity_icon == ""
warning = Problem(
file_path=Path("/t.py"),
line=1,
column=1,
severity=Severity.WARNING,
message="warn",
source="test",
)
assert warning.severity_icon == ""
class TestProblemsSummary:
"""Tests for ProblemsSummary model."""
def test_create_summary(self):
summary = ProblemsSummary(errors=5, warnings=3, infos=1, hints=0)
assert summary.total == 9
class TestTodoItem:
"""Tests for TodoItem model."""
def test_create_todo(self):
todo = TodoItem(
file_path=Path("/src/main.py"),
line=42,
column=5,
todo_type=TodoType.TODO,
text="Implement this feature",
context_line="# TODO: Implement this feature",
)
assert todo.line == 42
assert todo.todo_type == TodoType.TODO
def test_type_icon(self):
todo = TodoItem(
file_path=Path("/t.py"),
line=1,
column=1,
todo_type=TodoType.TODO,
text="todo",
context_line="# TODO: todo",
)
assert todo.type_icon == ""
fixme = TodoItem(
file_path=Path("/t.py"),
line=1,
column=1,
todo_type=TodoType.FIXME,
text="fixme",
context_line="# FIXME: fixme",
)
assert fixme.type_icon == "🔧"
class TestTodosSummary:
"""Tests for TodosSummary model."""
def test_create_summary(self):
summary = TodosSummary(
todo_count=5,
fixme_count=3,
hack_count=2,
other_count=0,
)
assert summary.total == 10
assert summary.todo_count == 5
class TestThemeColors:
"""Tests for ThemeColors model."""
def test_valid_colors(self):
colors = ThemeColors(
primary="#00a3d2",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
)
assert colors.primary == "#00a3d2"
def test_invalid_color_rejected(self):
with pytest.raises(ValidationError):
ThemeColors(
primary="invalid",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
)
def test_color_normalized_to_lowercase(self):
colors = ThemeColors(
primary="#00A3D2",
secondary="#00A9B9",
accent="#FA5F8B",
background="#21262F",
surface="#393E48",
panel="#292E38",
foreground="#E2E8F5",
success="#00AB9A",
warning="#D08447",
error="#F06C6F",
)
assert colors.primary == "#00a3d2"
class TestThemeDefinition:
"""Tests for ThemeDefinition model."""
def test_create_theme(self):
colors = ThemeColors(
primary="#00a3d2",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
)
theme = ThemeDefinition(
name="test-theme",
display_name="Test Theme",
dark=True,
colors=colors,
)
assert theme.name == "test-theme"
assert theme.dark is True
def test_to_textual_theme(self):
colors = ThemeColors(
primary="#00a3d2",
secondary="#00a9b9",
accent="#fa5f8b",
background="#21262f",
surface="#393e48",
panel="#292e38",
foreground="#e2e8f5",
success="#00ab9a",
warning="#d08447",
error="#f06c6f",
)
theme_def = ThemeDefinition(
name="test",
display_name="Test",
dark=True,
colors=colors,
)
textual_theme = theme_def.to_textual_theme()
assert textual_theme.name == "test"
class TestThemeMetadata:
"""Tests for ThemeMetadata model."""
def test_create_metadata(self):
meta = ThemeMetadata(
name="summer-night",
display_name="Summer Night",
dark=True,
category="core",
)
assert meta.name == "summer-night"
assert meta.category == "core"
class TestClideSettings:
"""Tests for ClideSettings model."""
def test_default_settings(self):
settings = ClideSettings()
assert settings.theme == "summer-night"
assert settings.jira_enabled is False
def test_custom_settings(self):
settings = ClideSettings(theme="dracula", jira_enabled=True)
assert settings.theme == "dracula"
assert settings.jira_enabled is True
class TestPanelConfig:
"""Tests for PanelConfig model."""
def test_default_panel_config(self):
config = PanelConfig()
assert config.sidebar_visible is True
assert config.context_visible is True
assert config.workspace_visible is False
class TestKeybindingsConfig:
"""Tests for KeybindingsConfig model."""
def test_default_keybindings(self):
config = KeybindingsConfig()
assert config.toggle_sidebar == "ctrl+b"
assert config.toggle_terminal == "ctrl+`"

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