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

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB