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:
Jeroen Schweitzer
2026-02-01 22:02:00 +01:00
co-authored by Claude Opus 4.5
parent 2d2e5f5648
commit 8b1a84e7e2
20 changed files with 1574 additions and 834 deletions
+121 -51
View File
@@ -4,53 +4,135 @@ A terminal-based IDE that wraps Claude Code CLI, putting AI-assisted development
## Why Clide? ## 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. - **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. - **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. - **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. - **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 │ │ Sidebar │ Workspace │ Context │
│ │ [Editor][Diff][Terminal]│ │ │ │ [Editor][Diff][Terminal]│ │
│ [Files][Git] │ (appears when needed) │ [Problems][TODOs]│ │ [Files][Git] │ (appears when needed) │ [Jira][TODOs] │
│ [Graph] ├─────────────────────────┤ [Jira] │ │ [Tree] ├─────────────────────────┤ [Problems] │
│ │ │ │ │ │ │ │
│ │ Claude │ │ │ │ Claude │ │
│ │ (always visible) │ │ │ │ (always visible) │ │
│ │ │ │ │ │ │ │
├─────────────────┤ ├──────────────────┤ ├─────────────────┤ ├──────────────────┤
│ ⎇ main ▾ │ │ [⚠ 3][✓12][Jira]│ │ ⎇ main ▾ │ │ │
│ staged: 2 │ │ │
└─────────────────┴─────────────────────────┴──────────────────┘ └─────────────────┴─────────────────────────┴──────────────────┘
``` ```
## Features ## Features
**Left Sidebar** ### Left Sidebar
- File explorer with project tree - **Files** — Project file tree with syntax-aware icons
- Git panel showing staged/unstaged changes - **Git** — Staged/unstaged changes with action buttons
- Visual branch graph - **Tree** — Visual branch graph
- Quick branch switching - **Branch status** — Current branch with quick switcher
**Center Workspace** ### Center
- Claude Code integration (primary focus) - **Claude Code** — Full PTY terminal integration, always visible
- Tabbed editor with syntax highlighting - **Editor** — Syntax highlighting via tree-sitter
- Side-by-side diff viewer for proposed changes - **Diff** — Side-by-side diff viewer
- Integrated terminal - **Terminal** — Command execution
**Right Context Panel** ### Right Context
- Problems view (linter errors/warnings) - **Jira** — Issue display via CLI integration
- TODOs extracted from codebase - **TODOs** — Code comments and TODO.md items
- Jira/Confluence integration - **Problems** — Linter errors and warnings
**Responsive Design** ### Git Operations
- Works on 13" laptops to widescreen monitors
- Compact mode hides sidebars for focused work Click buttons in the Git panel to delegate operations to Claude:
- Panels preserve state when hidden
| 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 ## Tech Stack
@@ -61,37 +143,25 @@ Claude Code is powerful, but switching between your terminal, editor, and projec
| CLI | Typer | | CLI | Typer |
| Data Validation | Pydantic v2 | | Data Validation | Pydantic v2 |
| Extensions | Pluggy | | Extensions | Pluggy |
| Syntax Highlighting | tree-sitter |
## Getting Started ## Development
```bash ```bash
# Clone and setup make setup # Create venv, install deps
git clone <repo-url> make run # Run application
cd clide make test # Run all tests
make setup make typecheck # Run mypy
make lint # Run ruff
# Run make format # Format code
make run
``` ```
## 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 ## Documentation
- [Full UI/UX Specification](docs/tui-ide-spec.md) - [User Manual](docs/user-manual.md) — How to use Clide
- [Architecture Guide](docs/ARCHITECTURE.md) - [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 ## License
+14 -14
View File
@@ -44,23 +44,23 @@ Long-term open items for Clide development.
### Git Integration ### Git Integration
- [ ] Stage/unstage files from Git tab - [ ] Stage/unstage files from Git tab
- [ ] Discard changes context menu - [ ] Discard changes context menu
- [ ] Git graph visualization (Tree tab) - [x] Git graph visualization (Tree tab)
- [ ] Branch popout with checkout/new branch actions - [x] Branch popout with checkout/new branch actions
## Context Panel (Right Sidebar) ## Context Panel (Right Sidebar)
### Problems View ### Problems View
- [ ] Linter integration (ruff, eslint, etc.) - [ ] Linter integration (ruff, eslint, etc.)
- [ ] Click to navigate to file:line - [x] Click to navigate to file:line
- [ ] Reactive problem count badge - [x] Reactive problem count badge
### TODOs View ### TODOs View
- [ ] Scan for TODO/FIXME/HACK/XXX comments - [x] Scan for TODO/FIXME/HACK/XXX comments
- [ ] Click to navigate to file:line - [x] Click to navigate to file:line
- [ ] Reactive count badge - [x] Reactive count badge
### Jira View ### Jira View
- [ ] Render Jira CLI markdown output - [x] Render Jira CLI markdown output
- [ ] Auto-refresh on panel focus - [ ] Auto-refresh on panel focus
- [ ] Configurable refresh interval - [ ] Configurable refresh interval
@@ -69,7 +69,7 @@ Long-term open items for Clide development.
### Responsiveness ### Responsiveness
- [ ] CSS breakpoints for different terminal widths - [ ] CSS breakpoints for different terminal widths
- [ ] Auto-hide sidebars on narrow terminals (<100 cols) - [ ] Auto-hide sidebars on narrow terminals (<100 cols)
- [ ] Compact mode toggle (`Alt+C`) - [x] Compact mode toggle (`Alt+C`)
### Fullscreen Mode ### Fullscreen Mode
- [ ] Any panel can go fullscreen (`F11`) - [ ] Any panel can go fullscreen (`F11`)
@@ -94,15 +94,15 @@ Long-term open items for Clide development.
## Plugin System ## Plugin System
- [ ] User-defined panels via pluggy - [ ] User-defined panels via pluggy
- [ ] Custom integrations support - [x] Custom integrations support (hookspecs defined)
- [ ] Extension API documentation - [ ] Extension API documentation
## Testing ## Testing
- [ ] Snapshot tests for all panels - [x] Snapshot tests for all panels
- [ ] Integration tests for panel communication - [x] Integration tests for panel communication
- [ ] Unit tests for controllers - [x] Unit tests for controllers
- [ ] Unit tests for services - [x] Unit tests for services
## Documentation ## Documentation
+4 -5
View File
@@ -130,7 +130,9 @@ class EditorController:
self._state.buffers.remove(buffer) self._state.buffers.remove(buffer)
# Update active 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 = ( self._state.active_buffer_index = (
len(self._state.buffers) - 1 if self._state.buffers else None len(self._state.buffers) - 1 if self._state.buffers else None
) )
@@ -146,10 +148,7 @@ class EditorController:
Returns: Returns:
True if saved successfully True if saved successfully
""" """
if path: buffer = self._state.get_buffer_by_path(path) if path else self.active_buffer
buffer = self._state.get_buffer_by_path(path)
else:
buffer = self.active_buffer
if not buffer: if not buffer:
return False return False
+10 -4
View File
@@ -7,13 +7,12 @@ from terminal output, enabling tight IDE integration.
from __future__ import annotations from __future__ import annotations
import re import re
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Literal
from textual.message import Message from textual.message import Message
# Event Types # Event Types
# ----------- # -----------
@@ -21,54 +20,63 @@ from textual.message import Message
@dataclass @dataclass
class ClaudeEvent: class ClaudeEvent:
"""Base class for Claude Code events.""" """Base class for Claude Code events."""
pass pass
@dataclass @dataclass
class FileReadEvent(ClaudeEvent): class FileReadEvent(ClaudeEvent):
"""Emitted when Claude reads a file.""" """Emitted when Claude reads a file."""
path: Path path: Path
@dataclass @dataclass
class FileEditEvent(ClaudeEvent): class FileEditEvent(ClaudeEvent):
"""Emitted when Claude edits a file.""" """Emitted when Claude edits a file."""
path: Path path: Path
@dataclass @dataclass
class FileWriteEvent(ClaudeEvent): class FileWriteEvent(ClaudeEvent):
"""Emitted when Claude creates/writes a file.""" """Emitted when Claude creates/writes a file."""
path: Path path: Path
@dataclass @dataclass
class GlobEvent(ClaudeEvent): class GlobEvent(ClaudeEvent):
"""Emitted when Claude searches for files.""" """Emitted when Claude searches for files."""
pattern: str pattern: str
@dataclass @dataclass
class GrepEvent(ClaudeEvent): class GrepEvent(ClaudeEvent):
"""Emitted when Claude searches file contents.""" """Emitted when Claude searches file contents."""
pattern: str pattern: str
@dataclass @dataclass
class ToolStartEvent(ClaudeEvent): class ToolStartEvent(ClaudeEvent):
"""Emitted when Claude starts using a tool.""" """Emitted when Claude starts using a tool."""
tool_name: str tool_name: str
@dataclass @dataclass
class ToolEndEvent(ClaudeEvent): class ToolEndEvent(ClaudeEvent):
"""Emitted when Claude finishes using a tool.""" """Emitted when Claude finishes using a tool."""
tool_name: str tool_name: str
@dataclass @dataclass
class DiffProposedEvent(ClaudeEvent): class DiffProposedEvent(ClaudeEvent):
"""Emitted when Claude proposes a diff.""" """Emitted when Claude proposes a diff."""
content: str content: str
@@ -95,11 +103,9 @@ PATTERNS = {
"tool_write": re.compile(r"● Write\(([^)]+)\)"), "tool_write": re.compile(r"● Write\(([^)]+)\)"),
"tool_glob": re.compile(r"● Glob\(([^)]+)\)"), "tool_glob": re.compile(r"● Glob\(([^)]+)\)"),
"tool_grep": re.compile(r"● Grep\(([^)]+)\)"), "tool_grep": re.compile(r"● Grep\(([^)]+)\)"),
# Generic tool pattern # Generic tool pattern
"tool_start": re.compile(r"● (\w+)\("), "tool_start": re.compile(r"● (\w+)\("),
"tool_end": re.compile(r"└─"), "tool_end": re.compile(r"└─"),
# Diff headers # Diff headers
"diff_header": re.compile(r"^@@\s*-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*@@", re.MULTILINE), "diff_header": re.compile(r"^@@\s*-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*@@", re.MULTILINE),
"diff_file": re.compile(r"^(?:---|\+\+\+)\s+([^\s]+)", re.MULTILINE), "diff_file": re.compile(r"^(?:---|\+\+\+)\s+([^\s]+)", re.MULTILINE),
-1
View File
@@ -2,7 +2,6 @@
from pathlib import Path from pathlib import Path
# Language extension mapping for syntax highlighting # Language extension mapping for syntax highlighting
# Maps file extensions to tree-sitter language identifiers # Maps file extensions to tree-sitter language identifiers
LANGUAGE_MAP: dict[str, str] = { LANGUAGE_MAP: dict[str, str] = {
-4
View File
@@ -8,10 +8,6 @@ registering additional languages.
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+31 -31
View File
@@ -1,17 +1,17 @@
""" """
pyte.graphics pyte.graphics
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
This module defines graphic-related constants, mostly taken from This module defines graphic-related constants, mostly taken from
:manpage:`console_codes(4)` and :manpage:`console_codes(4)` and
http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html. http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html.
:copyright: (c) 2011-2012 by Selectel. :copyright: (c) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors, :copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details. see AUTHORS for details.
:license: LGPL, see LICENSE for more 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: #: A mapping of ANSI text style codes to style names, "+" means the:
@@ -51,7 +51,7 @@ FG_ANSI = {
35: "magenta", 35: "magenta",
36: "cyan", 36: "cyan",
37: "white", 37: "white",
39: "default" # white. 39: "default", # white.
} }
#: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility. #: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility.
@@ -67,7 +67,7 @@ FG_AIXTERM = {
94: "brightblue", 94: "brightblue",
95: "brightmagenta", 95: "brightmagenta",
96: "brightcyan", 96: "brightcyan",
97: "brightwhite" 97: "brightwhite",
} }
#: A mapping of ANSI background color codes to color names. #: A mapping of ANSI background color codes to color names.
@@ -85,7 +85,7 @@ BG_ANSI = {
45: "magenta", 45: "magenta",
46: "cyan", 46: "cyan",
47: "white", 47: "white",
49: "default" # black. 49: "default", # black.
} }
#: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility. #: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility.
@@ -101,7 +101,7 @@ BG_AIXTERM = {
104: "brightblue", 104: "brightblue",
105: "bfightmagenta", 105: "bfightmagenta",
106: "brightcyan", 106: "brightcyan",
107: "brightwhite" 107: "brightwhite",
} }
#: SGR code for foreground in 256 or True color mode. #: 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). # The following code is part of the Pygments project (BSD licensed).
_FG_BG_256 = [ _FG_BG_256 = [
(0x00, 0x00, 0x00), # 0 (0x00, 0x00, 0x00), # 0
(0xcd, 0x00, 0x00), # 1 (0xCD, 0x00, 0x00), # 1
(0x00, 0xcd, 0x00), # 2 (0x00, 0xCD, 0x00), # 2
(0xcd, 0xcd, 0x00), # 3 (0xCD, 0xCD, 0x00), # 3
(0x00, 0x00, 0xee), # 4 (0x00, 0x00, 0xEE), # 4
(0xcd, 0x00, 0xcd), # 5 (0xCD, 0x00, 0xCD), # 5
(0x00, 0xcd, 0xcd), # 6 (0x00, 0xCD, 0xCD), # 6
(0xe5, 0xe5, 0xe5), # 7 (0xE5, 0xE5, 0xE5), # 7
(0x7f, 0x7f, 0x7f), # 8 (0x7F, 0x7F, 0x7F), # 8
(0xff, 0x00, 0x00), # 9 (0xFF, 0x00, 0x00), # 9
(0x00, 0xff, 0x00), # 10 (0x00, 0xFF, 0x00), # 10
(0xff, 0xff, 0x00), # 11 (0xFF, 0xFF, 0x00), # 11
(0x5c, 0x5c, 0xff), # 12 (0x5C, 0x5C, 0xFF), # 12
(0xff, 0x00, 0xff), # 13 (0xFF, 0x00, 0xFF), # 13
(0x00, 0xff, 0xff), # 14 (0x00, 0xFF, 0xFF), # 14
(0xff, 0xff, 0xff), # 15 (0xFF, 0xFF, 0xFF), # 15
] ]
# colors 16..231: the 6x6x6 color cube # 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): for i in range(216):
r = valuerange[(i // 36) % 6] r = valuerange[(i // 36) % 6]
@@ -145,4 +145,4 @@ for i in range(24):
v = 8 + i * 10 v = 8 + i * 10
_FG_BG_256.append((v, v, v)) _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]
+94 -73
View File
@@ -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) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors, :copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details. see AUTHORS for details.
:license: LGPL, see LICENSE for more 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 from __future__ import annotations
import copy import copy
@@ -20,18 +21,22 @@ import os
import sys import sys
import unicodedata import unicodedata
import warnings 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 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 wcwidth import wcwidth as _wcwidth # type: ignore[import]
from . import ( from . import charsets as cs
charsets as cs, from . import control as ctrl
control as ctrl, from . import graphics as g
graphics as g, from . import modes as mo
modes as mo
)
from .streams import Stream from .streams import Stream
wcwidth: Callable[[str], int] = lru_cache(maxsize=4096)(_wcwidth) wcwidth: Callable[[str], int] = lru_cache(maxsize=4096)(_wcwidth)
@@ -40,10 +45,10 @@ KT = TypeVar("KT")
VT = TypeVar("VT") VT = TypeVar("VT")
# Clide diagnostic logging support # 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.""" """Set a debug logger function for diagnostic output."""
global _debug_logger global _debug_logger
_debug_logger = logger _debug_logger = logger
@@ -57,12 +62,14 @@ def _log_debug(message: str) -> None:
class Margins(NamedTuple): class Margins(NamedTuple):
"""A container for screen's scroll margins.""" """A container for screen's scroll margins."""
top: int top: int
bottom: int bottom: int
class Savepoint(NamedTuple): class Savepoint(NamedTuple):
"""A container for savepoint, created on :data:`~pyte.escape.DECSC`.""" """A container for savepoint, created on :data:`~pyte.escape.DECSC`."""
cursor: Cursor cursor: Cursor
g0_charset: str g0_charset: str
g1_charset: str g1_charset: str
@@ -73,6 +80,7 @@ class Savepoint(NamedTuple):
class Char(NamedTuple): class Char(NamedTuple):
"""A single styled on-screen character.""" """A single styled on-screen character."""
data: str data: str
fg: str = "default" fg: str = "default"
bg: str = "default" bg: str = "default"
@@ -86,6 +94,7 @@ class Char(NamedTuple):
class Cursor: class Cursor:
"""Screen cursor.""" """Screen cursor."""
__slots__ = ("x", "y", "attrs", "hidden") __slots__ = ("x", "y", "attrs", "hidden")
def __init__(self, x: int, y: int, attrs: Char = Char(" ")) -> None: def __init__(self, x: int, y: int, attrs: Char = Char(" ")) -> None:
@@ -95,8 +104,9 @@ class Cursor:
self.hidden = False self.hidden = False
class StaticDefaultDict(Dict[KT, VT]): class StaticDefaultDict(dict[KT, VT]):
"""A dict with a static default value.""" """A dict with a static default value."""
def __init__(self, default: VT) -> None: def __init__(self, default: VT) -> None:
self.default = default self.default = default
@@ -117,22 +127,24 @@ class Screen:
return Char(data=" ", fg="default", bg="default", reverse=reverse) return Char(data=" ", fg="default", bg="default", reverse=reverse)
def __init__(self, columns: int, lines: int) -> None: def __init__(self, columns: int, lines: int) -> None:
self.savepoints: List[Savepoint] = [] self.savepoints: list[Savepoint] = []
self.columns = columns self.columns = columns
self.lines = lines self.lines = lines
self.buffer: Dict[int, StaticDefaultDict[int, Char]] = defaultdict(lambda: StaticDefaultDict[int, Char](self.default_char)) self.buffer: dict[int, StaticDefaultDict[int, Char]] = defaultdict(
self.dirty: Set[int] = set() lambda: StaticDefaultDict[int, Char](self.default_char)
)
self.dirty: set[int] = set()
self.reset() self.reset()
self.mode = _DEFAULT_MODE.copy() self.mode = _DEFAULT_MODE.copy()
self.margins: Optional[Margins] = None self.margins: Margins | None = None
def __repr__(self) -> str: def __repr__(self) -> str:
return ("{0}({1}, {2})".format(self.__class__.__name__, return f"{self.__class__.__name__}({self.columns}, {self.lines})"
self.columns, self.lines))
@property @property
def display(self) -> List[str]: def display(self) -> list[str]:
"""A list of screen lines as unicode strings.""" """A list of screen lines as unicode strings."""
def render(line: StaticDefaultDict[int, Char]) -> Generator[str, None, None]: def render(line: StaticDefaultDict[int, Char]) -> Generator[str, None, None]:
is_wide_char = False is_wide_char = False
for x in range(self.columns): for x in range(self.columns):
@@ -167,9 +179,9 @@ class Screen:
self.cursor = Cursor(0, 0) self.cursor = Cursor(0, 0)
self.cursor_position() 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.""" """Resize the screen to the given size."""
lines = lines or self.lines lines = lines or self.lines
columns = columns or self.columns columns = columns or self.columns
@@ -195,7 +207,7 @@ class Screen:
self.lines, self.columns = lines, columns self.lines, self.columns = lines, columns
self.set_margins() 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.""" """Select top and bottom margins for the scrolling region."""
if (top is None or top == 0) and bottom is None: if (top is None or top == 0) and bottom is None:
self.margins = None self.margins = None
@@ -293,8 +305,7 @@ class Screen:
def draw(self, data: str) -> None: def draw(self, data: str) -> None:
"""Display decoded characters at the current cursor position.""" """Display decoded characters at the current cursor position."""
data = data.translate( data = data.translate(self.g1_charset if self.charset else self.g0_charset)
self.g1_charset if self.charset else self.g0_charset)
for char in data: for char in data:
char_width = wcwidth(char) char_width = wcwidth(char)
@@ -303,7 +314,9 @@ class Screen:
if _debug_logger is not None and char_width > 0: if _debug_logger is not None and char_width > 0:
code = ord(char) code = ord(char)
if code > 127 or code < 32: 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 self.cursor.x == self.columns:
if mo.DECAWM in self.mode: if mo.DECAWM in self.mode:
@@ -331,7 +344,9 @@ class Screen:
elif self.cursor.y: elif self.cursor.y:
last = self.buffer[self.cursor.y - 1][self.columns - 1] last = self.buffer[self.cursor.y - 1][self.columns - 1]
normalized = unicodedata.normalize("NFC", last.data + char) 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: else:
break break
@@ -396,12 +411,16 @@ class Screen:
def save_cursor(self) -> None: def save_cursor(self) -> None:
"""Push the current cursor position onto the stack.""" """Push the current cursor position onto the stack."""
self.savepoints.append(Savepoint(copy.copy(self.cursor), self.savepoints.append(
self.g0_charset, Savepoint(
self.g1_charset, copy.copy(self.cursor),
self.charset, self.g0_charset,
mo.DECOM in self.mode, self.g1_charset,
mo.DECAWM in self.mode)) self.charset,
mo.DECOM in self.mode,
mo.DECAWM in self.mode,
)
)
def restore_cursor(self) -> None: def restore_cursor(self) -> None:
"""Set the current cursor position to whatever cursor is on top of the stack.""" """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.reset_mode(mo.DECOM)
self.cursor_position() 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.""" """Insert the indicated # of lines at line with cursor."""
count = count or 1 count = count or 1
top, bottom = self.margins or Margins(0, self.lines - 1) top, bottom = self.margins or Margins(0, self.lines - 1)
@@ -433,7 +452,7 @@ class Screen:
self.buffer.pop(y, None) self.buffer.pop(y, None)
self.carriage_return() 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.""" """Delete the indicated # of lines."""
count = count or 1 count = count or 1
top, bottom = self.margins or Margins(0, self.lines - 1) top, bottom = self.margins or Margins(0, self.lines - 1)
@@ -447,7 +466,7 @@ class Screen:
self.buffer.pop(y, None) self.buffer.pop(y, None)
self.carriage_return() 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.""" """Insert the indicated # of blank characters at the cursor position."""
self.dirty.add(self.cursor.y) self.dirty.add(self.cursor.y)
count = count or 1 count = count or 1
@@ -457,7 +476,7 @@ class Screen:
line[x + count] = line[x] line[x + count] = line[x]
line.pop(x, None) 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.""" """Delete the indicated # of characters."""
self.dirty.add(self.cursor.y) self.dirty.add(self.cursor.y)
count = count or 1 count = count or 1
@@ -468,7 +487,7 @@ class Screen:
else: else:
line.pop(x, None) 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.""" """Erase the indicated # of characters."""
self.dirty.add(self.cursor.y) self.dirty.add(self.cursor.y)
count = count or 1 count = count or 1
@@ -525,7 +544,7 @@ class Screen:
"""Ensure the cursor is within horizontal screen bounds.""" """Ensure the cursor is within horizontal screen bounds."""
self.cursor.x = min(max(0, self.cursor.x), self.columns - 1) 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.""" """Ensure the cursor is within vertical screen bounds."""
if (use_margins or mo.DECOM in self.mode) and self.margins is not None: if (use_margins or mo.DECOM in self.mode) and self.margins is not None:
top, bottom = self.margins top, bottom = self.margins
@@ -533,39 +552,39 @@ class Screen:
top, bottom = 0, self.lines - 1 top, bottom = 0, self.lines - 1
self.cursor.y = min(max(top, self.cursor.y), bottom) 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.""" """Move cursor up the indicated # of lines."""
top, _bottom = self.margins or Margins(0, self.lines - 1) top, _bottom = self.margins or Margins(0, self.lines - 1)
self.cursor.y = max(self.cursor.y - (count or 1), top) 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.""" """Move cursor up the indicated # of lines to column 1."""
self.cursor_up(count) self.cursor_up(count)
self.carriage_return() 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.""" """Move cursor down the indicated # of lines."""
_top, bottom = self.margins or Margins(0, self.lines - 1) _top, bottom = self.margins or Margins(0, self.lines - 1)
self.cursor.y = min(self.cursor.y + (count or 1), bottom) 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.""" """Move cursor down the indicated # of lines to column 1."""
self.cursor_down(count) self.cursor_down(count)
self.carriage_return() 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.""" """Move cursor left the indicated # of columns."""
if self.cursor.x == self.columns: if self.cursor.x == self.columns:
self.cursor.x -= 1 self.cursor.x -= 1
self.cursor.x -= count or 1 self.cursor.x -= count or 1
self.ensure_hbounds() 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.""" """Move cursor right the indicated # of columns."""
self.cursor.x += count or 1 self.cursor.x += count or 1
self.ensure_hbounds() 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.""" """Set the cursor to a specific line and column."""
column = (column or 1) - 1 column = (column or 1) - 1
line = (line or 1) - 1 line = (line or 1) - 1
@@ -580,12 +599,12 @@ class Screen:
self.ensure_hbounds() self.ensure_hbounds()
self.ensure_vbounds() 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.""" """Move cursor to a specific column in the current line."""
self.cursor.x = (column or 1) - 1 self.cursor.x = (column or 1) - 1
self.ensure_hbounds() 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.""" """Move cursor to a specific line in the current column."""
self.cursor.y = (line or 1) - 1 self.cursor.y = (line or 1) - 1
if mo.DECOM in self.mode: if mo.DECOM in self.mode:
@@ -608,7 +627,7 @@ class Screen:
"""Set display attributes.""" """Set display attributes."""
replace = {} replace = {}
if not attrs or attrs == (0, ): if not attrs or attrs == (0,):
self.cursor.attrs = self.default_char self.cursor.attrs = self.default_char
return return
@@ -637,8 +656,9 @@ class Screen:
m = attrs_list.pop() m = attrs_list.pop()
replace[key] = g.FG_BG_256[m] replace[key] = g.FG_BG_256[m]
elif n == 2: elif n == 2:
replace[key] = "{0:02x}{1:02x}{2:02x}".format( replace[key] = (
attrs_list.pop(), attrs_list.pop(), attrs_list.pop()) f"{attrs_list.pop():02x}{attrs_list.pop():02x}{attrs_list.pop():02x}"
)
except IndexError: except IndexError:
pass pass
@@ -659,7 +679,7 @@ class Screen:
if mo.DECOM in self.mode: if mo.DECOM in self.mode:
assert self.margins is not None assert self.margins is not None
y -= self.margins.top 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: def write_process_input(self, data: str) -> None:
"""Write data to the process running inside the terminal.""" """Write data to the process running inside the terminal."""
@@ -673,10 +693,13 @@ class Screen:
class DiffScreen(Screen): class DiffScreen(Screen):
"""A screen subclass, which maintains a set of dirty lines. Deprecated.""" """A screen subclass, which maintains a set of dirty lines. Deprecated."""
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn( warnings.warn(
"The functionality of ``DiffScreen` has been merged into " "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) super(DiffScreen, self).__init__(*args, **kwargs)
@@ -694,12 +717,10 @@ class HistoryScreen(Screen):
_wrapped = set(Stream.events) _wrapped = set(Stream.events)
_wrapped.update(["next_page", "prev_page"]) _wrapped.update(["next_page", "prev_page"])
def __init__(self, columns: int, lines: int, history: int = 100, ratio: float = .5) -> None: def __init__(self, columns: int, lines: int, history: int = 100, ratio: float = 0.5) -> None:
self.history = History(deque(maxlen=history), self.history = History(
deque(maxlen=history), deque(maxlen=history), deque(maxlen=history), float(ratio), history, history
float(ratio), )
history,
history)
super(HistoryScreen, self).__init__(columns, lines) super(HistoryScreen, self).__init__(columns, lines)
def _make_wrapper(self, event: str, handler: Callable[..., Any]) -> Callable[..., Any]: def _make_wrapper(self, event: str, handler: Callable[..., Any]) -> Callable[..., Any]:
@@ -708,6 +729,7 @@ class HistoryScreen(Screen):
result = handler(*args, **kwargs) result = handler(*args, **kwargs)
self.after_event(event) self.after_event(event)
return result return result
return inner return inner
def __getattribute__(self, attr: str) -> Callable[..., Any]: def __getattribute__(self, attr: str) -> Callable[..., Any]:
@@ -732,8 +754,7 @@ class HistoryScreen(Screen):
line.pop(x) line.pop(x)
self.cursor.hidden = not ( self.cursor.hidden = not (
self.history.position == self.history.size and self.history.position == self.history.size and mo.DECTCEM in self.mode
mo.DECTCEM in self.mode
) )
def _reset_history(self) -> None: def _reset_history(self) -> None:
@@ -769,12 +790,11 @@ class HistoryScreen(Screen):
def prev_page(self) -> None: def prev_page(self) -> None:
"""Move the screen page up through the history buffer.""" """Move the screen page up through the history buffer."""
if self.history.position > self.lines and self.history.top: if self.history.position > self.lines and self.history.top:
mid = min(len(self.history.top), mid = min(len(self.history.top), int(math.ceil(self.lines * self.history.ratio)))
int(math.ceil(self.lines * self.history.ratio)))
self.history.bottom.extendleft( self.history.bottom.extendleft(
self.buffer[y] self.buffer[y] for y in range(self.lines - 1, self.lines - mid - 1, -1)
for y in range(self.lines - 1, self.lines - mid - 1, -1)) )
self.history = self.history._replace(position=self.history.position - mid) self.history = self.history._replace(position=self.history.position - mid)
for y in range(self.lines - 1, mid - 1, -1): for y in range(self.lines - 1, mid - 1, -1):
@@ -787,8 +807,7 @@ class HistoryScreen(Screen):
def next_page(self) -> None: def next_page(self) -> None:
"""Move the screen page down through the history buffer.""" """Move the screen page down through the history buffer."""
if self.history.position < self.history.size and self.history.bottom: if self.history.position < self.history.size and self.history.bottom:
mid = min(len(self.history.bottom), mid = min(len(self.history.bottom), int(math.ceil(self.lines * self.history.ratio)))
int(math.ceil(self.lines * self.history.ratio)))
self.history.top.extend(self.buffer[y] for y in range(mid)) self.history.top.extend(self.buffer[y] for y in range(mid))
self.history = self.history._replace(position=self.history.position + mid) self.history = self.history._replace(position=self.history.position + mid)
@@ -803,6 +822,7 @@ class HistoryScreen(Screen):
class DebugEvent(NamedTuple): class DebugEvent(NamedTuple):
"""Event dispatched to DebugScreen.""" """Event dispatched to DebugScreen."""
name: str name: str
args: Any args: Any
kwargs: Any kwargs: Any
@@ -830,6 +850,7 @@ class DebugScreen:
def wrapper(*args: Any, **kwargs: Any) -> None: def wrapper(*args: Any, **kwargs: Any) -> None:
self.to.write(str(DebugEvent(attr, args, kwargs))) self.to.write(str(DebugEvent(attr, args, kwargs)))
self.to.write(str(os.linesep)) self.to.write(str(os.linesep))
return wrapper return wrapper
def __getattribute__(self, attr: str) -> Callable[..., None]: def __getattribute__(self, attr: str) -> Callable[..., None]:
+64 -51
View File
@@ -1,25 +1,26 @@
""" """
pyte.streams pyte.streams
~~~~~~~~~~~~ ~~~~~~~~~~~~
This module provides three stream implementations with different This module provides three stream implementations with different
features; for starters, here's a quick example of how streams are features; for starters, here's a quick example of how streams are
typically used: typically used:
>>> import pyte >>> import pyte
>>> screen = pyte.Screen(80, 24) >>> screen = pyte.Screen(80, 24)
>>> stream = pyte.Stream(screen) >>> stream = pyte.Stream(screen)
>>> stream.feed("\x1b[5B") # Move the cursor down 5 rows. >>> stream.feed("\x1b[5B") # Move the cursor down 5 rows.
>>> screen.cursor.y >>> screen.cursor.y
5 5
:copyright: (c) 2011-2012 by Selectel. :copyright: (c) 2011-2012 by Selectel.
:copyright: (c) 2012-2017 by pyte authors and contributors, :copyright: (c) 2012-2017 by pyte authors and contributors,
see AUTHORS for details. see AUTHORS for details.
:license: LGPL, see LICENSE for more 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 from __future__ import annotations
import codecs import codecs
@@ -27,21 +28,22 @@ import itertools
import re import re
import warnings import warnings
from collections import defaultdict from collections import defaultdict
from collections.abc import Mapping from collections.abc import Callable, Generator, Mapping
from typing import Any, Callable, Dict, Generator, Optional, TYPE_CHECKING 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: if TYPE_CHECKING:
from .screens import Screen from .screens import Screen
# Clide diagnostic logging support # Clide diagnostic logging support
_debug_logger: Optional[Callable[[str], None]] = None _debug_logger: Callable[[str], None] | None = None
_event_callback: Optional[Callable[[str], 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. """Set a debug logger function for diagnostic output.
Args: Args:
@@ -51,7 +53,7 @@ def set_debug_logger(logger: Optional[Callable[[str], None]]) -> None:
_debug_logger = logger _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. """Set an event callback for raw terminal data.
This callback is invoked with the raw data before parsing. This callback is invoked with the raw data before parsing.
@@ -70,7 +72,7 @@ def _log_debug(message: str) -> None:
_debug_logger(message) _debug_logger(message)
ParserGenerator = Generator[Optional[bool], str, None] ParserGenerator = Generator[bool | None, str, None]
class Stream: class Stream:
@@ -158,30 +160,35 @@ class Stream:
esc.SGR: "select_graphic_rendition", esc.SGR: "select_graphic_rendition",
esc.DSR: "report_device_status", esc.DSR: "report_device_status",
esc.DECSTBM: "set_margins", esc.DECSTBM: "set_margins",
esc.HPA: "cursor_to_column" esc.HPA: "cursor_to_column",
} }
#: A set of all events dispatched by the stream. #: A set of all events dispatched by the stream.
events = frozenset(itertools.chain( events = frozenset(
basic.values(), escape.values(), sharp.values(), csi.values(), itertools.chain(
["define_charset"], basic.values(),
["set_icon_name", "set_title"], # OSC. escape.values(),
["draw", "debug"])) sharp.values(),
csi.values(),
["define_charset"],
["set_icon_name", "set_title"], # OSC.
["draw", "debug"],
)
)
#: A regular expression pattern matching everything what can be #: A regular expression pattern matching everything what can be
#: considered plain text. #: considered plain text.
_special = set([ctrl.ESC, ctrl.CSI_C1, ctrl.NUL, ctrl.DEL, ctrl.OSC_C1]) _special = set([ctrl.ESC, ctrl.CSI_C1, ctrl.NUL, ctrl.DEL, ctrl.OSC_C1])
_special.update(basic) _special.update(basic)
_text_pattern = re.compile( _text_pattern = re.compile("[^" + "".join(map(re.escape, _special)) + "]+")
"[^" + "".join(map(re.escape, _special)) + "]+")
del _special del _special
def __init__(self, screen: Optional[Screen] = None, strict: bool = True) -> None: def __init__(self, screen: Screen | None = None, strict: bool = True) -> None:
self.listener: Optional[Screen] = None self.listener: Screen | None = None
self.strict = strict self.strict = strict
self.use_utf8: bool = True self.use_utf8: bool = True
self._taking_plain_text: Optional[bool] = None self._taking_plain_text: bool | None = None
if screen is not None: if screen is not None:
self.attach(screen) self.attach(screen)
@@ -192,18 +199,20 @@ class Stream:
:param pyte.screens.Screen screen: a screen to attach to. :param pyte.screens.Screen screen: a screen to attach to.
""" """
if self.listener is not None: if self.listener is not None:
warnings.warn("As of version 0.6.0 the listener queue is " warnings.warn(
"restricted to a single element. Existing " "As of version 0.6.0 the listener queue is "
"listener {0} will be replaced." "restricted to a single element. Existing "
.format(self.listener), DeprecationWarning) f"listener {self.listener} will be replaced.",
DeprecationWarning,
)
if self.strict: if self.strict:
for event in self.events: for event in self.events:
if not hasattr(screen, event): 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.listener = screen
self._parser: Optional[ParserGenerator] = None self._parser: ParserGenerator | None = None
self._initialize_parser() self._initialize_parser()
def detach(self, screen: Screen) -> None: def detach(self, screen: Screen) -> None:
@@ -230,7 +239,7 @@ class Stream:
# Clide: Debug log incoming data # Clide: Debug log incoming data
if _debug_logger is not None: if _debug_logger is not None:
# Log escape sequences in a readable format # 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: if len(escaped) > 200:
escaped = escaped[:200] + "..." escaped = escaped[:200] + "..."
_log_debug(f"[STREAM] feed: {escaped}") _log_debug(f"[STREAM] feed: {escaped}")
@@ -254,12 +263,12 @@ class Stream:
else: else:
taking_plain_text = False taking_plain_text = False
else: else:
taking_plain_text = send(data[offset:offset + 1]) taking_plain_text = send(data[offset : offset + 1])
offset += 1 offset += 1
self._taking_plain_text = taking_plain_text 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: try:
assert self._parser is not None assert self._parser is not None
return self._parser.send(data) return self._parser.send(data)
@@ -294,25 +303,28 @@ class Stream:
SP_OR_GT = ctrl.SP + ">" SP_OR_GT = ctrl.SP + ">"
NUL_OR_DEL = ctrl.NUL + ctrl.DEL NUL_OR_DEL = ctrl.NUL + ctrl.DEL
CAN_OR_SUB = ctrl.CAN + ctrl.SUB CAN_OR_SUB = ctrl.CAN + ctrl.SUB
ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, ctrl.VT, ctrl.FF, ctrl.CR])
ctrl.VT, ctrl.FF, ctrl.CR])
OSC_TERMINATORS = set([ctrl.ST_C0, ctrl.ST_C1, ctrl.BEL]) OSC_TERMINATORS = set([ctrl.ST_C0, ctrl.ST_C1, ctrl.BEL])
def create_dispatcher(mapping: Mapping[str, str]) -> Dict[str, Callable[..., None]]: def create_dispatcher(mapping: Mapping[str, str]) -> dict[str, Callable[..., None]]:
d = defaultdict(lambda: debug, dict( d = defaultdict(
(event, getattr(listener, attr)) lambda: debug,
for event, attr in mapping.items())) dict((event, getattr(listener, attr)) for event, attr in mapping.items()),
)
# Clide: Wrap dispatchers with debug logging # Clide: Wrap dispatchers with debug logging
if _debug_logger is not None: if _debug_logger is not None:
original_d = dict(d) original_d = dict(d)
for event, attr in mapping.items(): for event, attr in mapping.items():
original_handler = original_d.get(event, debug) original_handler = original_d.get(event, debug)
def make_wrapper(e: str, a: str, h: Callable[..., None]) -> Callable[..., None]: def make_wrapper(e: str, a: str, h: Callable[..., None]) -> Callable[..., None]:
def wrapper(*args: Any, **kwargs: Any) -> None: def wrapper(*args: Any, **kwargs: Any) -> None:
_log_debug(f"[DISPATCH] {a}({args}, {kwargs})") _log_debug(f"[DISPATCH] {a}({args}, {kwargs})")
return h(*args, **kwargs) return h(*args, **kwargs)
return wrapper return wrapper
d[event] = make_wrapper(event, attr, original_handler) d[event] = make_wrapper(event, attr, original_handler)
return d return d
@@ -360,7 +372,7 @@ class Stream:
listener.define_charset(code, mode=char) listener.define_charset(code, mode=char)
else: else:
escape_dispatch[char]() escape_dispatch[char]()
continue # Don't go to CSI. continue # Don't go to CSI.
if char in basic: if char in basic:
# Ignore shifts in UTF-8 mode. See # 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 Assume the input to :meth:`~pyte.streams.ByteStream.feed` is encoded
using UTF-8. Defaults to ``True``. using UTF-8. Defaults to ``True``.
""" """
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *args: Any, **kwargs: Any):
super(ByteStream, self).__init__(*args, **kwargs) super(ByteStream, self).__init__(*args, **kwargs)
+1 -2
View File
@@ -2,14 +2,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal from textual.containers import Horizontal
from textual.message import Message from textual.message import Message
from textual.reactive import reactive from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Button, Static from textual.widgets import Button, Static
+1 -1
View File
@@ -137,7 +137,7 @@ class DiffPane(Vertical):
header = self.query_one(".diff-header", Static) header = self.query_one(".diff-header", Static)
header.update("No diff loaded") 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.""" """Handle button presses."""
if not self._diff: if not self._diff:
return return
+41 -28
View File
@@ -6,10 +6,10 @@ from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.message import Message from textual.message import Message
from textual.reactive import reactive 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.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.diff_pane import DiffPane
from clide.widgets.components.editor_pane import EditorPane from clide.widgets.components.editor_pane import EditorPane
from clide.widgets.components.terminal_pane import TerminalPane from clide.widgets.components.terminal_pane import TerminalPane
@@ -104,6 +104,7 @@ class WorkspacePanel(Vertical):
class CloseRequested(Message): class CloseRequested(Message):
"""Emitted when workspace should be hidden.""" """Emitted when workspace should be hidden."""
pass pass
# Reactive state - persisted when hidden # Reactive state - persisted when hidden
@@ -114,10 +115,12 @@ class WorkspacePanel(Vertical):
# Messages for app-level actions # Messages for app-level actions
class MaximizeRequested(Message): class MaximizeRequested(Message):
"""Emitted when workspace should be maximized.""" """Emitted when workspace should be maximized."""
pass pass
class RestoreRequested(Message): class RestoreRequested(Message):
"""Emitted when workspace should be restored from maximized.""" """Emitted when workspace should be restored from maximized."""
pass pass
def __init__( def __init__(
@@ -163,43 +166,53 @@ class WorkspacePanel(Vertical):
# Register standard buttons using simple ASCII icons # Register standard buttons using simple ASCII icons
# Save button (for editor) # Save button (for editor)
self._action_bar.register_button(ActionButton( self._action_bar.register_button(
id="save", ActionButton(
icon="[S]", id="save",
tooltip="Save", icon="[S]",
)) tooltip="Save",
)
)
# Add separator # Add separator
self._action_bar.add_separator() self._action_bar.add_separator()
# Close button # Close button
self._action_bar.register_button(ActionButton( self._action_bar.register_button(
id="close", ActionButton(
icon="x", id="close",
tooltip="Close", icon="x",
)) tooltip="Close",
)
)
# Minimize button # Minimize button
self._action_bar.register_button(ActionButton( self._action_bar.register_button(
id="minimize", ActionButton(
icon="_", id="minimize",
tooltip="Minimize", icon="_",
)) tooltip="Minimize",
)
)
# Maximize button # Maximize button
self._action_bar.register_button(ActionButton( self._action_bar.register_button(
id="maximize", ActionButton(
icon="^", id="maximize",
tooltip="Maximize", icon="^",
)) tooltip="Maximize",
)
)
# Restore button (hidden by default) # Restore button (hidden by default)
self._action_bar.register_button(ActionButton( self._action_bar.register_button(
id="restore", ActionButton(
icon="v", id="restore",
tooltip="Restore", icon="v",
visible=False, tooltip="Restore",
)) visible=False,
)
)
# Update button visibility based on current tab # Update button visibility based on current tab
self._update_action_bar_for_tab(self.active_tab) self._update_action_bar_for_tab(self.active_tab)
+273 -256
View File
@@ -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 ## Table of Contents
- [Application Architecture](#application-architecture)
- [Textual TUI Framework](#textual-tui-framework) - [Textual TUI Framework](#textual-tui-framework)
- [Typer CLI Framework](#typer-cli-framework)
- [Pydantic Data Validation](#pydantic-data-validation) - [Pydantic Data Validation](#pydantic-data-validation)
- [Extension System](#extension-system) - [Extension System](#extension-system)
- [Testing Strategy](#testing-strategy) - [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 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** — Building blocks of the UI. Everything visible is a widget.
- Widgets are the building blocks of the UI
- Containers are widgets that hold other widgets
- Default layout stacks widgets vertically from top of screen
**Reactive Programming** **Containers** — Widgets that hold other widgets (Vertical, Horizontal, Container).
- State changes trigger automatic UI updates
- No manual refresh loops needed
- Use reactive attributes for state management
**Event-Driven Model** **Reactive Programming** — State changes trigger automatic UI updates.
- Define callbacks for key presses, mouse clicks, timer ticks
- Actions are functions callable via keystroke or text link
### Best Practices **CSS Styling** — Layout and appearance defined in CSS, similar to web development.
1. **Use Immutable Objects** ### Layout System
- Prefer tuples, NamedTuples, or frozen dataclasses
- Easier to reason about, cache, and test
- Enables side-effect-free code
2. **Separate Styles** Clide uses CSS Grid for the main layout:
- Keep CSS in `.tcss` files, not inline
- Python code stays clean and focused on logic
3. **Async-First** ```css
- Textual is async under the hood
- Use `async`/`await` for I/O operations
- Can integrate with async libraries if needed
### Layout Management
```python
# Grid layout example
CSS = """
Screen { Screen {
layout: grid; layout: grid;
grid-size: 3 1; 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 ### References
- [Textual Documentation](https://textual.textualize.io/) - [Textual Documentation](https://textual.textualize.io/)
- [Textual Tutorial](https://textual.textualize.io/tutorial/) - [Textual Widgets](https://textual.textualize.io/widgets/)
- [Real Python Textual Guide](https://realpython.com/python-textual/) - [Textual CSS](https://textual.textualize.io/guide/CSS/)
- [Textual GitHub](https://github.com/Textualize/textual)
---
## Typer CLI Framework
Typer is built on Click with Python type hints for automatic argument parsing.
### Project Structure Pattern
```
app/
├── __init__.py
├── main.py # Root Typer app
├── commands/ # Subcommand modules
│ ├── users.py
│ └── tasks.py
└── helpers/ # Shared utilities
└── validate.py
```
### Best Practices
1. **Organize Commands**
- Use `add_typer()` to group commands
- Avoid giant files with dozens of commands
- Each command function should orchestrate, not contain all logic
2. **Entry Point Support**
- Add `__main__.py` for `python -m` support
- Define entry points in pyproject.toml for CLI scripts
3. **Standard Exit Codes**
- `0` for success
- Non-zero for errors
- Crucial for CI/CD integration
4. **Type Hints for Validation**
- Use Enum for dropdown-style restrictions
- Type hints provide editor autocompletion
### Subcommand Example
```python
# commands/users.py
import typer
app = typer.Typer()
@app.command()
def create(name: str):
"""Create a new user."""
...
# main.py
from commands import users
main_app = typer.Typer()
main_app.add_typer(users.app, name="users")
```
### References
- [Typer Documentation](https://typer.tiangolo.com/)
- [Typer Subcommands](https://typer.tiangolo.com/tutorial/subcommands/)
- [Building a Package](https://typer.tiangolo.com/tutorial/package/)
--- ---
## Pydantic Data Validation ## Pydantic 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 ```python
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class MyModel(BaseModel): class GitChange(BaseModel):
model_config = ConfigDict(strict=True, frozen=True) 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 Management
Settings have moved to `pydantic-settings` package: Application settings use `pydantic-settings`:
```python ```python
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings): class ClideSettings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="APP_", env_prefix="CLIDE_",
env_file=".env", env_file=".env",
env_nested_delimiter="__",
) )
database_url: str theme: str = "summer-night"
debug: bool = False jira_enabled: bool = False
``` ```
### Best Practices Settings load from (in priority order):
1. Environment variables (`CLIDE_THEME=dracula`)
1. **Use `frozen=True` for Immutability** 2. `.env` file
- Prevents accidental mutation 3. Default values
- Enables hashing for use as dict keys
2. **Explicit Strict Types**
- `StrictInt`, `StrictStr` for field-level strictness
- Or use `model_config` for model-wide strictness
3. **Validation vs Parsing**
- Strict mode rejects type coercion
- JSON parsing allows some conversion (ISO8601 → datetime)
### References ### 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 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 ## 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 Hooks define extension points:
2. **Hook Implementations** - Extension code implementing hooks
3. **Plugin Manager** - Discovers and calls implementations
### Architecture
```python ```python
# hookspecs.py - Define hooks # clide/extensions/hookspecs.py
import pluggy import pluggy
hookspec = pluggy.HookspecMarker("clide") hookspec = pluggy.HookspecMarker("clide")
@@ -213,18 +252,36 @@ hookimpl = pluggy.HookimplMarker("clide")
class ClideHookSpec: class ClideHookSpec:
@hookspec @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: class MyExtension:
@hookimpl @hookimpl
def register_panel(self) -> dict: def clide_startup(self, app: App) -> None:
return {"name": "custom", "widget": CustomWidget} 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 ### Distribution
Extensions can be distributed as packages using entry points: Extensions can be packaged and distributed via entry points:
```toml ```toml
# pyproject.toml of extension package # pyproject.toml of extension package
@@ -232,55 +289,73 @@ Extensions can be distributed as packages using entry points:
my_extension = "my_package:MyExtension" my_extension = "my_package:MyExtension"
``` ```
### Hook Execution Order ### Available Hooks
- Multiple implementations called in LIFO (Last In, First Out) order | Hook | When Called |
- Use `hookimpl(tryfirst=True)` or `hookimpl(trylast=True)` for ordering |------|-------------|
| `clide_startup` | App initialization |
### Alternatives | `clide_shutdown` | App cleanup |
| `clide_on_file_changed` | File created/modified/deleted |
- **Stevedore** - Better for driver/extension patterns, uses entry points | `clide_on_file_saved` | File saved in editor |
- Choose Pluggy for hook-based systems (like pytest uses)
### References ### References
- [Pluggy Documentation](https://pluggy.readthedocs.io/) - [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 ## 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 ```toml
# pyproject.toml
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
``` ```
### Async Test Patterns Tests can be async without decorators:
```python ```python
import pytest
# Auto mode - no decorator needed
async def test_async_operation(): async def test_async_operation():
result = await some_async_function() result = await some_async_function()
assert result == expected 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 ```python
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -291,73 +366,43 @@ async def test_with_mock():
assert result["status"] == "ok" 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 ### 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) - [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/) - [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
- [pytest Fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html)
--- ---
## Build and Distribution ## Build and Distribution
### PyInstaller Limitations ### Development
**Critical: PyInstaller cannot cross-compile.** ```bash
- Build on the target OS make setup # Create venv, install deps
- Use CI/CD for multi-platform builds 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 ```yaml
# .gitea/workflows/build.yml
name: Build
on: [push, tag]
jobs: jobs:
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -371,45 +416,17 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
steps: # ... same steps
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[build]"
- run: pyinstaller clide.spec --clean
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[build]"
- run: pyinstaller clide.spec --clean
``` ```
### Optimization Tips ### Optimization
1. **Use `--onefile`** for single executable - Use `--onefile` for single executable
2. **Apply `--strip`** to reduce binary size - Apply `--strip` to reduce size
3. **Use UPX compression** (460 MB → ~130 MB possible) - Use UPX compression for further reduction
4. **Exclude unused modules** with `--exclude-module` - Exclude unused modules with `--exclude-module`
5. **Lazy imports** for large libraries
### Platform-Specific Output
- **Windows**: `.exe` or MSIX installer
- **macOS**: `.app` bundle in `.dmg`
- **Linux**: AppImage or native package
### Linux Compatibility
Build on the oldest target distro version. Newer systems may produce incompatible binaries.
### References ### References
- [PyInstaller Documentation](https://pyinstaller.org/) - [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) - [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
+342
View File
@@ -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
+153 -290
View File
@@ -1,14 +1,14 @@
# TUI IDE Specification # 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 ## Design Principles
- **Claude-centric**: Claude Code is the primary workspace, always visible - **Claude-centric** — Claude Code is the primary workspace, always visible
- **Contextual panels**: Editor/Diff/Terminal appear only when needed - **Contextual panels** — Editor/Diff/Terminal appear only when needed
- **VSCode-familiar**: Keybindings and interaction patterns follow VSCode conventions - **Alt-key shortcuts** — Keybindings use Alt to avoid conflicts with Claude Code input
- **Responsive**: Works on 13" laptop and widescreen monitors - **Responsive** — Works on 13" laptops to widescreen monitors
- **No vim magic**: Standard keyboard navigation, no modal editing - **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 │ │ panel-sidebar │ panel-workspace (60%) │ panel-context │
│ │ [Editor][Diff][Terminal]│ │ │ 20% │ [Editor][Diff][Terminal]│ 25% │
│ [Files][Git] │ (hidden when inactive) │ (content area) │ │ │ (hidden when inactive) │ │
│ [Tree] ├─────────────────────────┤ │ │ [Files][Git] ├─────────────────────────┤ [Jira][TODOs] │
│ │ │ │ │ [Tree] │ │ [Problems] │
│ (content area) │ panel-claude │ │ │ │ panel-claude │ │
│ │ (40% when workspace │ │ │ (content area) │ (40% when workspace │ (content area) │
│ │ visible, else 100%) │ │ │ │ visible, else 100%) │ │
├─────────────────┤ ├──────────────────┤ ├─────────────────┤ ├──────────────────┤
│ branch-status │ │[⚠ 3][✓12][Jira] │ │ branch-status │ │ │
│ ⎇ main ▾ │ │ context-tabs │ │ ⎇ main ▾ │ │ │
│ staged: 2 │ │ │
└─────────────────┴─────────────────────────┴──────────────────┘ └─────────────────┴─────────────────────────┴──────────────────┘
``` ```
### Panel Definitions ### Panel IDs
```python ```python
PANELS = { PANELS = {
# Left sidebar # Left sidebar
"sidebar": "panel-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 # Center
"claude": "panel-claude", "claude": "panel-claude",
"workspace": "panel-workspace", "workspace": "panel-workspace",
"editor": "panel-editor",
"diff": "panel-diff",
"terminal": "panel-terminal",
# Right context # Right context
"context": "panel-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 ### Tabs
| Tab | Content | Widget | | Tab | Content | Purpose |
|-----|---------|--------| |-----|---------|---------|
| Files | Project file tree | `DirectoryTree` | | Files | Project file tree | Navigate and open files |
| Git | Staged/Unstaged changes | `GitChangesView` (custom) | | Git | Staged/Unstaged changes | Review and manage changes |
| Tree | Merge/branch graph | `GitGraphView` (custom) | | Tree | Branch graph | Visualize git history |
### Git Tab Details ### Git Tab
Two collapsible sections: Two collapsible sections showing staged and unstaged changes.
- **Staged**: Files in index, ready to commit
- **Unstaged**: Modified/untracked files
Each file item shows: **File status indicators:**
- Status icon: `+` added, `~` modified, `-` deleted, `?` untracked, `→` renamed - `+` Added
- File path (relative) - `~` Modified
- `-` Deleted
- `?` Untracked
- `→` Renamed
**Interactions:** **Action buttons:**
- Click file → opens in Editor panel - **Commit** — Delegate to Claude with `/commit` skill
- Double-click or keybind → stage/unstage file - **Stash** — Delegate to Claude with `/stash` skill
- Right-click or keybind → show context menu (discard, diff, etc.) - **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 ● main: Latest commit message
│ ●── feature: Feature work │
●──┴── Merge branch 'feature' ├─● feature: Feature work
◆───── Tagged release v1.0 │
●─┴ Merge branch 'feature'
◆ Tagged release v1.0
``` ```
Use canvas or rich text with: **Symbols:**
```python - `●` Regular commit
GRAPH_CHARS = { - `◆` Merge commit
'commit': '●', - `│` Branch line
'merge': '◆', - `├` Branch point
'line': '│', - `┴` Merge point
'branch': '├──',
'join': '┴──',
}
```
### Branch Status Bar ### 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 │ │ Recent branches │
│ ○ main │ │ ● main │
│ ○ feature/xyz │ │ ○ feature/xyz │
│ ○ develop │ │ ○ develop │
├─────────────────┤ ├─────────────────┤
│ [Checkout] [New]│ │[Checkout] [New] │
└─────────────────┘ └─────────────────┘
``` ```
@@ -132,232 +125,162 @@ Fixed at bottom of sidebar. Shows current branch with popout toggle.
## Center Column ## 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 **Default state:** 100% height of center column
**With workspace**: 40% height (bottom) **With workspace:** 40% height (bottom)
**Content:** **Content:**
- Streaming markdown responses (use `Markdown` or `RichLog` widget) - Full PTY terminal running Claude Code CLI
- Visual distinction between: - Scrollback history (1000 lines)
- Claude's responses - Input at bottom
- Tool calls / file operations
- User input
- Input area 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: **Visibility principle:** Hiding is not closing. All panels retain state:
- Editor: Open files, cursor position, scroll position, unsaved changes - Editor: Open file, cursor position, scroll, unsaved changes
- Diff: Current diff content, scroll position - Diff: Current diff content, scroll position
- Terminal: Active session, command history, output buffer - Terminal: Command history, output buffer
Use `display: none` for visibility, never destroy/recreate widgets.
**Visibility triggers:** **Visibility triggers:**
| Trigger | Result | | Trigger | Result |
|---------|--------| |---------|--------|
| Click file in sidebar | Show workspace, focus Editor tab | | Click file in sidebar | Show workspace, focus Editor |
| Claude proposes changes | Show workspace, focus Diff tab | | Click problem/TODO | Show workspace, focus Editor at line |
| User presses `` Ctrl+` `` | Show workspace, focus Terminal tab | | Press `` Alt+` `` | Show workspace, focus Terminal |
| User runs command | Show workspace, focus Terminal tab | | Close all content | Hide workspace, Claude reclaims space |
| Close all tabs / Escape | Hide workspace, Claude reclaims space |
**Height**: 60% of center column when visible
#### Editor Tab #### Editor Tab
- `TextArea` widget with syntax highlighting Code editor with:
- Language detection from file extension - Syntax highlighting (tree-sitter based)
- Theme: Follow terminal theme or user preference - Line numbers
- Current line highlighting
#### Diff Tab #### Diff Tab
- Side-by-side or unified diff view Side-by-side diff viewer for:
- Syntax highlighting for changed content - Git changes (staged and unstaged)
- Accept/Reject buttons for Claude-proposed changes - Claude-proposed edits
#### Terminal Tab #### Terminal Tab
- Proper PTY integration for full terminal emulation Command execution terminal:
- Or simpler command runner with output display (decide based on complexity)
- Working directory tied to project root - 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: | Tab | Badge | Content |
- Jira view (default) |-----|-------|---------|
- Problems view | Jira | — | Jira issue display |
- TODOs view | 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.
``` ### Jira Tab
┌──────────────────┐
│ [⚠ 3][✓12][Jira]│
└──────────────────┘
```
Tabs show inline counts that update reactively. Displays Jira issues via CLI integration. Manual refresh button.
| Tab | Icon | Content | ### TODOs Tab
|-----|------|---------|
| Problems | ⚠ | Linter errors, warnings (count badge) |
| TODOs | ✓ | TODO/FIXME comments from codebase (count badge) |
| Jira | Jira | Output from your CLI tool (default) |
### Jira View Two sub-tabs:
Renders markdown output from your CLI tool. Refreshes on: **Project tab:** Items from `TODO.md` (checkbox format)
- Panel focus **Comments tab:** TODO/FIXME/HACK/XXX comments in code
- Manual refresh keybind
- Configurable interval
### Problems View Click any item to jump to source location.
Aggregates from linters (eslint, ruff, etc.). Shows: ### Problems Tab
Linter output showing:
- File path - File path
- Line number - Line number
- Severity icon - Severity (error/warning)
- Message - Message
Click → opens file in Editor at that line. Click to navigate to source.
### TODOs View
Grep results for `TODO`, `FIXME`, `HACK`, `XXX`. Shows:
- File path
- Line number
- Comment text
Click → opens file in Editor at that line.
--- ---
## Responsiveness ## Responsiveness
### CSS Breakpoints ### CSS Strategy
```css ```css
/* Widescreen (default) */ /* Default layout */
#panel-sidebar { width: 20%; min-width: 25; } #panel-sidebar { width: 20%; min-width: 25; }
#panel-context { width: 25%; min-width: 30; } #panel-context { width: 25%; min-width: 30; }
#panel-claude { width: 1fr; } #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 ### Compact Mode
Toggle with `Ctrl+Shift+C`. Hides both sidebars, maximizes Claude + workspace. Toggle with `Alt+C`. Hides both sidebars:
```css ```css
.compact #panel-sidebar { display: none; } .compact #panel-sidebar { display: none; }
.compact #panel-context { display: none; } .compact #panel-context { display: none; }
``` ```
### Fullscreen Mode All panel state preserved when hidden.
Any panel can go fullscreen with `F11` (when focused). Press `Escape` to exit.
```css
.fullscreen {
dock: top;
width: 100%;
height: 100%;
layer: fullscreen;
}
```
--- ---
## Keybindings ## Keybindings
Following VSCode conventions where possible. All shortcuts use `Alt` modifier to avoid conflicts with Claude Code input.
### Global ### Panel Navigation
| Action | Binding | | Action | Binding |
|--------|---------| |--------|---------|
| Command palette | `Ctrl+Shift+P` | | Toggle left sidebar | `Alt+B` |
| Quick open file | `Ctrl+P` | | Toggle right sidebar | `Alt+Shift+B` |
| Toggle left sidebar | `Ctrl+B` | | Toggle terminal | `` Alt+` `` |
| Toggle right sidebar | `Ctrl+Shift+B` | | Focus Claude | `Alt+1` |
| Toggle terminal | `` Ctrl+` `` | | Focus Editor | `Alt+2` |
| Toggle compact mode | `Ctrl+Shift+C` | | Focus Terminal | `Alt+3` |
| Fullscreen focused panel | `F11` | | Toggle compact mode | `Alt+C` |
| Exit fullscreen | `Escape` |
### Navigation ### Application
| Action | Binding | | Action | Binding |
|--------|---------| |--------|---------|
| Focus Claude panel | `Ctrl+1` | | Command palette | `Alt+P` |
| Focus Editor | `Ctrl+2` | | Quick open file | `Alt+O` |
| Focus Terminal | `Ctrl+3` | | Select theme | `Alt+T` |
| Focus sidebar | `Ctrl+0` | | Quit | `Alt+Q` |
| Next tab (in tabbed panels) | `Ctrl+Tab` |
| Previous tab | `Ctrl+Shift+Tab` |
| Close current tab/editor | `Ctrl+W` |
### Git ### Git
| Action | Binding | | Action | Binding |
|--------|---------| |--------|---------|
| Open Git panel | `Ctrl+Shift+G` | | Open Git panel | `Alt+G` |
| Stage file | `Ctrl+Enter` (in git view) |
| Unstage file | `Ctrl+Backspace` (in git view) |
### Search & Problems
| Action | Binding |
|--------|---------|
| Find in file | `Ctrl+F` |
| Find in project | `Ctrl+Shift+F` |
| Go to problems | `Ctrl+Shift+M` |
| Next problem | `F8` |
| Previous problem | `Shift+F8` |
### Editor ### Editor
| Action | Binding | | Action | Binding |
|--------|---------| |--------|---------|
| Save | `Ctrl+S` | | Save | `Alt+S` |
| Undo | `Ctrl+Z` | | Go to line | `Alt+L` |
| Redo | `Ctrl+Shift+Z` | | Go to problems | `Alt+M` |
| Go to line | `Ctrl+G` |
--- ---
## Panel Communication ## Panel Communication
Panels should feel connected, like a normal IDE. ### File Navigation Flow
### File Navigation
``` ```
Sidebar file click Sidebar file click
@@ -372,7 +295,7 @@ Editor tab focused
File loaded in Editor File loaded in Editor
``` ```
### Problems/TODOs Navigation ### Problem/TODO Navigation Flow
``` ```
Click problem/todo item Click problem/todo item
@@ -387,117 +310,57 @@ Editor tab focused
File opened at specific line 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 Claude executes git workflow
│
├─► Accept: Apply changes, optionally close diff
│
└─► Reject: Discard, close diff
```
### Git File Actions
```
Click file in Git tab
│
▼
Workspace appears
│
▼
Diff tab shows unstaged changes
│
▼
Stage/unstage from diff view
``` ```
--- ---
## Implementation Notes ## State Management
### Recommended Textual Widgets ### Reactive Properties
| Component | Widget |
|-----------|--------|
| File browser | `DirectoryTree` |
| Claude output | `Markdown` or `RichLog` (for streaming) |
| Editor | `TextArea` (syntax highlighting built-in) |
| Tabbed panels | `TabbedContent`, `TabPane` |
| Panel switching | `ContentSwitcher` |
| Problems/TODOs list | `ListView` with `ListItem` |
| Git graph | `RichLog` or custom canvas widget |
| Command palette | `CommandPalette` (built-in) |
### Background Tasks
Use Textual's `@work` decorator for:
- Git status refresh
- Linter execution
- TODO scanning
- Jira CLI calls
```python ```python
@work(thread=True) class ClideApp(App):
def refresh_git_status(self) -> None:
result = subprocess.run(["git", "status", "--porcelain"], ...)
self.call_from_thread(self.update_git_view, result.stdout)
```
### State Management
**Core principle**: Hiding is not closing. All panels persist state when hidden.
```python
class IDEApp(App):
current_file: reactive[str | None] = reactive(None)
workspace_visible: reactive[bool] = reactive(False) workspace_visible: reactive[bool] = reactive(False)
problem_count: reactive[int] = reactive(0) problem_count: reactive[int] = reactive(0)
todo_count: reactive[int] = reactive(0) todo_count: reactive[int] = reactive(0)
current_branch: reactive[str] = reactive("main")
compact_mode: reactive[bool] = reactive(False) compact_mode: reactive[bool] = reactive(False)
``` ```
**Panel visibility pattern** — toggle `display`, don't destroy: ### State Preservation
```python
def toggle_workspace(self, visible: bool) -> None:
workspace = self.query_one("#panel-workspace")
workspace.display = visible # Retains all child state
# Adjust Claude panel height
claude = self.query_one("#panel-claude")
claude.styles.height = "40%" if visible else "100%"
```
**State to preserve per panel:**
| Panel | Preserved State | | Panel | Preserved State |
|-------|-----------------| |-------|-----------------|
| Editor | Open files, cursor positions, scroll, unsaved changes, undo history | | Editor | Open file, cursor, scroll, unsaved changes |
| Diff | Current diff content, scroll position, accept/reject state | | Diff | Current diff, scroll position |
| Terminal | PTY session, command history, output buffer, working directory | | Terminal | Session, history, output buffer |
| Sidebar tabs | Scroll position, expanded/collapsed sections, selection | | Sidebar tabs | Scroll, expanded sections, selection |
| Context tabs | Scroll position, selected item | | Context tabs | Scroll, selected item |
| Git views | Expanded sections, selected files |
--- ---
## Future Considerations ## Themes
- **Session persistence**: Remember open files, panel sizes, last git state 22 built-in themes with custom theme support.
- **Multiple projects**: Workspace switcher
- **Claude history**: Browse past conversations **Default:** summer-night (dark theme)
- **Custom themes**: User-selectable color schemes
- **Plugin system**: User-defined panels/integrations Theme selection persists in user settings (`~/.clide/settings.json`).
Custom themes can be added to `~/.clide/themes/` as TOML files.
+398
View File
@@ -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.
+4
View File
@@ -80,6 +80,7 @@ asyncio_default_fixture_loop_scope = "function"
addopts = [ addopts = [
"-v", "-v",
"--tb=short", "--tb=short",
"--snapshot-report=logs/snapshot_report.html",
] ]
[tool.mypy] [tool.mypy]
@@ -93,6 +94,7 @@ exclude = ["tests", "dist", "build"]
target-version = "py312" target-version = "py312"
line-length = 100 line-length = 100
src = ["clide", "tests"] src = ["clide", "tests"]
exclude = ["clide/vendor"]
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
@@ -116,6 +118,8 @@ ignore = [
"SIM105", # contextlib.suppress (try-except-pass is clearer in context) "SIM105", # contextlib.suppress (try-except-pass is clearer in context)
"SIM115", # context handler for files (sometimes not applicable) "SIM115", # context handler for files (sometimes not applicable)
"PTH123", # Path.open() vs open() (not always cleaner) "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] [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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB