docs: update documentation and fix lint issues for v1.0.0
Documentation: - Rewrite README.md with current features and git operations - Rewrite docs/ARCHITECTURE.md with layered architecture details - Rewrite docs/tui-ide-spec.md with Alt-key shortcuts - Add docs/code-organization.md for component architecture - Add docs/user-manual.md for end users - Update TODO.md to mark completed items Code fixes: - Fix undefined 'event' variable in diff_pane.py (was _event) - Use ternary operator in editor.py save_file method - Clean up imports in claude_events.py and syntax_service.py - Auto-fix import sorting across multiple files Config: - Add snapshot report path to pyproject.toml pytest options - Exclude clide/vendor from ruff linting - Ignore TCH002/TCH003 type-checking import rules Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
2d2e5f5648
commit
8b1a84e7e2
@@ -4,53 +4,135 @@ A terminal-based IDE that wraps Claude Code CLI, putting AI-assisted development
|
||||
|
||||
## Why Clide?
|
||||
|
||||
Claude Code is powerful, but switching between your terminal, editor, and project tools breaks your flow. Clide brings everything into one interface:
|
||||
Claude Code is powerful, but switching between terminal, editor, and project tools breaks your flow. Clide brings everything into one interface:
|
||||
|
||||
- **Claude stays visible** — No more switching windows. Claude Code is always front and center.
|
||||
- **Context at a glance** — File tree, git status, problems, and TODOs in dedicated panels.
|
||||
- **Panels appear when needed** — Editor, diff viewer, and terminal stay hidden until you need them.
|
||||
- **Familiar keybindings** — VSCode-inspired shortcuts that don't interfere with your input.
|
||||
- **Claude stays visible** — Claude Code runs in the center panel, always accessible
|
||||
- **Context at a glance** — File tree, git status, problems, and TODOs in dedicated panels
|
||||
- **Panels appear when needed** — Editor, diff viewer, and terminal stay hidden until you need them
|
||||
- **Git integration** — Commit, stash, pull, and push via Claude with built-in skills
|
||||
- **22 themes** — From summer-night to dracula, with custom theme support
|
||||
|
||||
## Layout
|
||||
## Screenshot
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ Sidebar │ Workspace │ Context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (appears when needed) │ [Problems][TODOs]│
|
||||
│ [Graph] ├─────────────────────────┤ [Jira] │
|
||||
│ [Files][Git] │ (appears when needed) │ [Jira][TODOs] │
|
||||
│ [Tree] ├─────────────────────────┤ [Problems] │
|
||||
│ │ │ │
|
||||
│ │ Claude │ │
|
||||
│ │ (always visible) │ │
|
||||
│ │ │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ [⚠ 3][✓12][Jira]│
|
||||
│ ⎇ main ▾ │ │ │
|
||||
│ staged: 2 │ │ │
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
**Left Sidebar**
|
||||
- File explorer with project tree
|
||||
- Git panel showing staged/unstaged changes
|
||||
- Visual branch graph
|
||||
- Quick branch switching
|
||||
### Left Sidebar
|
||||
- **Files** — Project file tree with syntax-aware icons
|
||||
- **Git** — Staged/unstaged changes with action buttons
|
||||
- **Tree** — Visual branch graph
|
||||
- **Branch status** — Current branch with quick switcher
|
||||
|
||||
**Center Workspace**
|
||||
- Claude Code integration (primary focus)
|
||||
- Tabbed editor with syntax highlighting
|
||||
- Side-by-side diff viewer for proposed changes
|
||||
- Integrated terminal
|
||||
### Center
|
||||
- **Claude Code** — Full PTY terminal integration, always visible
|
||||
- **Editor** — Syntax highlighting via tree-sitter
|
||||
- **Diff** — Side-by-side diff viewer
|
||||
- **Terminal** — Command execution
|
||||
|
||||
**Right Context Panel**
|
||||
- Problems view (linter errors/warnings)
|
||||
- TODOs extracted from codebase
|
||||
- Jira/Confluence integration
|
||||
### Right Context
|
||||
- **Jira** — Issue display via CLI integration
|
||||
- **TODOs** — Code comments and TODO.md items
|
||||
- **Problems** — Linter errors and warnings
|
||||
|
||||
**Responsive Design**
|
||||
- Works on 13" laptops to widescreen monitors
|
||||
- Compact mode hides sidebars for focused work
|
||||
- Panels preserve state when hidden
|
||||
### Git Operations
|
||||
|
||||
Click buttons in the Git panel to delegate operations to Claude:
|
||||
|
||||
| Button | Skill | What Claude Does |
|
||||
|--------|-------|------------------|
|
||||
| Commit | `/commit` | Reviews changes, writes commit message |
|
||||
| Stash | `/stash` | Stashes working changes |
|
||||
| Pull | `/pull` | Pulls with rebase, helps resolve conflicts |
|
||||
| Push | `/push` | Pushes to remote, sets upstream if needed |
|
||||
|
||||
Skills are installed automatically to your project's `.claude/skills/` directory.
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- Git
|
||||
- Claude Code CLI (installed and authenticated)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd clide
|
||||
make setup
|
||||
make run
|
||||
```
|
||||
|
||||
Or install directly:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
clide
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
All shortcuts use `Alt` to avoid conflicts with Claude Code input.
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Quit | `Alt+Q` |
|
||||
|
||||
## Themes
|
||||
|
||||
22 built-in themes. Press `Alt+T` to switch.
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| Seasonal | winter-is-coming, monokai-winter, fall, dark-autumn |
|
||||
| Special | all-hallows-eve, halloween, christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
|
||||
Create custom themes in `~/.clide/themes/` as TOML files.
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings stored in `~/.clide/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "summer-night",
|
||||
"compact_mode": false,
|
||||
"jira_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Override with environment variables:
|
||||
|
||||
```bash
|
||||
CLIDE_THEME=dracula clide
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -61,37 +143,25 @@ Claude Code is powerful, but switching between your terminal, editor, and projec
|
||||
| CLI | Typer |
|
||||
| Data Validation | Pydantic v2 |
|
||||
| Extensions | Pluggy |
|
||||
| Syntax Highlighting | tree-sitter |
|
||||
|
||||
## Getting Started
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <repo-url>
|
||||
cd clide
|
||||
make setup
|
||||
|
||||
# Run
|
||||
make run
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Command palette | `Alt+P` |
|
||||
| Quick open file | `Alt+O` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Full UI/UX Specification](docs/tui-ide-spec.md)
|
||||
- [Architecture Guide](docs/ARCHITECTURE.md)
|
||||
- [User Manual](docs/user-manual.md) — How to use Clide
|
||||
- [UI/UX Specification](docs/tui-ide-spec.md) — Design decisions
|
||||
- [Architecture](docs/ARCHITECTURE.md) — Technical overview
|
||||
- [Code Organization](docs/code-organization.md) — Project structure
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -44,23 +44,23 @@ Long-term open items for Clide development.
|
||||
### Git Integration
|
||||
- [ ] Stage/unstage files from Git tab
|
||||
- [ ] Discard changes context menu
|
||||
- [ ] Git graph visualization (Tree tab)
|
||||
- [ ] Branch popout with checkout/new branch actions
|
||||
- [x] Git graph visualization (Tree tab)
|
||||
- [x] Branch popout with checkout/new branch actions
|
||||
|
||||
## Context Panel (Right Sidebar)
|
||||
|
||||
### Problems View
|
||||
- [ ] Linter integration (ruff, eslint, etc.)
|
||||
- [ ] Click to navigate to file:line
|
||||
- [ ] Reactive problem count badge
|
||||
- [x] Click to navigate to file:line
|
||||
- [x] Reactive problem count badge
|
||||
|
||||
### TODOs View
|
||||
- [ ] Scan for TODO/FIXME/HACK/XXX comments
|
||||
- [ ] Click to navigate to file:line
|
||||
- [ ] Reactive count badge
|
||||
- [x] Scan for TODO/FIXME/HACK/XXX comments
|
||||
- [x] Click to navigate to file:line
|
||||
- [x] Reactive count badge
|
||||
|
||||
### Jira View
|
||||
- [ ] Render Jira CLI markdown output
|
||||
- [x] Render Jira CLI markdown output
|
||||
- [ ] Auto-refresh on panel focus
|
||||
- [ ] Configurable refresh interval
|
||||
|
||||
@@ -69,7 +69,7 @@ Long-term open items for Clide development.
|
||||
### Responsiveness
|
||||
- [ ] CSS breakpoints for different terminal widths
|
||||
- [ ] Auto-hide sidebars on narrow terminals (<100 cols)
|
||||
- [ ] Compact mode toggle (`Alt+C`)
|
||||
- [x] Compact mode toggle (`Alt+C`)
|
||||
|
||||
### Fullscreen Mode
|
||||
- [ ] Any panel can go fullscreen (`F11`)
|
||||
@@ -94,15 +94,15 @@ Long-term open items for Clide development.
|
||||
## Plugin System
|
||||
|
||||
- [ ] User-defined panels via pluggy
|
||||
- [ ] Custom integrations support
|
||||
- [x] Custom integrations support (hookspecs defined)
|
||||
- [ ] Extension API documentation
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Snapshot tests for all panels
|
||||
- [ ] Integration tests for panel communication
|
||||
- [ ] Unit tests for controllers
|
||||
- [ ] Unit tests for services
|
||||
- [x] Snapshot tests for all panels
|
||||
- [x] Integration tests for panel communication
|
||||
- [x] Unit tests for controllers
|
||||
- [x] Unit tests for services
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ class EditorController:
|
||||
self._state.buffers.remove(buffer)
|
||||
|
||||
# Update active buffer
|
||||
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(self._state.buffers):
|
||||
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(
|
||||
self._state.buffers
|
||||
):
|
||||
self._state.active_buffer_index = (
|
||||
len(self._state.buffers) - 1 if self._state.buffers else None
|
||||
)
|
||||
@@ -146,10 +148,7 @@ class EditorController:
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
if path:
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
else:
|
||||
buffer = self.active_buffer
|
||||
buffer = self._state.get_buffer_by_path(path) if path else self.active_buffer
|
||||
|
||||
if not buffer:
|
||||
return False
|
||||
|
||||
@@ -7,13 +7,12 @@ from terminal output, enabling tight IDE integration.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
# Event Types
|
||||
# -----------
|
||||
|
||||
@@ -21,54 +20,63 @@ from textual.message import Message
|
||||
@dataclass
|
||||
class ClaudeEvent:
|
||||
"""Base class for Claude Code events."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileReadEvent(ClaudeEvent):
|
||||
"""Emitted when Claude reads a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEditEvent(ClaudeEvent):
|
||||
"""Emitted when Claude edits a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileWriteEvent(ClaudeEvent):
|
||||
"""Emitted when Claude creates/writes a file."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobEvent(ClaudeEvent):
|
||||
"""Emitted when Claude searches for files."""
|
||||
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GrepEvent(ClaudeEvent):
|
||||
"""Emitted when Claude searches file contents."""
|
||||
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStartEvent(ClaudeEvent):
|
||||
"""Emitted when Claude starts using a tool."""
|
||||
|
||||
tool_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolEndEvent(ClaudeEvent):
|
||||
"""Emitted when Claude finishes using a tool."""
|
||||
|
||||
tool_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffProposedEvent(ClaudeEvent):
|
||||
"""Emitted when Claude proposes a diff."""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
@@ -95,11 +103,9 @@ PATTERNS = {
|
||||
"tool_write": re.compile(r"● Write\(([^)]+)\)"),
|
||||
"tool_glob": re.compile(r"● Glob\(([^)]+)\)"),
|
||||
"tool_grep": re.compile(r"● Grep\(([^)]+)\)"),
|
||||
|
||||
# Generic tool pattern
|
||||
"tool_start": re.compile(r"● (\w+)\("),
|
||||
"tool_end": re.compile(r"└─"),
|
||||
|
||||
# Diff headers
|
||||
"diff_header": re.compile(r"^@@\s*-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*@@", re.MULTILINE),
|
||||
"diff_file": re.compile(r"^(?:---|\+\+\+)\s+([^\s]+)", re.MULTILINE),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Language extension mapping for syntax highlighting
|
||||
# Maps file extensions to tree-sitter language identifiers
|
||||
LANGUAGE_MAP: dict[str, str] = {
|
||||
|
||||
@@ -8,10 +8,6 @@ registering additional languages.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Vendored
+31
-31
@@ -1,17 +1,17 @@
|
||||
"""
|
||||
pyte.graphics
|
||||
~~~~~~~~~~~~~
|
||||
pyte.graphics
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
This module defines graphic-related constants, mostly taken from
|
||||
:manpage:`console_codes(4)` and
|
||||
http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html.
|
||||
This module defines graphic-related constants, mostly taken from
|
||||
:manpage:`console_codes(4)` and
|
||||
http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
#: A mapping of ANSI text style codes to style names, "+" means the:
|
||||
@@ -51,7 +51,7 @@ FG_ANSI = {
|
||||
35: "magenta",
|
||||
36: "cyan",
|
||||
37: "white",
|
||||
39: "default" # white.
|
||||
39: "default", # white.
|
||||
}
|
||||
|
||||
#: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility.
|
||||
@@ -67,7 +67,7 @@ FG_AIXTERM = {
|
||||
94: "brightblue",
|
||||
95: "brightmagenta",
|
||||
96: "brightcyan",
|
||||
97: "brightwhite"
|
||||
97: "brightwhite",
|
||||
}
|
||||
|
||||
#: A mapping of ANSI background color codes to color names.
|
||||
@@ -85,7 +85,7 @@ BG_ANSI = {
|
||||
45: "magenta",
|
||||
46: "cyan",
|
||||
47: "white",
|
||||
49: "default" # black.
|
||||
49: "default", # black.
|
||||
}
|
||||
|
||||
#: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility.
|
||||
@@ -101,7 +101,7 @@ BG_AIXTERM = {
|
||||
104: "brightblue",
|
||||
105: "bfightmagenta",
|
||||
106: "brightcyan",
|
||||
107: "brightwhite"
|
||||
107: "brightwhite",
|
||||
}
|
||||
|
||||
#: SGR code for foreground in 256 or True color mode.
|
||||
@@ -114,25 +114,25 @@ BG_256 = 48
|
||||
# The following code is part of the Pygments project (BSD licensed).
|
||||
_FG_BG_256 = [
|
||||
(0x00, 0x00, 0x00), # 0
|
||||
(0xcd, 0x00, 0x00), # 1
|
||||
(0x00, 0xcd, 0x00), # 2
|
||||
(0xcd, 0xcd, 0x00), # 3
|
||||
(0x00, 0x00, 0xee), # 4
|
||||
(0xcd, 0x00, 0xcd), # 5
|
||||
(0x00, 0xcd, 0xcd), # 6
|
||||
(0xe5, 0xe5, 0xe5), # 7
|
||||
(0x7f, 0x7f, 0x7f), # 8
|
||||
(0xff, 0x00, 0x00), # 9
|
||||
(0x00, 0xff, 0x00), # 10
|
||||
(0xff, 0xff, 0x00), # 11
|
||||
(0x5c, 0x5c, 0xff), # 12
|
||||
(0xff, 0x00, 0xff), # 13
|
||||
(0x00, 0xff, 0xff), # 14
|
||||
(0xff, 0xff, 0xff), # 15
|
||||
(0xCD, 0x00, 0x00), # 1
|
||||
(0x00, 0xCD, 0x00), # 2
|
||||
(0xCD, 0xCD, 0x00), # 3
|
||||
(0x00, 0x00, 0xEE), # 4
|
||||
(0xCD, 0x00, 0xCD), # 5
|
||||
(0x00, 0xCD, 0xCD), # 6
|
||||
(0xE5, 0xE5, 0xE5), # 7
|
||||
(0x7F, 0x7F, 0x7F), # 8
|
||||
(0xFF, 0x00, 0x00), # 9
|
||||
(0x00, 0xFF, 0x00), # 10
|
||||
(0xFF, 0xFF, 0x00), # 11
|
||||
(0x5C, 0x5C, 0xFF), # 12
|
||||
(0xFF, 0x00, 0xFF), # 13
|
||||
(0x00, 0xFF, 0xFF), # 14
|
||||
(0xFF, 0xFF, 0xFF), # 15
|
||||
]
|
||||
|
||||
# colors 16..231: the 6x6x6 color cube
|
||||
valuerange = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff)
|
||||
valuerange = (0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF)
|
||||
|
||||
for i in range(216):
|
||||
r = valuerange[(i // 36) % 6]
|
||||
@@ -145,4 +145,4 @@ for i in range(24):
|
||||
v = 8 + i * 10
|
||||
_FG_BG_256.append((v, v, v))
|
||||
|
||||
FG_BG_256 = ["{0:02x}{1:02x}{2:02x}".format(r, g, b) for r, g, b in _FG_BG_256]
|
||||
FG_BG_256 = [f"{r:02x}{g:02x}{b:02x}" for r, g, b in _FG_BG_256]
|
||||
|
||||
Vendored
+94
-73
@@ -1,16 +1,17 @@
|
||||
"""
|
||||
pyte.screens
|
||||
~~~~~~~~~~~~
|
||||
pyte.screens
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module provides classes for terminal screens.
|
||||
This module provides classes for terminal screens.
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -20,18 +21,22 @@ import os
|
||||
import sys
|
||||
import unicodedata
|
||||
import warnings
|
||||
from collections import deque, defaultdict
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable, Generator, Sequence
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable, DefaultDict, Dict, Generator, List, NamedTuple, Optional, Set, Sequence, TextIO, TypeVar
|
||||
from typing import (
|
||||
Any,
|
||||
NamedTuple,
|
||||
TextIO,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from wcwidth import wcwidth as _wcwidth # type: ignore[import]
|
||||
|
||||
from . import (
|
||||
charsets as cs,
|
||||
control as ctrl,
|
||||
graphics as g,
|
||||
modes as mo
|
||||
)
|
||||
from . import charsets as cs
|
||||
from . import control as ctrl
|
||||
from . import graphics as g
|
||||
from . import modes as mo
|
||||
from .streams import Stream
|
||||
|
||||
wcwidth: Callable[[str], int] = lru_cache(maxsize=4096)(_wcwidth)
|
||||
@@ -40,10 +45,10 @@ KT = TypeVar("KT")
|
||||
VT = TypeVar("VT")
|
||||
|
||||
# Clide diagnostic logging support
|
||||
_debug_logger: Optional[Callable[[str], None]] = None
|
||||
_debug_logger: Callable[[str], None] | None = None
|
||||
|
||||
|
||||
def set_debug_logger(logger: Optional[Callable[[str], None]]) -> None:
|
||||
def set_debug_logger(logger: Callable[[str], None] | None) -> None:
|
||||
"""Set a debug logger function for diagnostic output."""
|
||||
global _debug_logger
|
||||
_debug_logger = logger
|
||||
@@ -57,12 +62,14 @@ def _log_debug(message: str) -> None:
|
||||
|
||||
class Margins(NamedTuple):
|
||||
"""A container for screen's scroll margins."""
|
||||
|
||||
top: int
|
||||
bottom: int
|
||||
|
||||
|
||||
class Savepoint(NamedTuple):
|
||||
"""A container for savepoint, created on :data:`~pyte.escape.DECSC`."""
|
||||
|
||||
cursor: Cursor
|
||||
g0_charset: str
|
||||
g1_charset: str
|
||||
@@ -73,6 +80,7 @@ class Savepoint(NamedTuple):
|
||||
|
||||
class Char(NamedTuple):
|
||||
"""A single styled on-screen character."""
|
||||
|
||||
data: str
|
||||
fg: str = "default"
|
||||
bg: str = "default"
|
||||
@@ -86,6 +94,7 @@ class Char(NamedTuple):
|
||||
|
||||
class Cursor:
|
||||
"""Screen cursor."""
|
||||
|
||||
__slots__ = ("x", "y", "attrs", "hidden")
|
||||
|
||||
def __init__(self, x: int, y: int, attrs: Char = Char(" ")) -> None:
|
||||
@@ -95,8 +104,9 @@ class Cursor:
|
||||
self.hidden = False
|
||||
|
||||
|
||||
class StaticDefaultDict(Dict[KT, VT]):
|
||||
class StaticDefaultDict(dict[KT, VT]):
|
||||
"""A dict with a static default value."""
|
||||
|
||||
def __init__(self, default: VT) -> None:
|
||||
self.default = default
|
||||
|
||||
@@ -117,22 +127,24 @@ class Screen:
|
||||
return Char(data=" ", fg="default", bg="default", reverse=reverse)
|
||||
|
||||
def __init__(self, columns: int, lines: int) -> None:
|
||||
self.savepoints: List[Savepoint] = []
|
||||
self.savepoints: list[Savepoint] = []
|
||||
self.columns = columns
|
||||
self.lines = lines
|
||||
self.buffer: Dict[int, StaticDefaultDict[int, Char]] = defaultdict(lambda: StaticDefaultDict[int, Char](self.default_char))
|
||||
self.dirty: Set[int] = set()
|
||||
self.buffer: dict[int, StaticDefaultDict[int, Char]] = defaultdict(
|
||||
lambda: StaticDefaultDict[int, Char](self.default_char)
|
||||
)
|
||||
self.dirty: set[int] = set()
|
||||
self.reset()
|
||||
self.mode = _DEFAULT_MODE.copy()
|
||||
self.margins: Optional[Margins] = None
|
||||
self.margins: Margins | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return ("{0}({1}, {2})".format(self.__class__.__name__,
|
||||
self.columns, self.lines))
|
||||
return f"{self.__class__.__name__}({self.columns}, {self.lines})"
|
||||
|
||||
@property
|
||||
def display(self) -> List[str]:
|
||||
def display(self) -> list[str]:
|
||||
"""A list of screen lines as unicode strings."""
|
||||
|
||||
def render(line: StaticDefaultDict[int, Char]) -> Generator[str, None, None]:
|
||||
is_wide_char = False
|
||||
for x in range(self.columns):
|
||||
@@ -167,9 +179,9 @@ class Screen:
|
||||
self.cursor = Cursor(0, 0)
|
||||
self.cursor_position()
|
||||
|
||||
self.saved_columns: Optional[int] = None
|
||||
self.saved_columns: int | None = None
|
||||
|
||||
def resize(self, lines: Optional[int] = None, columns: Optional[int] = None) -> None:
|
||||
def resize(self, lines: int | None = None, columns: int | None = None) -> None:
|
||||
"""Resize the screen to the given size."""
|
||||
lines = lines or self.lines
|
||||
columns = columns or self.columns
|
||||
@@ -195,7 +207,7 @@ class Screen:
|
||||
self.lines, self.columns = lines, columns
|
||||
self.set_margins()
|
||||
|
||||
def set_margins(self, top: Optional[int] = None, bottom: Optional[int] = None) -> None:
|
||||
def set_margins(self, top: int | None = None, bottom: int | None = None) -> None:
|
||||
"""Select top and bottom margins for the scrolling region."""
|
||||
if (top is None or top == 0) and bottom is None:
|
||||
self.margins = None
|
||||
@@ -293,8 +305,7 @@ class Screen:
|
||||
|
||||
def draw(self, data: str) -> None:
|
||||
"""Display decoded characters at the current cursor position."""
|
||||
data = data.translate(
|
||||
self.g1_charset if self.charset else self.g0_charset)
|
||||
data = data.translate(self.g1_charset if self.charset else self.g0_charset)
|
||||
|
||||
for char in data:
|
||||
char_width = wcwidth(char)
|
||||
@@ -303,7 +314,9 @@ class Screen:
|
||||
if _debug_logger is not None and char_width > 0:
|
||||
code = ord(char)
|
||||
if code > 127 or code < 32:
|
||||
_log_debug(f"[DRAW] char={char!r} code=U+{code:04X} width={char_width} pos=({self.cursor.x},{self.cursor.y})")
|
||||
_log_debug(
|
||||
f"[DRAW] char={char!r} code=U+{code:04X} width={char_width} pos=({self.cursor.x},{self.cursor.y})"
|
||||
)
|
||||
|
||||
if self.cursor.x == self.columns:
|
||||
if mo.DECAWM in self.mode:
|
||||
@@ -331,7 +344,9 @@ class Screen:
|
||||
elif self.cursor.y:
|
||||
last = self.buffer[self.cursor.y - 1][self.columns - 1]
|
||||
normalized = unicodedata.normalize("NFC", last.data + char)
|
||||
self.buffer[self.cursor.y - 1][self.columns - 1] = last._replace(data=normalized)
|
||||
self.buffer[self.cursor.y - 1][self.columns - 1] = last._replace(
|
||||
data=normalized
|
||||
)
|
||||
else:
|
||||
break
|
||||
|
||||
@@ -396,12 +411,16 @@ class Screen:
|
||||
|
||||
def save_cursor(self) -> None:
|
||||
"""Push the current cursor position onto the stack."""
|
||||
self.savepoints.append(Savepoint(copy.copy(self.cursor),
|
||||
self.g0_charset,
|
||||
self.g1_charset,
|
||||
self.charset,
|
||||
mo.DECOM in self.mode,
|
||||
mo.DECAWM in self.mode))
|
||||
self.savepoints.append(
|
||||
Savepoint(
|
||||
copy.copy(self.cursor),
|
||||
self.g0_charset,
|
||||
self.g1_charset,
|
||||
self.charset,
|
||||
mo.DECOM in self.mode,
|
||||
mo.DECAWM in self.mode,
|
||||
)
|
||||
)
|
||||
|
||||
def restore_cursor(self) -> None:
|
||||
"""Set the current cursor position to whatever cursor is on top of the stack."""
|
||||
@@ -421,7 +440,7 @@ class Screen:
|
||||
self.reset_mode(mo.DECOM)
|
||||
self.cursor_position()
|
||||
|
||||
def insert_lines(self, count: Optional[int] = None) -> None:
|
||||
def insert_lines(self, count: int | None = None) -> None:
|
||||
"""Insert the indicated # of lines at line with cursor."""
|
||||
count = count or 1
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
@@ -433,7 +452,7 @@ class Screen:
|
||||
self.buffer.pop(y, None)
|
||||
self.carriage_return()
|
||||
|
||||
def delete_lines(self, count: Optional[int] = None) -> None:
|
||||
def delete_lines(self, count: int | None = None) -> None:
|
||||
"""Delete the indicated # of lines."""
|
||||
count = count or 1
|
||||
top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
@@ -447,7 +466,7 @@ class Screen:
|
||||
self.buffer.pop(y, None)
|
||||
self.carriage_return()
|
||||
|
||||
def insert_characters(self, count: Optional[int] = None) -> None:
|
||||
def insert_characters(self, count: int | None = None) -> None:
|
||||
"""Insert the indicated # of blank characters at the cursor position."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
count = count or 1
|
||||
@@ -457,7 +476,7 @@ class Screen:
|
||||
line[x + count] = line[x]
|
||||
line.pop(x, None)
|
||||
|
||||
def delete_characters(self, count: Optional[int] = None) -> None:
|
||||
def delete_characters(self, count: int | None = None) -> None:
|
||||
"""Delete the indicated # of characters."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
count = count or 1
|
||||
@@ -468,7 +487,7 @@ class Screen:
|
||||
else:
|
||||
line.pop(x, None)
|
||||
|
||||
def erase_characters(self, count: Optional[int] = None) -> None:
|
||||
def erase_characters(self, count: int | None = None) -> None:
|
||||
"""Erase the indicated # of characters."""
|
||||
self.dirty.add(self.cursor.y)
|
||||
count = count or 1
|
||||
@@ -525,7 +544,7 @@ class Screen:
|
||||
"""Ensure the cursor is within horizontal screen bounds."""
|
||||
self.cursor.x = min(max(0, self.cursor.x), self.columns - 1)
|
||||
|
||||
def ensure_vbounds(self, use_margins: Optional[bool] = None) -> None:
|
||||
def ensure_vbounds(self, use_margins: bool | None = None) -> None:
|
||||
"""Ensure the cursor is within vertical screen bounds."""
|
||||
if (use_margins or mo.DECOM in self.mode) and self.margins is not None:
|
||||
top, bottom = self.margins
|
||||
@@ -533,39 +552,39 @@ class Screen:
|
||||
top, bottom = 0, self.lines - 1
|
||||
self.cursor.y = min(max(top, self.cursor.y), bottom)
|
||||
|
||||
def cursor_up(self, count: Optional[int] = None) -> None:
|
||||
def cursor_up(self, count: int | None = None) -> None:
|
||||
"""Move cursor up the indicated # of lines."""
|
||||
top, _bottom = self.margins or Margins(0, self.lines - 1)
|
||||
self.cursor.y = max(self.cursor.y - (count or 1), top)
|
||||
|
||||
def cursor_up1(self, count: Optional[int] = None) -> None:
|
||||
def cursor_up1(self, count: int | None = None) -> None:
|
||||
"""Move cursor up the indicated # of lines to column 1."""
|
||||
self.cursor_up(count)
|
||||
self.carriage_return()
|
||||
|
||||
def cursor_down(self, count: Optional[int] = None) -> None:
|
||||
def cursor_down(self, count: int | None = None) -> None:
|
||||
"""Move cursor down the indicated # of lines."""
|
||||
_top, bottom = self.margins or Margins(0, self.lines - 1)
|
||||
self.cursor.y = min(self.cursor.y + (count or 1), bottom)
|
||||
|
||||
def cursor_down1(self, count: Optional[int] = None) -> None:
|
||||
def cursor_down1(self, count: int | None = None) -> None:
|
||||
"""Move cursor down the indicated # of lines to column 1."""
|
||||
self.cursor_down(count)
|
||||
self.carriage_return()
|
||||
|
||||
def cursor_back(self, count: Optional[int] = None) -> None:
|
||||
def cursor_back(self, count: int | None = None) -> None:
|
||||
"""Move cursor left the indicated # of columns."""
|
||||
if self.cursor.x == self.columns:
|
||||
self.cursor.x -= 1
|
||||
self.cursor.x -= count or 1
|
||||
self.ensure_hbounds()
|
||||
|
||||
def cursor_forward(self, count: Optional[int] = None) -> None:
|
||||
def cursor_forward(self, count: int | None = None) -> None:
|
||||
"""Move cursor right the indicated # of columns."""
|
||||
self.cursor.x += count or 1
|
||||
self.ensure_hbounds()
|
||||
|
||||
def cursor_position(self, line: Optional[int] = None, column: Optional[int] = None) -> None:
|
||||
def cursor_position(self, line: int | None = None, column: int | None = None) -> None:
|
||||
"""Set the cursor to a specific line and column."""
|
||||
column = (column or 1) - 1
|
||||
line = (line or 1) - 1
|
||||
@@ -580,12 +599,12 @@ class Screen:
|
||||
self.ensure_hbounds()
|
||||
self.ensure_vbounds()
|
||||
|
||||
def cursor_to_column(self, column: Optional[int] = None) -> None:
|
||||
def cursor_to_column(self, column: int | None = None) -> None:
|
||||
"""Move cursor to a specific column in the current line."""
|
||||
self.cursor.x = (column or 1) - 1
|
||||
self.ensure_hbounds()
|
||||
|
||||
def cursor_to_line(self, line: Optional[int] = None) -> None:
|
||||
def cursor_to_line(self, line: int | None = None) -> None:
|
||||
"""Move cursor to a specific line in the current column."""
|
||||
self.cursor.y = (line or 1) - 1
|
||||
if mo.DECOM in self.mode:
|
||||
@@ -608,7 +627,7 @@ class Screen:
|
||||
"""Set display attributes."""
|
||||
replace = {}
|
||||
|
||||
if not attrs or attrs == (0, ):
|
||||
if not attrs or attrs == (0,):
|
||||
self.cursor.attrs = self.default_char
|
||||
return
|
||||
|
||||
@@ -637,8 +656,9 @@ class Screen:
|
||||
m = attrs_list.pop()
|
||||
replace[key] = g.FG_BG_256[m]
|
||||
elif n == 2:
|
||||
replace[key] = "{0:02x}{1:02x}{2:02x}".format(
|
||||
attrs_list.pop(), attrs_list.pop(), attrs_list.pop())
|
||||
replace[key] = (
|
||||
f"{attrs_list.pop():02x}{attrs_list.pop():02x}{attrs_list.pop():02x}"
|
||||
)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
@@ -659,7 +679,7 @@ class Screen:
|
||||
if mo.DECOM in self.mode:
|
||||
assert self.margins is not None
|
||||
y -= self.margins.top
|
||||
self.write_process_input(ctrl.CSI + "{0};{1}R".format(y, x))
|
||||
self.write_process_input(ctrl.CSI + f"{y};{x}R")
|
||||
|
||||
def write_process_input(self, data: str) -> None:
|
||||
"""Write data to the process running inside the terminal."""
|
||||
@@ -673,10 +693,13 @@ class Screen:
|
||||
|
||||
class DiffScreen(Screen):
|
||||
"""A screen subclass, which maintains a set of dirty lines. Deprecated."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
warnings.warn(
|
||||
"The functionality of ``DiffScreen` has been merged into "
|
||||
"``Screen`` and will be removed in 0.8.0.", DeprecationWarning)
|
||||
"``Screen`` and will be removed in 0.8.0.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
super(DiffScreen, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -694,12 +717,10 @@ class HistoryScreen(Screen):
|
||||
_wrapped = set(Stream.events)
|
||||
_wrapped.update(["next_page", "prev_page"])
|
||||
|
||||
def __init__(self, columns: int, lines: int, history: int = 100, ratio: float = .5) -> None:
|
||||
self.history = History(deque(maxlen=history),
|
||||
deque(maxlen=history),
|
||||
float(ratio),
|
||||
history,
|
||||
history)
|
||||
def __init__(self, columns: int, lines: int, history: int = 100, ratio: float = 0.5) -> None:
|
||||
self.history = History(
|
||||
deque(maxlen=history), deque(maxlen=history), float(ratio), history, history
|
||||
)
|
||||
super(HistoryScreen, self).__init__(columns, lines)
|
||||
|
||||
def _make_wrapper(self, event: str, handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@@ -708,6 +729,7 @@ class HistoryScreen(Screen):
|
||||
result = handler(*args, **kwargs)
|
||||
self.after_event(event)
|
||||
return result
|
||||
|
||||
return inner
|
||||
|
||||
def __getattribute__(self, attr: str) -> Callable[..., Any]:
|
||||
@@ -732,8 +754,7 @@ class HistoryScreen(Screen):
|
||||
line.pop(x)
|
||||
|
||||
self.cursor.hidden = not (
|
||||
self.history.position == self.history.size and
|
||||
mo.DECTCEM in self.mode
|
||||
self.history.position == self.history.size and mo.DECTCEM in self.mode
|
||||
)
|
||||
|
||||
def _reset_history(self) -> None:
|
||||
@@ -769,12 +790,11 @@ class HistoryScreen(Screen):
|
||||
def prev_page(self) -> None:
|
||||
"""Move the screen page up through the history buffer."""
|
||||
if self.history.position > self.lines and self.history.top:
|
||||
mid = min(len(self.history.top),
|
||||
int(math.ceil(self.lines * self.history.ratio)))
|
||||
mid = min(len(self.history.top), int(math.ceil(self.lines * self.history.ratio)))
|
||||
|
||||
self.history.bottom.extendleft(
|
||||
self.buffer[y]
|
||||
for y in range(self.lines - 1, self.lines - mid - 1, -1))
|
||||
self.buffer[y] for y in range(self.lines - 1, self.lines - mid - 1, -1)
|
||||
)
|
||||
self.history = self.history._replace(position=self.history.position - mid)
|
||||
|
||||
for y in range(self.lines - 1, mid - 1, -1):
|
||||
@@ -787,8 +807,7 @@ class HistoryScreen(Screen):
|
||||
def next_page(self) -> None:
|
||||
"""Move the screen page down through the history buffer."""
|
||||
if self.history.position < self.history.size and self.history.bottom:
|
||||
mid = min(len(self.history.bottom),
|
||||
int(math.ceil(self.lines * self.history.ratio)))
|
||||
mid = min(len(self.history.bottom), int(math.ceil(self.lines * self.history.ratio)))
|
||||
|
||||
self.history.top.extend(self.buffer[y] for y in range(mid))
|
||||
self.history = self.history._replace(position=self.history.position + mid)
|
||||
@@ -803,6 +822,7 @@ class HistoryScreen(Screen):
|
||||
|
||||
class DebugEvent(NamedTuple):
|
||||
"""Event dispatched to DebugScreen."""
|
||||
|
||||
name: str
|
||||
args: Any
|
||||
kwargs: Any
|
||||
@@ -830,6 +850,7 @@ class DebugScreen:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
self.to.write(str(DebugEvent(attr, args, kwargs)))
|
||||
self.to.write(str(os.linesep))
|
||||
|
||||
return wrapper
|
||||
|
||||
def __getattribute__(self, attr: str) -> Callable[..., None]:
|
||||
|
||||
Vendored
+64
-51
@@ -1,25 +1,26 @@
|
||||
"""
|
||||
pyte.streams
|
||||
~~~~~~~~~~~~
|
||||
pyte.streams
|
||||
~~~~~~~~~~~~
|
||||
|
||||
This module provides three stream implementations with different
|
||||
features; for starters, here's a quick example of how streams are
|
||||
typically used:
|
||||
This module provides three stream implementations with different
|
||||
features; for starters, here's a quick example of how streams are
|
||||
typically used:
|
||||
|
||||
>>> import pyte
|
||||
>>> screen = pyte.Screen(80, 24)
|
||||
>>> stream = pyte.Stream(screen)
|
||||
>>> stream.feed("\x1b[5B") # Move the cursor down 5 rows.
|
||||
>>> screen.cursor.y
|
||||
5
|
||||
>>> import pyte
|
||||
>>> screen = pyte.Screen(80, 24)
|
||||
>>> stream = pyte.Stream(screen)
|
||||
>>> stream.feed("\x1b[5B") # Move the cursor down 5 rows.
|
||||
>>> screen.cursor.y
|
||||
5
|
||||
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
:copyright: (c) 2011-2012 by Selectel.
|
||||
:copyright: (c) 2012-2017 by pyte authors and contributors,
|
||||
see AUTHORS for details.
|
||||
:license: LGPL, see LICENSE for more details.
|
||||
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
Vendored for Clide with modifications for diagnostic logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
@@ -27,21 +28,22 @@ import itertools
|
||||
import re
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Callable, Dict, Generator, Optional, TYPE_CHECKING
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from . import control as ctrl, escape as esc
|
||||
from . import control as ctrl
|
||||
from . import escape as esc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .screens import Screen
|
||||
|
||||
|
||||
# Clide diagnostic logging support
|
||||
_debug_logger: Optional[Callable[[str], None]] = None
|
||||
_event_callback: Optional[Callable[[str], None]] = None
|
||||
_debug_logger: Callable[[str], None] | None = None
|
||||
_event_callback: Callable[[str], None] | None = None
|
||||
|
||||
|
||||
def set_debug_logger(logger: Optional[Callable[[str], None]]) -> None:
|
||||
def set_debug_logger(logger: Callable[[str], None] | None) -> None:
|
||||
"""Set a debug logger function for diagnostic output.
|
||||
|
||||
Args:
|
||||
@@ -51,7 +53,7 @@ def set_debug_logger(logger: Optional[Callable[[str], None]]) -> None:
|
||||
_debug_logger = logger
|
||||
|
||||
|
||||
def set_event_callback(callback: Optional[Callable[[str], None]]) -> None:
|
||||
def set_event_callback(callback: Callable[[str], None] | None) -> None:
|
||||
"""Set an event callback for raw terminal data.
|
||||
|
||||
This callback is invoked with the raw data before parsing.
|
||||
@@ -70,7 +72,7 @@ def _log_debug(message: str) -> None:
|
||||
_debug_logger(message)
|
||||
|
||||
|
||||
ParserGenerator = Generator[Optional[bool], str, None]
|
||||
ParserGenerator = Generator[bool | None, str, None]
|
||||
|
||||
|
||||
class Stream:
|
||||
@@ -158,30 +160,35 @@ class Stream:
|
||||
esc.SGR: "select_graphic_rendition",
|
||||
esc.DSR: "report_device_status",
|
||||
esc.DECSTBM: "set_margins",
|
||||
esc.HPA: "cursor_to_column"
|
||||
esc.HPA: "cursor_to_column",
|
||||
}
|
||||
|
||||
#: A set of all events dispatched by the stream.
|
||||
events = frozenset(itertools.chain(
|
||||
basic.values(), escape.values(), sharp.values(), csi.values(),
|
||||
["define_charset"],
|
||||
["set_icon_name", "set_title"], # OSC.
|
||||
["draw", "debug"]))
|
||||
events = frozenset(
|
||||
itertools.chain(
|
||||
basic.values(),
|
||||
escape.values(),
|
||||
sharp.values(),
|
||||
csi.values(),
|
||||
["define_charset"],
|
||||
["set_icon_name", "set_title"], # OSC.
|
||||
["draw", "debug"],
|
||||
)
|
||||
)
|
||||
|
||||
#: A regular expression pattern matching everything what can be
|
||||
#: considered plain text.
|
||||
_special = set([ctrl.ESC, ctrl.CSI_C1, ctrl.NUL, ctrl.DEL, ctrl.OSC_C1])
|
||||
_special.update(basic)
|
||||
_text_pattern = re.compile(
|
||||
"[^" + "".join(map(re.escape, _special)) + "]+")
|
||||
_text_pattern = re.compile("[^" + "".join(map(re.escape, _special)) + "]+")
|
||||
del _special
|
||||
|
||||
def __init__(self, screen: Optional[Screen] = None, strict: bool = True) -> None:
|
||||
self.listener: Optional[Screen] = None
|
||||
def __init__(self, screen: Screen | None = None, strict: bool = True) -> None:
|
||||
self.listener: Screen | None = None
|
||||
self.strict = strict
|
||||
self.use_utf8: bool = True
|
||||
|
||||
self._taking_plain_text: Optional[bool] = None
|
||||
self._taking_plain_text: bool | None = None
|
||||
|
||||
if screen is not None:
|
||||
self.attach(screen)
|
||||
@@ -192,18 +199,20 @@ class Stream:
|
||||
:param pyte.screens.Screen screen: a screen to attach to.
|
||||
"""
|
||||
if self.listener is not None:
|
||||
warnings.warn("As of version 0.6.0 the listener queue is "
|
||||
"restricted to a single element. Existing "
|
||||
"listener {0} will be replaced."
|
||||
.format(self.listener), DeprecationWarning)
|
||||
warnings.warn(
|
||||
"As of version 0.6.0 the listener queue is "
|
||||
"restricted to a single element. Existing "
|
||||
f"listener {self.listener} will be replaced.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
if self.strict:
|
||||
for event in self.events:
|
||||
if not hasattr(screen, event):
|
||||
raise TypeError("{0} is missing {1}".format(screen, event))
|
||||
raise TypeError(f"{screen} is missing {event}")
|
||||
|
||||
self.listener = screen
|
||||
self._parser: Optional[ParserGenerator] = None
|
||||
self._parser: ParserGenerator | None = None
|
||||
self._initialize_parser()
|
||||
|
||||
def detach(self, screen: Screen) -> None:
|
||||
@@ -230,7 +239,7 @@ class Stream:
|
||||
# Clide: Debug log incoming data
|
||||
if _debug_logger is not None:
|
||||
# Log escape sequences in a readable format
|
||||
escaped = data.encode('unicode_escape').decode('ascii')
|
||||
escaped = data.encode("unicode_escape").decode("ascii")
|
||||
if len(escaped) > 200:
|
||||
escaped = escaped[:200] + "..."
|
||||
_log_debug(f"[STREAM] feed: {escaped}")
|
||||
@@ -254,12 +263,12 @@ class Stream:
|
||||
else:
|
||||
taking_plain_text = False
|
||||
else:
|
||||
taking_plain_text = send(data[offset:offset + 1])
|
||||
taking_plain_text = send(data[offset : offset + 1])
|
||||
offset += 1
|
||||
|
||||
self._taking_plain_text = taking_plain_text
|
||||
|
||||
def _send_to_parser(self, data: str) -> Optional[bool]:
|
||||
def _send_to_parser(self, data: str) -> bool | None:
|
||||
try:
|
||||
assert self._parser is not None
|
||||
return self._parser.send(data)
|
||||
@@ -294,25 +303,28 @@ class Stream:
|
||||
SP_OR_GT = ctrl.SP + ">"
|
||||
NUL_OR_DEL = ctrl.NUL + ctrl.DEL
|
||||
CAN_OR_SUB = ctrl.CAN + ctrl.SUB
|
||||
ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF,
|
||||
ctrl.VT, ctrl.FF, ctrl.CR])
|
||||
ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, ctrl.VT, ctrl.FF, ctrl.CR])
|
||||
OSC_TERMINATORS = set([ctrl.ST_C0, ctrl.ST_C1, ctrl.BEL])
|
||||
|
||||
def create_dispatcher(mapping: Mapping[str, str]) -> Dict[str, Callable[..., None]]:
|
||||
d = defaultdict(lambda: debug, dict(
|
||||
(event, getattr(listener, attr))
|
||||
for event, attr in mapping.items()))
|
||||
def create_dispatcher(mapping: Mapping[str, str]) -> dict[str, Callable[..., None]]:
|
||||
d = defaultdict(
|
||||
lambda: debug,
|
||||
dict((event, getattr(listener, attr)) for event, attr in mapping.items()),
|
||||
)
|
||||
|
||||
# Clide: Wrap dispatchers with debug logging
|
||||
if _debug_logger is not None:
|
||||
original_d = dict(d)
|
||||
for event, attr in mapping.items():
|
||||
original_handler = original_d.get(event, debug)
|
||||
|
||||
def make_wrapper(e: str, a: str, h: Callable[..., None]) -> Callable[..., None]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
_log_debug(f"[DISPATCH] {a}({args}, {kwargs})")
|
||||
return h(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
d[event] = make_wrapper(event, attr, original_handler)
|
||||
|
||||
return d
|
||||
@@ -360,7 +372,7 @@ class Stream:
|
||||
listener.define_charset(code, mode=char)
|
||||
else:
|
||||
escape_dispatch[char]()
|
||||
continue # Don't go to CSI.
|
||||
continue # Don't go to CSI.
|
||||
|
||||
if char in basic:
|
||||
# Ignore shifts in UTF-8 mode. See
|
||||
@@ -485,6 +497,7 @@ class ByteStream(Stream):
|
||||
Assume the input to :meth:`~pyte.streams.ByteStream.feed` is encoded
|
||||
using UTF-8. Defaults to ``True``.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
super(ByteStream, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class DiffPane(Vertical):
|
||||
header = self.query_one(".diff-header", Static)
|
||||
header.update("No diff loaded")
|
||||
|
||||
def on_button_pressed(self, _event: Button.Pressed) -> None:
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if not self._diff:
|
||||
return
|
||||
|
||||
@@ -6,10 +6,10 @@ 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 ContentSwitcher, Tab, TabPane, Tabs
|
||||
from textual.widgets import ContentSwitcher, Tab, Tabs
|
||||
|
||||
from clide.models.diff import DiffContent
|
||||
from clide.widgets.components.action_bar import ActionBar, ActionButton, STANDARD_BUTTONS
|
||||
from clide.widgets.components.action_bar import ActionBar, ActionButton
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
@@ -104,6 +104,7 @@ class WorkspacePanel(Vertical):
|
||||
|
||||
class CloseRequested(Message):
|
||||
"""Emitted when workspace should be hidden."""
|
||||
|
||||
pass
|
||||
|
||||
# Reactive state - persisted when hidden
|
||||
@@ -114,10 +115,12 @@ class WorkspacePanel(Vertical):
|
||||
# Messages for app-level actions
|
||||
class MaximizeRequested(Message):
|
||||
"""Emitted when workspace should be maximized."""
|
||||
|
||||
pass
|
||||
|
||||
class RestoreRequested(Message):
|
||||
"""Emitted when workspace should be restored from maximized."""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
@@ -163,43 +166,53 @@ class WorkspacePanel(Vertical):
|
||||
|
||||
# Register standard buttons using simple ASCII icons
|
||||
# Save button (for editor)
|
||||
self._action_bar.register_button(ActionButton(
|
||||
id="save",
|
||||
icon="[S]",
|
||||
tooltip="Save",
|
||||
))
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="save",
|
||||
icon="[S]",
|
||||
tooltip="Save",
|
||||
)
|
||||
)
|
||||
|
||||
# Add separator
|
||||
self._action_bar.add_separator()
|
||||
|
||||
# Close button
|
||||
self._action_bar.register_button(ActionButton(
|
||||
id="close",
|
||||
icon="x",
|
||||
tooltip="Close",
|
||||
))
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="close",
|
||||
icon="x",
|
||||
tooltip="Close",
|
||||
)
|
||||
)
|
||||
|
||||
# Minimize button
|
||||
self._action_bar.register_button(ActionButton(
|
||||
id="minimize",
|
||||
icon="_",
|
||||
tooltip="Minimize",
|
||||
))
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="minimize",
|
||||
icon="_",
|
||||
tooltip="Minimize",
|
||||
)
|
||||
)
|
||||
|
||||
# Maximize button
|
||||
self._action_bar.register_button(ActionButton(
|
||||
id="maximize",
|
||||
icon="^",
|
||||
tooltip="Maximize",
|
||||
))
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="maximize",
|
||||
icon="^",
|
||||
tooltip="Maximize",
|
||||
)
|
||||
)
|
||||
|
||||
# Restore button (hidden by default)
|
||||
self._action_bar.register_button(ActionButton(
|
||||
id="restore",
|
||||
icon="v",
|
||||
tooltip="Restore",
|
||||
visible=False,
|
||||
))
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="restore",
|
||||
icon="v",
|
||||
tooltip="Restore",
|
||||
visible=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Update button visibility based on current tab
|
||||
self._update_action_bar_for_tab(self.active_tab)
|
||||
|
||||
+273
-256
@@ -1,11 +1,11 @@
|
||||
# Clide Architecture Documentation
|
||||
# Clide Architecture
|
||||
|
||||
Comprehensive documentation of architecture patterns, best practices, and implementation guidelines.
|
||||
Technical documentation covering Clide's architecture, the frameworks it builds on, and implementation patterns.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Application Architecture](#application-architecture)
|
||||
- [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)
|
||||
@@ -13,199 +13,238 @@ Comprehensive documentation of architecture patterns, best practices, and implem
|
||||
|
||||
---
|
||||
|
||||
## Application Architecture
|
||||
|
||||
Clide follows a layered architecture with clear separation between UI, business logic, and data.
|
||||
|
||||
### Layer Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ClideApp (app.py) │
|
||||
│ Main application, layout, keybindings │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Widgets Layer │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Panels │ │ Components │ │ Themes │ │
|
||||
│ │ (layout) │ │ (reusable) │ │ (styling) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Controllers Layer │
|
||||
│ Business logic, state management │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Services Layer │
|
||||
│ Git, files, scanning, settings, skills │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Models Layer │
|
||||
│ Pydantic data structures │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Action (click, keypress)
|
||||
│
|
||||
▼
|
||||
Widget Event
|
||||
│
|
||||
▼
|
||||
Message Bubbles Up
|
||||
│
|
||||
▼
|
||||
App Event Handler
|
||||
│
|
||||
▼
|
||||
Controller Method
|
||||
│
|
||||
▼
|
||||
Service Call
|
||||
│
|
||||
▼
|
||||
Return Data/Status
|
||||
│
|
||||
▼
|
||||
Update UI State
|
||||
│
|
||||
▼
|
||||
Reactive UI Update
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Message-based communication** — Widgets emit messages that bubble up. Parent widgets or the app handle messages and coordinate responses.
|
||||
|
||||
**Reactive properties** — UI state uses Textual's `reactive` type. Changes automatically trigger `watch_*` methods.
|
||||
|
||||
**Background workers** — Long operations use `@work(thread=True)` to avoid blocking the UI.
|
||||
|
||||
**State preservation** — Hiding panels uses `display: none`, never destroying widgets. All state persists.
|
||||
|
||||
For detailed code organization, see [Code Organization](code-organization.md).
|
||||
|
||||
---
|
||||
|
||||
## Textual TUI Framework
|
||||
|
||||
Textual models TUIs as a reactive tree of widgets, similar to React's component tree but grid-based on character cells.
|
||||
Textual provides the foundation for Clide's terminal UI.
|
||||
|
||||
### Key Concepts
|
||||
### Core 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
|
||||
**Widgets** — Building blocks of the UI. Everything visible is a widget.
|
||||
|
||||
**Reactive Programming**
|
||||
- State changes trigger automatic UI updates
|
||||
- No manual refresh loops needed
|
||||
- Use reactive attributes for state management
|
||||
**Containers** — Widgets that hold other widgets (Vertical, Horizontal, Container).
|
||||
|
||||
**Event-Driven Model**
|
||||
- Define callbacks for key presses, mouse clicks, timer ticks
|
||||
- Actions are functions callable via keystroke or text link
|
||||
**Reactive Programming** — State changes trigger automatic UI updates.
|
||||
|
||||
### Best Practices
|
||||
**CSS Styling** — Layout and appearance defined in CSS, similar to web development.
|
||||
|
||||
1. **Use Immutable Objects**
|
||||
- Prefer tuples, NamedTuples, or frozen dataclasses
|
||||
- Easier to reason about, cache, and test
|
||||
- Enables side-effect-free code
|
||||
### Layout System
|
||||
|
||||
2. **Separate Styles**
|
||||
- Keep CSS in `.tcss` files, not inline
|
||||
- Python code stays clean and focused on logic
|
||||
Clide uses CSS Grid for the main layout:
|
||||
|
||||
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 = """
|
||||
```css
|
||||
Screen {
|
||||
layout: grid;
|
||||
grid-size: 3 1;
|
||||
grid-columns: 1fr 2fr 1fr;
|
||||
grid-columns: 20% 1fr 25%;
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
Panels use percentage widths with minimum sizes:
|
||||
|
||||
```css
|
||||
#panel-sidebar {
|
||||
width: 20%;
|
||||
min-width: 25;
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Lifecycle
|
||||
|
||||
```python
|
||||
class MyWidget(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Initialize instance variables
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
# Yield child widgets
|
||||
yield Label("Hello")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Called after widget is added to DOM
|
||||
# Safe to query other widgets here
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
# Cleanup when removed
|
||||
```
|
||||
|
||||
### Event Handling
|
||||
|
||||
Events bubble up through the widget tree:
|
||||
|
||||
```python
|
||||
# Define a message
|
||||
class FileSelected(Message):
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
# Emit the message
|
||||
self.post_message(self.FileSelected(path))
|
||||
|
||||
# Handle in parent (naming convention: on_<widget>_<message>)
|
||||
def on_files_view_file_selected(self, event: FilesView.FileSelected):
|
||||
self.open_file(event.path)
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Use `@work` for operations that shouldn't block the UI:
|
||||
|
||||
```python
|
||||
from textual import work
|
||||
|
||||
@work(thread=True)
|
||||
def fetch_data(self) -> dict:
|
||||
"""Runs in thread pool."""
|
||||
result = expensive_operation()
|
||||
return result
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
self.update_ui(event.worker.result)
|
||||
```
|
||||
|
||||
### 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/)
|
||||
- [Textual Widgets](https://textual.textualize.io/widgets/)
|
||||
- [Textual CSS](https://textual.textualize.io/guide/CSS/)
|
||||
|
||||
---
|
||||
|
||||
## Pydantic Data Validation
|
||||
|
||||
Pydantic v2 with strict mode ensures type safety and validation.
|
||||
All data models use Pydantic v2 with strict mode.
|
||||
|
||||
### Strict Mode Configuration
|
||||
### Model Configuration
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class MyModel(BaseModel):
|
||||
class GitChange(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
name: str
|
||||
count: int # Will reject "123" string
|
||||
|
||||
path: str
|
||||
status: Literal["added", "modified", "deleted"]
|
||||
staged: bool
|
||||
```
|
||||
|
||||
**strict=True** — No type coercion. `"123"` won't become `123`.
|
||||
|
||||
**frozen=True** — Immutable instances. Enables hashing for use as dict keys.
|
||||
|
||||
### Settings Management
|
||||
|
||||
Settings have moved to `pydantic-settings` package:
|
||||
Application settings use `pydantic-settings`:
|
||||
|
||||
```python
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
class ClideSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="APP_",
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".env",
|
||||
env_nested_delimiter="__",
|
||||
)
|
||||
|
||||
database_url: str
|
||||
debug: bool = False
|
||||
theme: str = "summer-night"
|
||||
jira_enabled: 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)
|
||||
Settings load from (in priority order):
|
||||
1. Environment variables (`CLIDE_THEME=dracula`)
|
||||
2. `.env` file
|
||||
3. Default values
|
||||
|
||||
### References
|
||||
|
||||
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
|
||||
- [Pydantic 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.
|
||||
Clide uses Pluggy for hook-based extensibility.
|
||||
|
||||
### Pluggy Concepts
|
||||
### Hook Specifications
|
||||
|
||||
1. **Hook Specifications** - Define the interface extensions implement
|
||||
2. **Hook Implementations** - Extension code implementing hooks
|
||||
3. **Plugin Manager** - Discovers and calls implementations
|
||||
|
||||
### Architecture
|
||||
Hooks define extension points:
|
||||
|
||||
```python
|
||||
# hookspecs.py - Define hooks
|
||||
# clide/extensions/hookspecs.py
|
||||
import pluggy
|
||||
|
||||
hookspec = pluggy.HookspecMarker("clide")
|
||||
@@ -213,18 +252,36 @@ hookimpl = pluggy.HookimplMarker("clide")
|
||||
|
||||
class ClideHookSpec:
|
||||
@hookspec
|
||||
def register_panel(self) -> dict: ...
|
||||
def clide_startup(self, app: App) -> None:
|
||||
"""Called when the app starts."""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_changed(self, event: FileEvent) -> None:
|
||||
"""Called when a file changes."""
|
||||
```
|
||||
|
||||
### Implementing Hooks
|
||||
|
||||
Extensions implement hooks with the `@hookimpl` decorator:
|
||||
|
||||
```python
|
||||
from clide.extensions import hookimpl
|
||||
|
||||
# extension.py - Implement hooks
|
||||
class MyExtension:
|
||||
@hookimpl
|
||||
def register_panel(self) -> dict:
|
||||
return {"name": "custom", "widget": CustomWidget}
|
||||
def clide_startup(self, app: App) -> None:
|
||||
app.notify("Extension loaded!")
|
||||
|
||||
@hookimpl
|
||||
def clide_on_file_changed(self, event: FileEvent) -> None:
|
||||
if event.path.suffix == ".py":
|
||||
# React to Python file changes
|
||||
pass
|
||||
```
|
||||
|
||||
### Distribution
|
||||
|
||||
Extensions can be distributed as packages using entry points:
|
||||
Extensions can be packaged and distributed via entry points:
|
||||
|
||||
```toml
|
||||
# pyproject.toml of extension package
|
||||
@@ -232,55 +289,73 @@ Extensions can be distributed as packages using entry points:
|
||||
my_extension = "my_package:MyExtension"
|
||||
```
|
||||
|
||||
### Hook Execution Order
|
||||
### Available Hooks
|
||||
|
||||
- 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)
|
||||
| Hook | When Called |
|
||||
|------|-------------|
|
||||
| `clide_startup` | App initialization |
|
||||
| `clide_shutdown` | App cleanup |
|
||||
| `clide_on_file_changed` | File created/modified/deleted |
|
||||
| `clide_on_file_saved` | File saved in editor |
|
||||
|
||||
### 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
|
||||
### Test Organization
|
||||
|
||||
Configure auto mode for automatic async test discovery:
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Isolated component tests
|
||||
├── integration/ # Component interaction tests
|
||||
└── snapshots/ # Visual regression tests
|
||||
```
|
||||
|
||||
### Async Testing
|
||||
|
||||
Configure pytest-asyncio in auto mode:
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
```
|
||||
|
||||
### Async Test Patterns
|
||||
Tests can be async without decorators:
|
||||
|
||||
```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
|
||||
### Snapshot Testing
|
||||
|
||||
Visual regression testing with pytest-textual-snapshot:
|
||||
|
||||
```python
|
||||
def test_layout(snap_compare):
|
||||
assert snap_compare(ClideApp(), terminal_size=(120, 40))
|
||||
|
||||
def test_with_interaction(snap_compare):
|
||||
async def setup(pilot):
|
||||
await pilot.press("tab", "enter")
|
||||
|
||||
assert snap_compare(ClideApp(), run_before=setup)
|
||||
```
|
||||
|
||||
Update snapshots after intentional changes:
|
||||
|
||||
```bash
|
||||
pytest tests/snapshots/ --snapshot-update
|
||||
```
|
||||
|
||||
### Mocking
|
||||
|
||||
Use `AsyncMock` for async dependencies:
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -291,73 +366,43 @@ async def test_with_mock():
|
||||
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-asyncio](https://pytest-asyncio.readthedocs.io/)
|
||||
- [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
|
||||
### Development
|
||||
|
||||
**Critical: PyInstaller cannot cross-compile.**
|
||||
- Build on the target OS
|
||||
- Use CI/CD for multi-platform builds
|
||||
```bash
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
### CI/CD Multi-Platform Build
|
||||
### PyInstaller
|
||||
|
||||
Use Gitea Actions (or compatible CI) for multi-platform builds:
|
||||
Build standalone executables:
|
||||
|
||||
```bash
|
||||
pip install -e ".[build]"
|
||||
pyinstaller clide.spec --clean
|
||||
```
|
||||
|
||||
**Important**: PyInstaller cannot cross-compile. Build on each target platform.
|
||||
|
||||
### CI/CD
|
||||
|
||||
Multi-platform builds via Gitea Actions:
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/build.yml
|
||||
name: Build
|
||||
on: [push, tag]
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -371,45 +416,17 @@ jobs:
|
||||
|
||||
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
|
||||
# ... same steps
|
||||
```
|
||||
|
||||
### Optimization Tips
|
||||
### Optimization
|
||||
|
||||
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.
|
||||
- Use `--onefile` for single executable
|
||||
- Apply `--strip` to reduce size
|
||||
- Use UPX compression for further reduction
|
||||
- Exclude unused modules with `--exclude-module`
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# Code Organization
|
||||
|
||||
Clide follows a layered architecture separating UI components from business logic, enabling testability and maintainability.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
clide/
|
||||
├── app.py # Main application, layout, keybindings
|
||||
├── cli.py # Typer entry point
|
||||
├── models/ # Pydantic data models
|
||||
├── services/ # Background services and utilities
|
||||
├── controllers/ # Business logic (no UI)
|
||||
├── widgets/
|
||||
│ ├── panels/ # Main layout containers
|
||||
│ └── components/ # Reusable UI pieces
|
||||
├── themes/ # Theme definitions and registry
|
||||
├── extensions/ # Plugin system (hookspecs, manager)
|
||||
├── templates/ # Bundled templates (skills, etc.)
|
||||
└── vendor/ # Vendored dependencies (pyte)
|
||||
```
|
||||
|
||||
## Layers
|
||||
|
||||
### Models (`clide/models/`)
|
||||
|
||||
Pure data structures using Pydantic with strict mode. Models are immutable and contain no business logic.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**Key models:**
|
||||
- `git.py` — Git-related types (GitBranch, GitCommit, GitChange, GitStatus)
|
||||
- `config.py` — Application settings (ClideSettings, PanelConfig)
|
||||
- `problems.py` — Linter output (Problem, Severity)
|
||||
- `todos.py` — TODO items (TodoItem, ProjectTodoItem, TodoType)
|
||||
|
||||
### Services (`clide/services/`)
|
||||
|
||||
Stateless utilities that perform work without UI interaction. Services may be async or use background threads.
|
||||
|
||||
```python
|
||||
class GitService:
|
||||
"""Git operations via subprocess."""
|
||||
|
||||
def __init__(self, workdir: Path):
|
||||
self._workdir = workdir
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
"""Get current repository status."""
|
||||
...
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""List all branches."""
|
||||
...
|
||||
```
|
||||
|
||||
**Key services:**
|
||||
- `git_service.py` — Git CLI operations
|
||||
- `file_service.py` — File read/write operations
|
||||
- `todo_scanner.py` — Scans codebase for TODO/FIXME comments
|
||||
- `settings_service.py` — User settings persistence
|
||||
- `skill_installer.py` — Claude Code skill management
|
||||
- `file_watcher.py` — File system change monitoring
|
||||
- `syntax_service.py` — Tree-sitter syntax highlighting
|
||||
|
||||
### Controllers (`clide/controllers/`)
|
||||
|
||||
Bridge between services and UI. Controllers contain business logic, manage state, and emit Textual messages. Controllers have no direct UI rendering.
|
||||
|
||||
```python
|
||||
from clide.controllers.base import controller
|
||||
|
||||
@controller
|
||||
class GitController:
|
||||
"""Manages git state and operations."""
|
||||
|
||||
def __init__(self, workdir: Path):
|
||||
self._service = GitService(workdir)
|
||||
self._status: GitStatus | None = None
|
||||
|
||||
async def refresh_status(self) -> GitStatus:
|
||||
"""Refresh and cache git status."""
|
||||
self._status = await self._service.get_status()
|
||||
return self._status
|
||||
|
||||
def stage_file(self, path: str) -> None:
|
||||
"""Stage a file for commit."""
|
||||
...
|
||||
```
|
||||
|
||||
**Key controllers:**
|
||||
- `git.py` — Git operations, skill integration
|
||||
- `editor.py` — File editing state
|
||||
- `diff.py` — Diff viewing and management
|
||||
- `problems.py` — Linter integration
|
||||
- `todos.py` — TODO tracking
|
||||
- `jira.py` — Jira CLI integration
|
||||
|
||||
### Widgets (`clide/widgets/`)
|
||||
|
||||
UI components split into panels (layout containers) and components (reusable pieces).
|
||||
|
||||
#### Panels (`clide/widgets/panels/`)
|
||||
|
||||
Top-level layout containers that compose the application UI.
|
||||
|
||||
```python
|
||||
class SidebarPanel(Vertical):
|
||||
"""Left sidebar with Files, Git, and Tree tabs."""
|
||||
|
||||
class FileSelected(Message):
|
||||
"""Emitted when a file is selected."""
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
with TabPane("Files"):
|
||||
yield FilesView(path=self._workdir)
|
||||
with TabPane("Git"):
|
||||
yield GitChangesView()
|
||||
with TabPane("Tree"):
|
||||
yield GitGraphView()
|
||||
yield BranchStatus()
|
||||
```
|
||||
|
||||
**Panels:**
|
||||
- `sidebar.py` — Left sidebar (files, git, graph)
|
||||
- `context.py` — Right sidebar (problems, todos, jira)
|
||||
- `workspace.py` — Center workspace (editor, diff, terminal)
|
||||
- `claude.py` — Claude Code terminal integration
|
||||
|
||||
#### Components (`clide/widgets/components/`)
|
||||
|
||||
Reusable UI pieces composed into panels.
|
||||
|
||||
**File browsing:**
|
||||
- `files_view.py` — Project file tree
|
||||
- `file_entry.py` — Single file/directory entry
|
||||
|
||||
**Git:**
|
||||
- `git_changes.py` — Staged/unstaged file lists
|
||||
- `git_graph.py` — Visual branch graph
|
||||
- `branch_status.py` — Branch indicator with popout selector
|
||||
|
||||
**Context:**
|
||||
- `problems_view.py` — Linter problems list
|
||||
- `todos_view.py` — TODO/FIXME list with sub-tabs
|
||||
- `jira_view.py` — Jira issue display
|
||||
|
||||
**Editor:**
|
||||
- `editor_pane.py` — Code editor with syntax highlighting
|
||||
- `diff_pane.py` — Side-by-side diff viewer
|
||||
- `terminal_pane.py` — Command execution terminal
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
### Message Flow
|
||||
|
||||
Components communicate via Textual's message system. Messages bubble up through the widget tree.
|
||||
|
||||
```
|
||||
Component emits message
|
||||
│
|
||||
▼
|
||||
Parent panel receives and may re-emit
|
||||
│
|
||||
▼
|
||||
App handles and coordinates response
|
||||
│
|
||||
▼
|
||||
App calls controller methods
|
||||
│
|
||||
▼
|
||||
Controller updates state, may emit messages
|
||||
│
|
||||
▼
|
||||
UI updates reactively
|
||||
```
|
||||
|
||||
**Example: File selection**
|
||||
|
||||
```python
|
||||
# In FilesView (component)
|
||||
class FileSelected(Message):
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def on_tree_node_selected(self, event):
|
||||
if event.node.data.is_file:
|
||||
self.post_message(self.FileSelected(event.node.data.path))
|
||||
|
||||
# In SidebarPanel (panel)
|
||||
def on_files_view_file_selected(self, event: FilesView.FileSelected):
|
||||
# Re-emit for app to handle
|
||||
self.post_message(self.FileSelected(event.path))
|
||||
|
||||
# In ClideApp (app)
|
||||
def on_sidebar_panel_file_selected(self, event: SidebarPanel.FileSelected):
|
||||
self.editor_controller.open_file(event.path)
|
||||
self.show_workspace("editor")
|
||||
```
|
||||
|
||||
### Reactive Properties
|
||||
|
||||
State that affects UI uses Textual's reactive system:
|
||||
|
||||
```python
|
||||
class ClideApp(App):
|
||||
# Reactive state
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
current_branch: reactive[str] = reactive("main")
|
||||
|
||||
def watch_workspace_visible(self, visible: bool) -> None:
|
||||
"""React to workspace visibility changes."""
|
||||
workspace = self.query_one("#panel-workspace")
|
||||
workspace.display = visible
|
||||
|
||||
claude = self.query_one("#panel-claude")
|
||||
claude.styles.height = "40%" if visible else "100%"
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Long-running operations use the `@work` decorator to avoid blocking the UI:
|
||||
|
||||
```python
|
||||
from textual import work
|
||||
|
||||
class ClideApp(App):
|
||||
@work(thread=True)
|
||||
def refresh_git_status(self) -> None:
|
||||
"""Refresh git status in background."""
|
||||
status = self.git_controller.get_status_sync()
|
||||
self.call_from_thread(self._update_git_ui, status)
|
||||
|
||||
def _update_git_ui(self, status: GitStatus) -> None:
|
||||
"""Update UI with git status (runs on main thread)."""
|
||||
sidebar = self.query_one(SidebarPanel)
|
||||
sidebar.update_git_status(status.staged, status.unstaged)
|
||||
```
|
||||
|
||||
## Extension System
|
||||
|
||||
Clide uses Pluggy for extensibility. Extensions implement hooks defined in `hookspecs.py`.
|
||||
|
||||
```python
|
||||
# clide/extensions/hookspecs.py
|
||||
class ClideHookSpec:
|
||||
@hookspec
|
||||
def clide_startup(self, app: App) -> None:
|
||||
"""Called when app starts."""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_changed(self, event: FileEvent) -> None:
|
||||
"""Called when a file changes."""
|
||||
|
||||
# User extension
|
||||
class MyExtension:
|
||||
@hookimpl
|
||||
def clide_on_file_changed(self, event: FileEvent) -> None:
|
||||
if event.path.suffix == ".py":
|
||||
# Custom logic for Python files
|
||||
...
|
||||
```
|
||||
|
||||
**Available hooks:**
|
||||
- `clide_startup` — App initialization
|
||||
- `clide_shutdown` — App cleanup
|
||||
- `clide_on_file_changed` — File system changes
|
||||
- `clide_on_file_saved` — File saved in editor
|
||||
|
||||
## Skills System
|
||||
|
||||
Clide integrates with Claude Code skills for git operations. Skills are installed to the project's `.claude/skills/` directory.
|
||||
|
||||
```python
|
||||
# clide/services/skill_installer.py
|
||||
class SkillInstaller:
|
||||
def install(self, skill_name: str, scope: Literal["user", "project"] = "project"):
|
||||
"""Install a skill from bundled templates."""
|
||||
template_dir = TEMPLATES_DIR / skill_name
|
||||
target_dir = self.project_skills_dir / skill_name
|
||||
shutil.copytree(template_dir, target_dir)
|
||||
```
|
||||
|
||||
**Bundled skills** (`clide/templates/skills/`):
|
||||
- `commit` — Git commit workflow
|
||||
- `stash` — Git stash operations
|
||||
- `pull` — Git pull with rebase
|
||||
- `push` — Git push to remote
|
||||
- `branch` — Branch management
|
||||
|
||||
When a git action button is clicked, Clide ensures the skill is installed before sending the command to Claude.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests mirror the source structure:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/
|
||||
│ ├── test_models.py
|
||||
│ ├── test_services.py
|
||||
│ ├── test_controllers.py
|
||||
│ ├── test_widgets.py
|
||||
│ └── test_app.py
|
||||
├── integration/
|
||||
│ └── test_files_view.py
|
||||
└── snapshots/
|
||||
└── test_app_snapshots.py
|
||||
```
|
||||
|
||||
**Unit tests** verify individual components in isolation.
|
||||
**Integration tests** verify component interactions.
|
||||
**Snapshot tests** catch visual regressions using pytest-textual-snapshot.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Application Settings
|
||||
|
||||
`ClideSettings` in `clide/models/config.py` defines app configuration loaded from environment or `.config/settings.toml`.
|
||||
|
||||
### User Settings
|
||||
|
||||
`UserSettings` persisted to `~/.clide/settings.json` stores user preferences:
|
||||
- Theme selection
|
||||
- Panel visibility defaults
|
||||
- Compact mode preference
|
||||
- Jira integration settings
|
||||
+155
-292
@@ -1,14 +1,14 @@
|
||||
# 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 specification for Clide's terminal user interface. This document describes the layout, interactions, and design decisions.
|
||||
|
||||
## 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
|
||||
- **Claude-centric** — Claude Code is the primary workspace, always visible
|
||||
- **Contextual panels** — Editor/Diff/Terminal appear only when needed
|
||||
- **Alt-key shortcuts** — Keybindings use Alt to avoid conflicts with Claude Code input
|
||||
- **Responsive** — Works on 13" laptops to widescreen monitors
|
||||
- **State preservation** — Hiding panels preserves all state (never destroy widgets)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,112 +19,105 @@ A terminal-based IDE built with Textual, designed to wrap Claude Code and integr
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ 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 │ │
|
||||
│ 20% │ [Editor][Diff][Terminal]│ 25% │
|
||||
│ │ (hidden when inactive) │ │
|
||||
│ [Files][Git] ├─────────────────────────┤ [Jira][TODOs] │
|
||||
│ [Tree] │ │ [Problems] │
|
||||
│ │ panel-claude │ │
|
||||
│ (content area) │ (40% when workspace │ (content area) │
|
||||
│ │ visible, else 100%) │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ branch-status │ │[⚠ 3][✓12][Jira] │
|
||||
│ ⎇ main ▾ │ │ context-tabs │
|
||||
│ branch-status │ │ │
|
||||
│ ⎇ main ▾ │ │ │
|
||||
│ staged: 2 │ │ │
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
### Panel Definitions
|
||||
### Panel IDs
|
||||
|
||||
```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`)
|
||||
## Left Sidebar
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Content | Widget |
|
||||
|-----|---------|--------|
|
||||
| Files | Project file tree | `DirectoryTree` |
|
||||
| Git | Staged/Unstaged changes | `GitChangesView` (custom) |
|
||||
| Tree | Merge/branch graph | `GitGraphView` (custom) |
|
||||
| Tab | Content | Purpose |
|
||||
|-----|---------|---------|
|
||||
| Files | Project file tree | Navigate and open files |
|
||||
| Git | Staged/Unstaged changes | Review and manage changes |
|
||||
| Tree | Branch graph | Visualize git history |
|
||||
|
||||
### Git Tab Details
|
||||
### Git Tab
|
||||
|
||||
Two collapsible sections:
|
||||
- **Staged**: Files in index, ready to commit
|
||||
- **Unstaged**: Modified/untracked files
|
||||
Two collapsible sections showing staged and unstaged changes.
|
||||
|
||||
Each file item shows:
|
||||
- Status icon: `+` added, `~` modified, `-` deleted, `?` untracked, `→` renamed
|
||||
- File path (relative)
|
||||
**File status indicators:**
|
||||
- `+` Added
|
||||
- `~` Modified
|
||||
- `-` Deleted
|
||||
- `?` Untracked
|
||||
- `→` Renamed
|
||||
|
||||
**Interactions:**
|
||||
- Click file → opens in Editor panel
|
||||
- Double-click or keybind → stage/unstage file
|
||||
- Right-click or keybind → show context menu (discard, diff, etc.)
|
||||
**Action buttons:**
|
||||
- **Commit** — Delegate to Claude with `/commit` skill
|
||||
- **Stash** — Delegate to Claude with `/stash` skill
|
||||
- **Pull** — Delegate to Claude with `/pull` skill
|
||||
- **Push** — Delegate to Claude with `/push` skill
|
||||
|
||||
### Tree Tab Details
|
||||
### Tree Tab
|
||||
|
||||
Renders `git log --graph --oneline --decorate --all` with visual styling.
|
||||
Visual git graph using box-drawing characters:
|
||||
|
||||
**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
|
||||
● 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': '┴──',
|
||||
}
|
||||
```
|
||||
**Symbols:**
|
||||
- `●` Regular commit
|
||||
- `◆` Merge commit
|
||||
- `│` Branch line
|
||||
- `├` Branch point
|
||||
- `┴` Merge point
|
||||
|
||||
### Branch Status Bar
|
||||
|
||||
Fixed at bottom of sidebar. Shows current branch with popout toggle.
|
||||
Fixed at bottom of sidebar. Shows current branch and git stats.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ⎇ main ▾ staged: 2 unstaged: 5│
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Click to expand branch selector:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ ⎇ main ▾ │ ← Click or keybind to expand
|
||||
└─────────────────┘
|
||||
│
|
||||
▼ (popout overlay)
|
||||
┌─────────────────┐
|
||||
│ Recent branches │
|
||||
│ ○ main │
|
||||
│ ● main │
|
||||
│ ○ feature/xyz │
|
||||
│ ○ develop │
|
||||
├─────────────────┤
|
||||
│ [Checkout] [New]│
|
||||
│[Checkout] [New] │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
@@ -132,232 +125,162 @@ Fixed at bottom of sidebar. Shows current branch with popout toggle.
|
||||
|
||||
## Center Column
|
||||
|
||||
### Claude Panel (`panel-claude`)
|
||||
### Claude Panel
|
||||
|
||||
The primary workspace. Displays Claude Code interaction.
|
||||
The primary workspace. Always visible.
|
||||
|
||||
**Default state**: 100% height of center column
|
||||
**With workspace**: 40% height (bottom)
|
||||
**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
|
||||
- Full PTY terminal running Claude Code CLI
|
||||
- Scrollback history (1000 lines)
|
||||
- Input at bottom
|
||||
|
||||
### Workspace Panel (`panel-workspace`)
|
||||
### Workspace Panel
|
||||
|
||||
Tabbed container for Editor, Diff, and Terminal. **Hidden by default.**
|
||||
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
|
||||
**Visibility principle:** Hiding is not closing. All panels retain state:
|
||||
- Editor: Open file, cursor position, scroll, unsaved changes
|
||||
- Diff: Current diff content, scroll position
|
||||
- Terminal: Active session, command history, output buffer
|
||||
|
||||
Use `display: none` for visibility, never destroy/recreate widgets.
|
||||
- Terminal: Command history, output buffer
|
||||
|
||||
**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
|
||||
| Click file in sidebar | Show workspace, focus Editor |
|
||||
| Click problem/TODO | Show workspace, focus Editor at line |
|
||||
| Press `` Alt+` `` | Show workspace, focus Terminal |
|
||||
| Close all content | Hide workspace, Claude reclaims space |
|
||||
|
||||
#### Editor Tab
|
||||
|
||||
- `TextArea` widget with syntax highlighting
|
||||
- Language detection from file extension
|
||||
- Theme: Follow terminal theme or user preference
|
||||
Code editor with:
|
||||
- Syntax highlighting (tree-sitter based)
|
||||
- Line numbers
|
||||
- Current line highlighting
|
||||
|
||||
#### Diff Tab
|
||||
|
||||
- Side-by-side or unified diff view
|
||||
- Syntax highlighting for changed content
|
||||
- Accept/Reject buttons for Claude-proposed changes
|
||||
Side-by-side diff viewer for:
|
||||
- Git changes (staged and unstaged)
|
||||
- Claude-proposed edits
|
||||
|
||||
#### Terminal Tab
|
||||
|
||||
- Proper PTY integration for full terminal emulation
|
||||
- Or simpler command runner with output display (decide based on complexity)
|
||||
Command execution terminal:
|
||||
- Working directory tied to project root
|
||||
- Output preserved when panel hidden
|
||||
|
||||
---
|
||||
|
||||
## Right Sidebar (`panel-context`)
|
||||
## Right Context Panel
|
||||
|
||||
### Content Area
|
||||
### Tabs
|
||||
|
||||
Switches based on selected bottom tab. Shows one of:
|
||||
- Jira view (default)
|
||||
- Problems view
|
||||
- TODOs view
|
||||
| Tab | Badge | Content |
|
||||
|-----|-------|---------|
|
||||
| Jira | — | Jira issue display |
|
||||
| TODOs | Count | TODO/FIXME from code and TODO.md |
|
||||
| Problems | Count | Linter errors and warnings |
|
||||
|
||||
### Bottom Tab Bar (`context-tabs`)
|
||||
Tab badges update reactively as counts change.
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ [⚠ 3][✓12][Jira]│
|
||||
└──────────────────┘
|
||||
```
|
||||
### Jira Tab
|
||||
|
||||
Tabs show inline counts that update reactively.
|
||||
Displays Jira issues via CLI integration. Manual refresh button.
|
||||
|
||||
| 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) |
|
||||
### TODOs Tab
|
||||
|
||||
### Jira View
|
||||
Two sub-tabs:
|
||||
|
||||
Renders markdown output from your CLI tool. Refreshes on:
|
||||
- Panel focus
|
||||
- Manual refresh keybind
|
||||
- Configurable interval
|
||||
**Project tab:** Items from `TODO.md` (checkbox format)
|
||||
**Comments tab:** TODO/FIXME/HACK/XXX comments in code
|
||||
|
||||
### Problems View
|
||||
Click any item to jump to source location.
|
||||
|
||||
Aggregates from linters (eslint, ruff, etc.). Shows:
|
||||
### Problems Tab
|
||||
|
||||
Linter output showing:
|
||||
- File path
|
||||
- Line number
|
||||
- Severity icon
|
||||
- Severity (error/warning)
|
||||
- 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.
|
||||
Click to navigate to source.
|
||||
|
||||
---
|
||||
|
||||
## Responsiveness
|
||||
|
||||
### CSS Breakpoints
|
||||
### CSS Strategy
|
||||
|
||||
```css
|
||||
/* Widescreen (default) */
|
||||
/* Default layout */
|
||||
#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.
|
||||
Toggle with `Alt+C`. Hides both sidebars:
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
All panel state preserved when hidden.
|
||||
|
||||
---
|
||||
|
||||
## Keybindings
|
||||
|
||||
Following VSCode conventions where possible.
|
||||
All shortcuts use `Alt` modifier to avoid conflicts with Claude Code input.
|
||||
|
||||
### Global
|
||||
### Panel Navigation
|
||||
|
||||
| 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` |
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
|
||||
### Navigation
|
||||
### Application
|
||||
|
||||
| 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` |
|
||||
| Command palette | `Alt+P` |
|
||||
| Quick open file | `Alt+O` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Quit | `Alt+Q` |
|
||||
|
||||
### 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` |
|
||||
| Open Git panel | `Alt+G` |
|
||||
|
||||
### Editor
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Save | `Ctrl+S` |
|
||||
| Undo | `Ctrl+Z` |
|
||||
| Redo | `Ctrl+Shift+Z` |
|
||||
| Go to line | `Ctrl+G` |
|
||||
| Save | `Alt+S` |
|
||||
| Go to line | `Alt+L` |
|
||||
| Go to problems | `Alt+M` |
|
||||
|
||||
---
|
||||
|
||||
## Panel Communication
|
||||
|
||||
Panels should feel connected, like a normal IDE.
|
||||
|
||||
### File Navigation
|
||||
### File Navigation Flow
|
||||
|
||||
```
|
||||
Sidebar file click
|
||||
@@ -372,7 +295,7 @@ Editor tab focused
|
||||
File loaded in Editor
|
||||
```
|
||||
|
||||
### Problems/TODOs Navigation
|
||||
### Problem/TODO Navigation Flow
|
||||
|
||||
```
|
||||
Click problem/todo item
|
||||
@@ -387,117 +310,57 @@ Editor tab focused
|
||||
File opened at specific line
|
||||
│
|
||||
▼
|
||||
Line highlighted/scrolled into view
|
||||
Line scrolled into view
|
||||
```
|
||||
|
||||
### Claude Diff Flow
|
||||
### Git Action Flow
|
||||
|
||||
```
|
||||
Claude proposes file changes
|
||||
Click git action button (Commit, Stash, etc.)
|
||||
│
|
||||
▼
|
||||
Workspace appears
|
||||
Ensure skill installed (async, with notification)
|
||||
│
|
||||
▼
|
||||
Diff tab focused
|
||||
Send /command to Claude
|
||||
│
|
||||
▼
|
||||
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
|
||||
Claude executes git workflow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
## State Management
|
||||
|
||||
### 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
|
||||
### Reactive Properties
|
||||
|
||||
```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)
|
||||
class ClideApp(App):
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
current_branch: reactive[str] = reactive("main")
|
||||
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:**
|
||||
### State Preservation
|
||||
|
||||
| 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 |
|
||||
| Editor | Open file, cursor, scroll, unsaved changes |
|
||||
| Diff | Current diff, scroll position |
|
||||
| Terminal | Session, history, output buffer |
|
||||
| Sidebar tabs | Scroll, expanded sections, selection |
|
||||
| Context tabs | Scroll, selected item |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
## Themes
|
||||
|
||||
- **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
|
||||
22 built-in themes with custom theme support.
|
||||
|
||||
**Default:** summer-night (dark theme)
|
||||
|
||||
Theme selection persists in user settings (`~/.clide/settings.json`).
|
||||
|
||||
Custom themes can be added to `~/.clide/themes/` as TOML files.
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# Clide User Manual
|
||||
|
||||
Clide is a terminal-based IDE that puts Claude Code at the center of your development workflow. This manual covers installation, daily usage, and customization.
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.12 or later
|
||||
- Git
|
||||
- Claude Code CLI installed and authenticated
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repo-url>
|
||||
cd clide
|
||||
|
||||
# Install dependencies and create virtual environment
|
||||
make setup
|
||||
|
||||
# Run Clide
|
||||
make run
|
||||
```
|
||||
|
||||
Or install directly:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
clide
|
||||
```
|
||||
|
||||
### First Run
|
||||
|
||||
On first launch, Clide creates a configuration directory at `~/.clide/` for user settings. Project-specific settings are stored in `.clide/` within your project.
|
||||
|
||||
## Interface Overview
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ Sidebar │ Workspace │ Context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (appears when needed) │ [Jira][TODOs] │
|
||||
│ [Tree] ├─────────────────────────┤ [Problems] │
|
||||
│ │ │ │
|
||||
│ │ Claude │ │
|
||||
│ │ (always visible) │ │
|
||||
│ │ │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ │
|
||||
│ staged: 2 │ │ │
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
### Panels
|
||||
|
||||
**Left Sidebar** — File browser, git changes, and branch graph
|
||||
|
||||
**Center** — Claude Code (always visible) and workspace panels (editor, diff, terminal) that appear when needed
|
||||
|
||||
**Right Context** — Jira integration, TODO list, and problems from linters
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
All shortcuts use `Alt` as the modifier to avoid conflicts with Claude Code input.
|
||||
|
||||
### Panel Navigation
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
|
||||
### File Operations
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Quick open file | `Alt+O` |
|
||||
| Save file | `Alt+S` |
|
||||
| Go to line | `Alt+L` |
|
||||
|
||||
### Git Operations
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Open Git panel | `Alt+G` |
|
||||
| Open Problems panel | `Alt+M` |
|
||||
|
||||
### Application
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Command palette | `Alt+P` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Quit | `Alt+Q` |
|
||||
|
||||
## Working with Claude
|
||||
|
||||
Claude Code runs in the center panel and is always visible. Type your prompts directly and Claude will respond with code suggestions, explanations, and file operations.
|
||||
|
||||
### Git Integration
|
||||
|
||||
The sidebar includes buttons for common git operations that delegate to Claude:
|
||||
|
||||
- **Commit** — Claude reviews staged changes and creates a well-formatted commit
|
||||
- **Stash** — Claude stashes your working changes
|
||||
- **Pull** — Claude pulls with rebase and helps resolve conflicts
|
||||
- **Push** — Claude pushes to remote, setting upstream if needed
|
||||
|
||||
On first use, Clide installs the corresponding skill to your project's `.claude/skills/` directory. These skills guide Claude through each operation following best practices.
|
||||
|
||||
### Branch Status
|
||||
|
||||
The branch status bar at the bottom of the sidebar shows:
|
||||
|
||||
- Current branch name
|
||||
- Staged and unstaged file counts
|
||||
|
||||
Click the branch name to open the branch selector:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Recent branches │
|
||||
│ ● main │
|
||||
│ ○ feature/xyz │
|
||||
│ ○ develop │
|
||||
├─────────────────┤
|
||||
│[Checkout] [New] │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Left Sidebar
|
||||
|
||||
### Files Tab
|
||||
|
||||
Browse your project structure. Click a file to open it in the editor.
|
||||
|
||||
- Directories expand/collapse on click
|
||||
- Hidden files (starting with `.`) are shown but dimmed
|
||||
- Noisy directories (`.git`, `__pycache__`, `node_modules`) are filtered
|
||||
|
||||
### Git Tab
|
||||
|
||||
View staged and unstaged changes:
|
||||
|
||||
```
|
||||
Staged (2)
|
||||
+ src/new_file.py
|
||||
~ src/modified.py
|
||||
|
||||
Unstaged (3)
|
||||
~ README.md
|
||||
? untracked.txt
|
||||
- deleted.py
|
||||
```
|
||||
|
||||
**Status indicators:**
|
||||
- `+` Added
|
||||
- `~` Modified
|
||||
- `-` Deleted
|
||||
- `?` Untracked
|
||||
- `→` Renamed
|
||||
|
||||
Click a file to view its diff. Use the action buttons to commit, stash, pull, or push via Claude.
|
||||
|
||||
### Tree Tab
|
||||
|
||||
Visual git graph showing branch history:
|
||||
|
||||
```
|
||||
● main: Latest commit message
|
||||
│
|
||||
├─● feature: Feature work
|
||||
│
|
||||
●─┴ Merge branch 'feature'
|
||||
```
|
||||
|
||||
**Commit types:**
|
||||
- `●` Regular commit
|
||||
- `◆` Merge commit
|
||||
|
||||
## Right Context Panel
|
||||
|
||||
### Jira Tab
|
||||
|
||||
Displays Jira issues when configured. Click the refresh button to update.
|
||||
|
||||
Configure Jira integration in settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"jira_enabled": true,
|
||||
"jira_cli_path": "jira"
|
||||
}
|
||||
```
|
||||
|
||||
### TODOs Tab
|
||||
|
||||
Scans your codebase for TODO comments and project tasks.
|
||||
|
||||
**Sub-tabs:**
|
||||
|
||||
- **Project** — Items from `TODO.md` (checkbox format)
|
||||
- **Comments** — TODO/FIXME/HACK/XXX comments in code
|
||||
|
||||
Click an item to jump to that location in the editor.
|
||||
|
||||
**Supported comment markers:**
|
||||
- `TODO` — Tasks to complete
|
||||
- `FIXME` — Bugs to fix
|
||||
- `HACK` — Temporary solutions
|
||||
- `XXX` — Dangerous or problematic code
|
||||
- `NOTE` — Important notes
|
||||
- `BUG` — Known bugs
|
||||
|
||||
### Problems Tab
|
||||
|
||||
Displays linter errors and warnings. Click a problem to jump to the source location.
|
||||
|
||||
## Workspace Panels
|
||||
|
||||
The workspace appears when you need to view or edit files. It contains three tabs:
|
||||
|
||||
### Editor
|
||||
|
||||
Full-featured code editor with:
|
||||
|
||||
- Syntax highlighting (Python, JavaScript, TypeScript, HTML, CSS, JSON, YAML, Markdown, and more)
|
||||
- Line numbers
|
||||
- Current line highlighting
|
||||
|
||||
### Diff
|
||||
|
||||
Side-by-side diff viewer for reviewing changes. Used when:
|
||||
|
||||
- Viewing git changes
|
||||
- Reviewing Claude's proposed edits
|
||||
|
||||
### Terminal
|
||||
|
||||
Command-line terminal for running commands. Output is preserved when the panel is hidden.
|
||||
|
||||
## Themes
|
||||
|
||||
Clide includes 22 built-in themes. Press `Alt+T` to open the theme selector.
|
||||
|
||||
**Theme categories:**
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| Seasonal | winter-is-coming, monokai-winter, fall, dark-autumn |
|
||||
| Halloween | all-hallows-eve, halloween |
|
||||
| Christmas | christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
| Other | gamma, one-dark-teal, houston |
|
||||
|
||||
Your theme choice is saved and persists across sessions.
|
||||
|
||||
### Custom Themes
|
||||
|
||||
Create custom themes in `~/.clide/themes/`:
|
||||
|
||||
```toml
|
||||
# ~/.clide/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"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### User Settings
|
||||
|
||||
Settings are stored in `~/.clide/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "summer-night",
|
||||
"compact_mode": false,
|
||||
"jira_enabled": false,
|
||||
"jira_cli_path": "jira"
|
||||
}
|
||||
```
|
||||
|
||||
### Project Settings
|
||||
|
||||
Project-specific settings in `.clide/`:
|
||||
|
||||
```
|
||||
.clide/
|
||||
├── settings.json # Project overrides
|
||||
└── skills/ # Installed Claude skills
|
||||
├── commit/
|
||||
├── stash/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Override settings with environment variables prefixed with `CLIDE_`:
|
||||
|
||||
```bash
|
||||
CLIDE_THEME=dracula clide
|
||||
```
|
||||
|
||||
## Compact Mode
|
||||
|
||||
Press `Alt+C` to toggle compact mode, which hides both sidebars for focused work. All panel state is preserved—nothing is lost when hiding panels.
|
||||
|
||||
## Project TODOs
|
||||
|
||||
Clide integrates with a `TODO.md` file in your project root. Format:
|
||||
|
||||
```markdown
|
||||
# TODO
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] Implement user authentication
|
||||
- [ ] Add search functionality
|
||||
- [x] Set up database connection
|
||||
|
||||
## Bugs
|
||||
|
||||
- [ ] Fix login redirect
|
||||
```
|
||||
|
||||
Items appear in the TODOs panel, grouped by section. Click to jump to that line. Check off items directly in the file.
|
||||
|
||||
If no `TODO.md` exists, click "Create TODO.md" in the TODOs panel to generate a template.
|
||||
|
||||
## Tips
|
||||
|
||||
### Efficient Navigation
|
||||
|
||||
1. Use `Alt+1/2/3` to quickly switch between Claude, Editor, and Terminal
|
||||
2. Click items in Problems or TODOs to jump directly to source
|
||||
3. Use compact mode (`Alt+C`) when you need more space for Claude
|
||||
|
||||
### Git Workflow
|
||||
|
||||
1. Make changes to your code
|
||||
2. Review changes in the Git tab
|
||||
3. Click "Commit" to have Claude create a well-formatted commit
|
||||
4. Use "Push" when ready to share
|
||||
|
||||
### Working with Claude
|
||||
|
||||
- Claude sees your project context automatically
|
||||
- Use the git action buttons for consistent commit messages
|
||||
- Click files in the sidebar to show Claude what you're working on
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Claude Code not starting
|
||||
|
||||
Ensure Claude Code CLI is installed and authenticated:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
claude auth status
|
||||
```
|
||||
|
||||
### Theme not applying
|
||||
|
||||
Check that the theme name in settings matches exactly. Theme names are case-sensitive.
|
||||
|
||||
### Skills not working
|
||||
|
||||
Skills are installed to `.claude/skills/` in your project. If a skill fails:
|
||||
|
||||
1. Check that the skill folder exists
|
||||
2. Verify `SKILL.md` is present
|
||||
3. Try removing and re-triggering the action
|
||||
|
||||
### Panels not updating
|
||||
|
||||
Try refreshing with the relevant shortcut or clicking the refresh button. File changes should update automatically via file watching.
|
||||
@@ -80,6 +80,7 @@ asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = [
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--snapshot-report=logs/snapshot_report.html",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
@@ -93,6 +94,7 @@ exclude = ["tests", "dist", "build"]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["clide", "tests"]
|
||||
exclude = ["clide/vendor"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
@@ -116,6 +118,8 @@ ignore = [
|
||||
"SIM105", # contextlib.suppress (try-except-pass is clearer in context)
|
||||
"SIM115", # context handler for files (sometimes not applicable)
|
||||
"PTH123", # Path.open() vs open() (not always cleaner)
|
||||
"TCH002", # move third-party imports to TYPE_CHECKING (clutters code)
|
||||
"TCH003", # move stdlib imports to TYPE_CHECKING (clutters code)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
+7
-7
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
Reference in New Issue
Block a user