Add TODO.md integration to TODOs panel

- Parse TODO.md markdown checkboxes and display in collapsible section
- Add ProjectTodoItem model for TODO.md items with section/subsection support
- Swap context panel tab order to: Jira, TODOs, Problems
- Add "Create TODO.md" button when file doesn't exist with template
- Click project TODO items to navigate to TODO.md at that line
- Add header to TODO.md explaining Clide integration format
- Add pre-commit config with ruff linting and formatting
- Update ruff ignore rules for Textual patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-02-01 19:37:58 +01:00
co-authored by Claude Opus 4.5
parent 8c80a3ee97
commit 8b2d7c57ef
11 changed files with 663 additions and 131 deletions
+21
View File
@@ -0,0 +1,21 @@
# Pre-commit configuration for Clide
# See https://pre-commit.com for more information
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
# Run the linter
- id: ruff
args: [--fix]
# Run the formatter
- id: ruff-format
+13
View File
@@ -1,5 +1,18 @@
# TODO # TODO
<!--
Clide Integration: This file is parsed by Clide's TODO panel.
Format:
- Use ## for sections and ### for subsections
- Use markdown checkboxes: - [ ] for open items, - [x] for completed
- Items appear in the TODOs panel grouped by section
- Click an item in Clide to jump to this file at that line
For AI agents: Add new items under the appropriate section using the
checkbox format. Mark items as done with [x] when completed.
-->
Long-term open items for Clide development. Long-term open items for Clide development.
## Core Features ## Core Features
+53 -13
View File
@@ -19,13 +19,13 @@ from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings from clide.models.config import ClideSettings
from clide.services.claude_events import ( from clide.services.claude_events import (
ClaudeEvent, ClaudeEvent,
FileReadEvent,
FileEditEvent, FileEditEvent,
FileReadEvent,
FileWriteEvent, FileWriteEvent,
setup_event_parsing, setup_event_parsing,
) )
from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching
from clide.services.settings_service import SettingsService, get_settings_service from clide.services.settings_service import get_settings_service
from clide.services.syntax_service import register_languages from clide.services.syntax_service import register_languages
from clide.themes.registry import get_all_themes, get_theme from clide.themes.registry import get_all_themes, get_theme
from clide.widgets.panels.claude import ClaudePanel from clide.widgets.panels.claude import ClaudePanel
@@ -242,6 +242,7 @@ class ClideApp(App[None]):
# Right context panel # Right context panel
yield ContextPanel( yield ContextPanel(
jira_enabled=self.settings.jira_enabled, jira_enabled=self.settings.jira_enabled,
project_path=self.workdir,
) )
yield Footer() yield Footer()
@@ -293,6 +294,7 @@ class ClideApp(App[None]):
thread-safe, but we create the Message on the main thread to avoid any thread-safe, but we create the Message on the main thread to avoid any
potential Textual threading issues. potential Textual threading issues.
""" """
def post_file_event(): def post_file_event():
self.post_message(FileEventMessage(event)) self.post_message(FileEventMessage(event))
@@ -353,9 +355,7 @@ class ClideApp(App[None]):
pass pass
# Trigger extension hook # Trigger extension hook
self.extension_manager.trigger_claude_event( self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
"file_read", {"path": str(event.path)}
)
elif isinstance(event, FileEditEvent): elif isinstance(event, FileEditEvent):
# Claude edited a file - notify user # Claude edited a file - notify user
@@ -363,9 +363,7 @@ class ClideApp(App[None]):
self.notify(f"Claude edited: {event.path.name}", severity="information") self.notify(f"Claude edited: {event.path.name}", severity="information")
# Trigger extension hook # Trigger extension hook
self.extension_manager.trigger_claude_event( self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
"file_edit", {"path": str(event.path)}
)
elif isinstance(event, FileWriteEvent): elif isinstance(event, FileWriteEvent):
# Claude created/wrote a file - notify user # Claude created/wrote a file - notify user
@@ -374,9 +372,7 @@ class ClideApp(App[None]):
self.notify(f"Claude wrote: {event.path.name}", severity="information") self.notify(f"Claude wrote: {event.path.name}", severity="information")
# Trigger extension hook # Trigger extension hook
self.extension_manager.trigger_claude_event( self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
"file_write", {"path": str(event.path)}
)
def _apply_user_settings(self) -> None: def _apply_user_settings(self) -> None:
"""Apply saved user settings on startup.""" """Apply saved user settings on startup."""
@@ -438,9 +434,9 @@ class ClideApp(App[None]):
async def _refresh_todos(self) -> None: async def _refresh_todos(self) -> None:
"""Refresh TODOs.""" """Refresh TODOs."""
todos = await self.todos_controller.scan() code_todos, project_todos = await self.todos_controller.scan()
context = self.query_one(ContextPanel) context = self.query_one(ContextPanel)
context.update_todos(todos) context.update_todos(code_todos, project_todos)
async def _refresh_jira(self) -> None: async def _refresh_jira(self) -> None:
"""Refresh Jira content.""" """Refresh Jira content."""
@@ -643,6 +639,31 @@ class ClideApp(App[None]):
workspace = self.query_one(WorkspacePanel) workspace = self.query_one(WorkspacePanel)
workspace.open_file(item.file_path, line=item.line) workspace.open_file(item.file_path, line=item.line)
async def on_context_panel_project_todo_clicked(
self,
event: ContextPanel.ProjectTodoClicked,
) -> None:
"""Handle project TODO click - open TODO.md at line."""
item = event.item
todo_path = self.workdir / "TODO.md"
if todo_path.exists():
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(todo_path, line=item.line)
async def on_context_panel_todo_md_created(
self,
event: ContextPanel.TodoMdCreated,
) -> None:
"""Handle TODO.md creation - refresh TODOs and open file."""
# Refresh TODOs to pick up the new file
await self._refresh_todos()
# Open the new file in editor
self.workspace_visible = True
workspace = self.query_one(WorkspacePanel)
workspace.open_file(event.path)
self.notify("Created TODO.md")
async def on_context_panel_jira_refresh_requested( async def on_context_panel_jira_refresh_requested(
self, self,
_event: ContextPanel.JiraRefreshRequested, _event: ContextPanel.JiraRefreshRequested,
@@ -749,3 +770,22 @@ class ClideApp(App[None]):
) -> None: ) -> None:
"""Handle workspace close request.""" """Handle workspace close request."""
self.workspace_visible = False self.workspace_visible = False
def on_sidebar_panel_claude_command_requested(
self,
event: SidebarPanel.ClaudeCommandRequested,
) -> None:
"""Handle Claude command request from git panel.
Sends skill commands (e.g., /commit) to Claude terminal.
Ensures the git-workflow skill is installed before sending.
"""
# Ensure skill is available
self.git_controller._ensure_git_skill()
# Send command to Claude terminal
claude = self.query_one(ClaudePanel)
claude.send_input(event.command)
# Focus Claude panel so user can see the response
self.action_focus_claude()
+37 -12
View File
@@ -5,7 +5,13 @@ from pathlib import Path
from textual.message import Message from textual.message import Message
from clide.controllers.base import controller from clide.controllers.base import controller
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType from clide.models.todos import (
ProjectTodoItem,
TodoItem,
TodosState,
TodosSummary,
TodoType,
)
from clide.services.todo_scanner import TodoScanner from clide.services.todo_scanner import TodoScanner
@@ -16,8 +22,14 @@ class TodosController:
class TodosUpdated(Message): class TodosUpdated(Message):
"""Emitted when TODOs list is updated.""" """Emitted when TODOs list is updated."""
def __init__(self, items: list[TodoItem], summary: TodosSummary) -> None: def __init__(
self,
items: list[TodoItem],
project_items: list[ProjectTodoItem],
summary: TodosSummary,
) -> None:
self.items = items self.items = items
self.project_items = project_items
self.summary = summary self.summary = summary
super().__init__() super().__init__()
@@ -39,9 +51,14 @@ class TodosController:
@property @property
def items(self) -> list[TodoItem]: def items(self) -> list[TodoItem]:
"""Get list of TODO items.""" """Get list of code TODO items."""
return self._state.items return self._state.items
@property
def project_items(self) -> list[ProjectTodoItem]:
"""Get list of project TODO items from TODO.md."""
return self._state.project_items
@property @property
def summary(self) -> TodosSummary: def summary(self) -> TodosSummary:
"""Get TODOs summary.""" """Get TODOs summary."""
@@ -49,21 +66,29 @@ class TodosController:
@property @property
def total_count(self) -> int: def total_count(self) -> int:
"""Get total TODO count.""" """Get total code TODO count."""
return self._state.summary.total return self._state.summary.total
async def refresh(self) -> tuple[list[TodoItem], TodosSummary]: @property
def project_count(self) -> int:
"""Get total project TODO count."""
return self._state.summary.project_total
async def refresh(
self,
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
"""Refresh TODOs from project. """Refresh TODOs from project.
Returns: Returns:
Tuple of (items, summary) Tuple of (code items, project items, summary)
""" """
items, summary = await self._scanner.scan() items, project_items, summary = await self._scanner.scan()
self._state.items = items self._state.items = items
self._state.project_items = project_items
self._state.summary = summary self._state.summary = summary
return items, summary return items, project_items, summary
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]: def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
"""Filter TODOs by type. """Filter TODOs by type.
@@ -128,11 +153,11 @@ class TodosController:
grouped[item.file_path].append(item) grouped[item.file_path].append(item)
return grouped return grouped
async def scan(self) -> list[TodoItem]: async def scan(self) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
"""Scan for TODOs and return items. """Scan for TODOs and return items.
Returns: Returns:
List of TODO items Tuple of (code items, project items)
""" """
items, _ = await self.refresh() items, project_items, _ = await self.refresh()
return items return items, project_items
+46 -1
View File
@@ -19,6 +19,30 @@ class TodoType(str, Enum):
REVIEW = "REVIEW" REVIEW = "REVIEW"
class ProjectTodoItem(BaseModel):
"""A TODO item from TODO.md file."""
model_config = ConfigDict(strict=True, frozen=True)
text: str
section: str # Top-level section (## heading)
subsection: str | None = None # Optional subsection (### heading)
line: int # Line number in TODO.md
checked: bool = False # Whether the checkbox is checked
@property
def category(self) -> str:
"""Get full category path."""
if self.subsection:
return f"{self.section} › {self.subsection}"
return self.section
@property
def icon(self) -> str:
"""Icon for display."""
return "☑" if self.checked else "☐"
class TodoItem(BaseModel): class TodoItem(BaseModel):
"""A single TODO comment found in code.""" """A single TODO comment found in code."""
@@ -61,12 +85,19 @@ class TodosSummary(BaseModel):
fixme_count: int = 0 fixme_count: int = 0
hack_count: int = 0 hack_count: int = 0
other_count: int = 0 other_count: int = 0
project_todo_count: int = 0 # Count from TODO.md
project_done_count: int = 0 # Checked items in TODO.md
@property @property
def total(self) -> int: def total(self) -> int:
"""Total number of TODOs.""" """Total number of code TODOs."""
return self.todo_count + self.fixme_count + self.hack_count + self.other_count return self.todo_count + self.fixme_count + self.hack_count + self.other_count
@property
def project_total(self) -> int:
"""Total number of project TODOs."""
return self.project_todo_count + self.project_done_count
@property @property
def display_text(self) -> str: def display_text(self) -> str:
"""Text for tab badge.""" """Text for tab badge."""
@@ -79,10 +110,12 @@ class TodosState(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
items: list[TodoItem] = [] items: list[TodoItem] = []
project_items: list[ProjectTodoItem] = [] # Items from TODO.md
summary: TodosSummary = TodosSummary() summary: TodosSummary = TodosSummary()
filter_type: TodoType | None = None filter_type: TodoType | None = None
selected_index: int | None = None selected_index: int | None = None
group_by_file: bool = True group_by_file: bool = True
show_completed_project_todos: bool = False # Toggle for checked items
def items_for_file(self, path: Path) -> list[TodoItem]: def items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODO items for a specific file.""" """Get TODO items for a specific file."""
@@ -91,3 +124,15 @@ class TodosState(BaseModel):
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]: def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Get TODO items of a specific type.""" """Get TODO items of a specific type."""
return [item for item in self.items if item.todo_type == todo_type] return [item for item in self.items if item.todo_type == todo_type]
def project_items_by_section(self, section: str) -> list[ProjectTodoItem]:
"""Get project TODO items for a specific section."""
return [item for item in self.project_items if item.section == section]
def get_project_sections(self) -> list[str]:
"""Get unique sections from project TODOs."""
sections: list[str] = []
for item in self.project_items:
if item.section not in sections:
sections.append(item.section)
return sections
+123 -30
View File
@@ -3,7 +3,7 @@
import re import re
from pathlib import Path from pathlib import Path
from clide.models.todos import TodoItem, TodosSummary, TodoType from clide.models.todos import ProjectTodoItem, TodoItem, TodosSummary, TodoType
from clide.services.process_service import ProcessService from clide.services.process_service import ProcessService
@@ -16,33 +16,66 @@ class TodoScanner:
re.IGNORECASE, re.IGNORECASE,
) )
# Pattern to match markdown checkboxes: - [ ] or - [x]
CHECKBOX_PATTERN = re.compile(r"^(\s*)-\s*\[([ xX])\]\s*(.+)$")
# File extensions to scan # File extensions to scan
SCAN_EXTENSIONS = { SCAN_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h", ".py",
".go", ".rs", ".rb", ".php", ".css", ".scss", ".html", ".vue", ".js",
".svelte", ".md", ".sh", ".bash", ".yaml", ".yml", ".toml", ".ts",
".jsx",
".tsx",
".java",
".c",
".cpp",
".h",
".go",
".rs",
".rb",
".php",
".css",
".scss",
".html",
".vue",
".svelte",
".md",
".sh",
".bash",
".yaml",
".yml",
".toml",
} }
def __init__(self, project_path: Path) -> None: def __init__(self, project_path: Path) -> None:
self.project_path = project_path self.project_path = project_path
self._process = ProcessService(cwd=project_path) self._process = ProcessService(cwd=project_path)
async def scan(self) -> tuple[list[TodoItem], TodosSummary]: async def scan(
"""Scan project for TODO comments. self,
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
"""Scan project for TODO comments and TODO.md items.
Returns: Returns:
Tuple of (todo items, summary) Tuple of (code todo items, project todo items, summary)
""" """
items: list[TodoItem] = [] items: list[TodoItem] = []
# Use ripgrep if available for speed # Use ripgrep if available for speed
result = await self._process.run( result = await self._process.run(
"rg", "--line-number", "--no-heading", "rg",
"-e", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b", "--line-number",
"--type-add", "code:*.py", "--no-heading",
"--type-add", "code:*.js", "-e",
"--type-add", "code:*.ts", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
"--type", "code", "--type-add",
"code:*.py",
"--type-add",
"code:*.js",
"--type-add",
"code:*.ts",
"--type",
"code",
".", ".",
) )
@@ -52,20 +85,76 @@ class TodoScanner:
# Fallback to Python-based scanning # Fallback to Python-based scanning
items = await self._scan_with_python() items = await self._scan_with_python()
# Parse TODO.md if it exists
project_items = self._parse_todo_md()
# Create summary # Create summary
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO) todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME) fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK) hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
other_count = len(items) - todo_count - fixme_count - hack_count other_count = len(items) - todo_count - fixme_count - hack_count
project_todo_count = sum(1 for i in project_items if not i.checked)
project_done_count = sum(1 for i in project_items if i.checked)
summary = TodosSummary( summary = TodosSummary(
todo_count=todo_count, todo_count=todo_count,
fixme_count=fixme_count, fixme_count=fixme_count,
hack_count=hack_count, hack_count=hack_count,
other_count=other_count, other_count=other_count,
project_todo_count=project_todo_count,
project_done_count=project_done_count,
) )
return items, summary return items, project_items, summary
def _parse_todo_md(self) -> list[ProjectTodoItem]:
"""Parse TODO.md file for checkbox items.
Returns:
List of project TODO items
"""
todo_md_path = self.project_path / "TODO.md"
if not todo_md_path.exists():
return []
items: list[ProjectTodoItem] = []
current_section = "General"
current_subsection: str | None = None
try:
content = todo_md_path.read_text(encoding="utf-8")
for line_num, line in enumerate(content.split("\n"), 1):
# Check for section headers (## Section)
if line.startswith("## "):
current_section = line[3:].strip()
current_subsection = None
continue
# Check for subsection headers (### Subsection)
if line.startswith("### "):
current_subsection = line[4:].strip()
continue
# Check for checkbox items
match = self.CHECKBOX_PATTERN.match(line)
if match:
checkbox_state = match.group(2)
text = match.group(3).strip()
checked = checkbox_state.lower() == "x"
items.append(
ProjectTodoItem(
text=text,
section=current_section,
subsection=current_subsection,
line=line_num,
checked=checked,
)
)
except (OSError, UnicodeDecodeError):
pass
return items
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]: def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
"""Parse ripgrep output into TodoItems.""" """Parse ripgrep output into TodoItems."""
@@ -99,14 +188,16 @@ class TodoScanner:
except ValueError: except ValueError:
todo_type = TodoType.TODO todo_type = TodoType.TODO
items.append(TodoItem( items.append(
file_path=file_path, TodoItem(
line=line_num, file_path=file_path,
column=content.find(todo_type_str) + 1, line=line_num,
todo_type=todo_type, column=content.find(todo_type_str) + 1,
text=todo_text, todo_type=todo_type,
context_line=content.strip(), text=todo_text,
)) context_line=content.strip(),
)
)
return items return items
@@ -137,14 +228,16 @@ class TodoScanner:
except ValueError: except ValueError:
todo_type = TodoType.TODO todo_type = TodoType.TODO
items.append(TodoItem( items.append(
file_path=file_path.relative_to(self.project_path), TodoItem(
line=line_num, file_path=file_path.relative_to(self.project_path),
column=line.find(todo_type_str) + 1, line=line_num,
todo_type=todo_type, column=line.find(todo_type_str) + 1,
text=todo_text, todo_type=todo_type,
context_line=line.strip(), text=todo_text,
)) context_line=line.strip(),
)
)
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
continue continue
+33 -21
View File
@@ -1,37 +1,39 @@
""" """
pyte pyte
~~~~ ~~~~
`pyte` implements a mix of VT100, VT220 and VT520 specification, `pyte` implements a mix of VT100, VT220 and VT520 specification,
and aims to support most of the `TERM=linux` functionality. and aims to support most of the `TERM=linux` functionality.
: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.
""" """
__all__ = ( __all__ = (
"Screen", "DiffScreen", "HistoryScreen", "DebugScreen", "Screen",
"Stream", "ByteStream", "DiffScreen",
"HistoryScreen",
"DebugScreen",
"Stream",
"ByteStream",
# Clide additions # Clide additions
"set_debug_logger", "set_event_callback", "set_debug_logger",
"get_debug_logger",
"set_event_callback",
) )
import io import io
from typing import Union
from .screens import Screen, DiffScreen, HistoryScreen, DebugScreen
from .screens import set_debug_logger as _set_screen_logger
from .streams import Stream, ByteStream
from .streams import set_debug_logger as _set_stream_logger
from .streams import set_event_callback
# Re-export submodules for compatibility # Re-export submodules for compatibility
from . import modes
from . import screens from . import screens
from .screens import DebugScreen, DiffScreen, HistoryScreen, Screen
from .screens import set_debug_logger as _set_screen_logger
from .streams import ByteStream, Stream, set_event_callback
from .streams import set_debug_logger as _set_stream_logger
def set_debug_logger(logger): def set_debug_logger(logger):
@@ -44,8 +46,18 @@ def set_debug_logger(logger):
_set_screen_logger(logger) _set_screen_logger(logger)
def get_debug_logger():
"""Get the current debug logger (if set).
Returns:
The current debug logger callable, or None if not set.
"""
return screens._debug_logger
if __debug__: if __debug__:
def dis(chars: Union[bytes, str]) -> None:
def dis(chars: bytes | str) -> None:
"""A :func:`dis.dis` for terminals.""" """A :func:`dis.dis` for terminals."""
if isinstance(chars, str): if isinstance(chars, str):
chars = chars.encode("utf-8") chars = chars.encode("utf-8")
+258 -26
View File
@@ -1,20 +1,54 @@
"""TODOs view component.""" """TODOs view component."""
from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Vertical from textual.containers import Vertical
from textual.message import Message from textual.message import Message
from textual.widgets import ListItem, ListView, Static from textual.widgets import Button, Collapsible, ListItem, ListView, Static
from clide.models.todos import TodoItem, TodoType from clide.models.todos import ProjectTodoItem, TodoItem, TodoType
# Template for new TODO.md files
TODO_MD_TEMPLATE = """# TODO
<!--
Clide Integration: This file is parsed by Clide's TODO panel.
Format:
- Use ## for sections and ### for subsections
- Use markdown checkboxes: - [ ] for open items, - [x] for completed
- Items appear in the TODOs panel grouped by section
- Click an item in Clide to jump to this file at that line
For AI agents: Add new items under the appropriate section using the
checkbox format. Mark items as done with [x] when completed.
-->
Project TODO items.
## Features
- [ ] Add your first feature here
- [ ] Another feature to implement
## Bugs
- [ ] Bug to fix
## Documentation
- [ ] Documentation to write
"""
class TodoListItem(ListItem): class TodoListItem(ListItem):
"""A single TODO item.""" """A single code TODO item."""
def __init__(self, item: TodoItem) -> None: def __init__(self, item: TodoItem) -> None:
super().__init__() super().__init__()
self.item = item self.item = item
self.is_project_item = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
icon = self.item.type_icon icon = self.item.type_icon
@@ -28,8 +62,27 @@ class TodoListItem(ListItem):
) )
class ProjectTodoListItem(ListItem):
"""A single project TODO item from TODO.md."""
def __init__(self, item: ProjectTodoItem) -> None:
super().__init__()
self.item = item
self.is_project_item = True
def compose(self) -> ComposeResult:
icon = self.item.icon
checked_style = "dim strike" if self.item.checked else ""
category = f"[dim]{self.item.category}[/] " if self.item.subsection else ""
yield Static(
f"[project]{icon}[/] {category}" f"[{checked_style}]{self.item.text}[/]",
markup=True,
)
class TodosView(Vertical): class TodosView(Vertical):
"""View for TODO/FIXME comments.""" """View for TODO/FIXME comments and project TODOs."""
DEFAULT_CSS = """ DEFAULT_CSS = """
TodosView { TodosView {
@@ -42,8 +95,31 @@ class TodosView(Vertical):
padding: 0 1; padding: 0 1;
} }
TodosView .section-header {
height: 1;
background: $panel;
padding: 0 1;
color: $text-muted;
}
TodosView ListView { TodosView ListView {
height: auto;
max-height: 50%;
}
TodosView #code-todos-list {
height: 1fr; height: 1fr;
max-height: none;
}
TodosView Collapsible {
padding: 0;
border: none;
}
TodosView CollapsibleTitle {
background: $panel;
padding: 0 1;
} }
TodosView .todo { color: $primary; } TodosView .todo { color: $primary; }
@@ -52,59 +128,215 @@ class TodosView(Vertical):
TodosView .xxx { color: $error; } TodosView .xxx { color: $error; }
TodosView .note { color: $secondary; } TodosView .note { color: $secondary; }
TodosView .bug { color: $error; } TodosView .bug { color: $error; }
TodosView .project { color: $accent; }
TodosView .empty-message { TodosView .empty-message {
padding: 2; padding: 2;
text-align: center; text-align: center;
color: $success; color: $success;
} }
TodosView .create-todo-section {
height: auto;
padding: 1 2;
align: center middle;
}
TodosView .create-todo-message {
text-align: center;
color: $text-muted;
margin-bottom: 1;
}
TodosView #create-todo-btn {
width: auto;
}
""" """
class TodoClicked(Message): class TodoClicked(Message):
"""Emitted when a TODO is clicked.""" """Emitted when a code TODO is clicked."""
def __init__(self, item: TodoItem) -> None: def __init__(self, item: TodoItem) -> None:
self.item = item self.item = item
super().__init__() super().__init__()
def __init__(self, items: list[TodoItem] | None = None, **kwargs) -> None: class ProjectTodoClicked(Message):
"""Emitted when a project TODO is clicked."""
def __init__(self, item: ProjectTodoItem) -> None:
self.item = item
super().__init__()
class CreateTodoMdRequested(Message):
"""Emitted when user wants to create a TODO.md file."""
pass
class TodoMdCreated(Message):
"""Emitted when TODO.md has been created."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
def __init__(
self,
items: list[TodoItem] | None = None,
project_items: list[ProjectTodoItem] | None = None,
project_path: Path | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._items = items or [] self._items = items or []
self._project_items = project_items or []
self._project_path = project_path or Path.cwd()
self._has_todo_md = (self._project_path / "TODO.md").exists()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
count = len(self._items) total = len(self._items) + len(self._project_items)
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header") yield Static(f"TODOs ({total})", classes="todos-header", id="todos-header")
if self._items: # Create TODO.md section (shown when file doesn't exist)
yield ListView( with Vertical(classes="create-todo-section", id="create-todo-section"):
*[TodoListItem(item) for item in self._items], yield Static(
id="todos-list", "No TODO.md found in project",
classes="create-todo-message",
) )
else: yield Button("Create TODO.md", id="create-todo-btn", variant="primary")
yield Static("No TODOs found ✓", classes="empty-message")
def update_items(self, items: list[TodoItem]) -> None: # Project TODOs section (collapsible)
"""Update the TODOs list.""" with Collapsible(title="Project TODOs", id="project-todos-section"):
self._items = items yield ListView(id="project-todos-list")
# Update header # Code TODOs section
header = self.query_one("#todos-header", Static) yield Static("Code TODOs", classes="section-header", id="code-todos-header")
header.update(f"TODOs ({len(items)})") yield ListView(id="code-todos-list")
# Update list yield Static("No TODOs found", classes="empty-message", id="todos-empty")
def on_mount(self) -> None:
"""Initialize the lists with items."""
self._refresh_lists()
def _refresh_lists(self) -> None:
"""Refresh both list views with current items."""
try: try:
todos_list = self.query_one("#todos-list", ListView) create_section = self.query_one("#create-todo-section", Vertical)
todos_list.clear() project_section = self.query_one("#project-todos-section", Collapsible)
for item in items: project_list = self.query_one("#project-todos-list", ListView)
todos_list.append(TodoListItem(item)) code_header = self.query_one("#code-todos-header", Static)
code_list = self.query_one("#code-todos-list", ListView)
empty_msg = self.query_one("#todos-empty", Static)
# Clear both lists
project_list.clear()
code_list.clear()
# Check if TODO.md exists
self._has_todo_md = (self._project_path / "TODO.md").exists()
has_items = False
# Show create button if no TODO.md and no project items
if not self._has_todo_md and not self._project_items:
create_section.display = True
project_section.display = False
else:
create_section.display = False
# Populate project TODOs
if self._project_items:
has_items = True
# Group by section
sections: dict[str, list[ProjectTodoItem]] = {}
for item in self._project_items:
if item.section not in sections:
sections[item.section] = []
sections[item.section].append(item)
# Add items (flat list, grouped display can be added later)
for item in self._project_items:
if not item.checked: # Only show unchecked by default
project_list.append(ProjectTodoListItem(item))
unchecked = sum(1 for i in self._project_items if not i.checked)
project_section.title = f"Project TODOs ({unchecked})"
project_section.display = True
else:
project_section.display = False
# Populate code TODOs
if self._items:
has_items = True
for item in self._items:
code_list.append(TodoListItem(item))
code_header.update(f"Code TODOs ({len(self._items)})")
code_header.display = True
code_list.display = True
else:
code_header.display = False
code_list.display = False
# Show empty message only if no items at all and TODO.md exists
empty_msg.display = not has_items and self._has_todo_md
except Exception: except Exception:
pass pass
def update_items(
self,
items: list[TodoItem],
project_items: list[ProjectTodoItem] | None = None,
) -> None:
"""Update both TODO lists."""
self._items = items
self._project_items = project_items or []
# Update header with total count
try:
unchecked_project = sum(1 for p in self._project_items if not p.checked)
total = len(items) + unchecked_project
header = self.query_one("#todos-header", Static)
header.update(f"TODOs ({total})")
except Exception:
pass
# Refresh the lists
self._refresh_lists()
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]: def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Filter by TODO type.""" """Filter code TODOs by type."""
return [i for i in self._items if i.todo_type == todo_type] return [i for i in self._items if i.todo_type == todo_type]
def on_list_view_selected(self, event: ListView.Selected) -> None: def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection.""" """Handle item selection."""
if isinstance(event.item, TodoListItem): if isinstance(event.item, TodoListItem):
self.post_message(self.TodoClicked(event.item.item)) self.post_message(self.TodoClicked(event.item.item))
elif isinstance(event.item, ProjectTodoListItem):
self.post_message(self.ProjectTodoClicked(event.item.item))
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "create-todo-btn":
self._create_todo_md()
def _create_todo_md(self) -> None:
"""Create a new TODO.md file with template."""
todo_path = self._project_path / "TODO.md"
if todo_path.exists():
return
try:
todo_path.write_text(TODO_MD_TEMPLATE)
self._has_todo_md = True
self.post_message(self.TodoMdCreated(todo_path))
# Notify app to refresh TODOs
self.post_message(self.CreateTodoMdRequested())
except OSError as e:
self.app.notify(f"Failed to create TODO.md: {e}", severity="error")
def set_project_path(self, path: Path) -> None:
"""Update the project path."""
self._project_path = path
self._has_todo_md = (path / "TODO.md").exists()
self._refresh_lists()
+20 -13
View File
@@ -22,9 +22,10 @@ from textual.reactive import reactive
from textual.strip import Strip from textual.strip import Strip
from textual.widget import Widget from textual.widget import Widget
from clide.services.settings_service import get_settings_service
# Use vendored pyte with diagnostic logging support # Use vendored pyte with diagnostic logging support
from clide.vendor import pyte from clide.vendor import pyte
from clide.services.settings_service import get_settings_service
if TYPE_CHECKING: if TYPE_CHECKING:
from textual.app import ComposeResult from textual.app import ComposeResult
@@ -46,6 +47,7 @@ def _setup_terminal_debug_logging() -> None:
def debug_logger(message: str) -> None: def debug_logger(message: str) -> None:
"""Log debug message with timestamp.""" """Log debug message with timestamp."""
import datetime import datetime
timestamp = datetime.datetime.now().isoformat() timestamp = datetime.datetime.now().isoformat()
log_file.write(f"[{timestamp}] {message}\n") log_file.write(f"[{timestamp}] {message}\n")
log_file.flush() log_file.flush()
@@ -153,6 +155,7 @@ class TerminalDisplay(Widget, can_focus=True):
# Send SIGWINCH to notify the child process of resize # Send SIGWINCH to notify the child process of resize
if self._pid is not None: if self._pid is not None:
import signal import signal
try: try:
os.kill(self._pid, signal.SIGWINCH) os.kill(self._pid, signal.SIGWINCH)
except OSError: except OSError:
@@ -231,11 +234,11 @@ class TerminalDisplay(Widget, can_focus=True):
# Kitty keyboard protocol, bracketed paste mode queries, etc. # Kitty keyboard protocol, bracketed paste mode queries, etc.
_UNSUPPORTED_ESCAPES = re.compile( _UNSUPPORTED_ESCAPES = re.compile(
r"\x1b\[[\=\>\<][0-9;]*[a-zA-Z]" # Kitty keyboard protocol (=, >, or < prefix) r"\x1b\[[\=\>\<][0-9;]*[a-zA-Z]" # Kitty keyboard protocol (=, >, or < prefix)
r"|\x1b\[\?[0-9;]*u" # Kitty keyboard query r"|\x1b\[\?[0-9;]*u" # Kitty keyboard query
r"|\x1b\[\?[0-9;]*c" # Device attributes query r"|\x1b\[\?[0-9;]*c" # Device attributes query
r"|\x1b\[>[0-9;]*c" # Secondary device attributes r"|\x1b\[>[0-9;]*c" # Secondary device attributes
r"|\x1b\]\d+;[^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences (title, etc.) r"|\x1b\]\d+;[^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences (title, etc.)
r"|\x1b\[\?2026[hl]" # Synchronized update mode (not used by pyte) r"|\x1b\[\?2026[hl]" # Synchronized update mode (not used by pyte)
) )
def _filter_unsupported_escapes(self, data: str) -> str: def _filter_unsupported_escapes(self, data: str) -> str:
@@ -417,7 +420,11 @@ class TerminalDisplay(Widget, can_focus=True):
screen_rows = self._rows screen_rows = self._rows
# Widget display dimensions (what we need to output) # Widget display dimensions (what we need to output)
output_width = self.size.width if self.size.width > 0 else screen_cols + self.PADDING_LEFT + self.PADDING_RIGHT output_width = (
self.size.width
if self.size.width > 0
else screen_cols + self.PADDING_LEFT + self.PADDING_RIGHT
)
if y >= screen_rows: if y >= screen_rows:
return Strip.blank(output_width) return Strip.blank(output_width)
@@ -436,7 +443,7 @@ class TerminalDisplay(Widget, can_focus=True):
cols_to_render = min(screen_cols, output_width - self.PADDING_LEFT - self.PADDING_RIGHT) cols_to_render = min(screen_cols, output_width - self.PADDING_LEFT - self.PADDING_RIGHT)
# Debug logging for render pipeline # Debug logging for render pipeline
debug_logger = getattr(pyte, '_debug_logger', None) debug_logger = pyte.get_debug_logger()
log_this_line = False log_this_line = False
for x in range(cols_to_render): for x in range(cols_to_render):
@@ -450,16 +457,15 @@ class TerminalDisplay(Widget, can_focus=True):
if 0x2500 <= code <= 0x257F: if 0x2500 <= code <= 0x257F:
log_this_line = True log_this_line = True
if debug_logger: if debug_logger:
debug_logger(f"RENDER y={y} x={x}: box-drawing U+{code:04X} char='{char_data}'") debug_logger(
f"RENDER y={y} x={x}: box-drawing U+{code:04X} char='{char_data}'"
)
# Handle characters that may not render correctly # Handle characters that may not render correctly
if len(char_data) == 1: if len(char_data) == 1:
code = ord(char_data) code = ord(char_data)
# Control characters (except space) # Control characters (except space)
if code < 32 and code != 0: if code < 32 and code != 0 or 127 <= code <= 159:
char_data = " "
# DEL and C1 control characters
elif 127 <= code <= 159:
char_data = " " char_data = " "
# Braille patterns (U+2800-U+28FF) - used for spinners # Braille patterns (U+2800-U+28FF) - used for spinners
# Replace with simple ASCII spinner chars or spaces # Replace with simple ASCII spinner chars or spaces
@@ -510,7 +516,7 @@ class TerminalDisplay(Widget, can_focus=True):
# Debug: log segments if we had box-drawing chars # Debug: log segments if we had box-drawing chars
if log_this_line and debug_logger: if log_this_line and debug_logger:
for i, seg in enumerate(segments[:10]): # First 10 segments for i, seg in enumerate(segments[:10]): # First 10 segments
seg_text = seg.text if hasattr(seg, 'text') else str(seg) seg_text = seg.text if hasattr(seg, "text") else str(seg)
if len(seg_text) <= 5: if len(seg_text) <= 5:
debug_logger(f"RENDER y={y} seg[{i}]: '{seg_text}' (repr: {repr(seg_text)})") debug_logger(f"RENDER y={y} seg[{i}]: '{seg_text}' (repr: {repr(seg_text)})")
@@ -665,6 +671,7 @@ class ClaudePanel(Vertical):
class ClaudeStarted(Message): class ClaudeStarted(Message):
"""Emitted when Claude Code process starts.""" """Emitted when Claude Code process starts."""
pass pass
# Reactive state # Reactive state
+54 -15
View File
@@ -1,5 +1,6 @@
"""Context panel with Problems, TODOs, and Jira tabs.""" """Context panel with Jira, TODOs, and Problems tabs."""
from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
@@ -8,14 +9,14 @@ from textual.reactive import reactive
from textual.widgets import Static, TabbedContent, TabPane from textual.widgets import Static, TabbedContent, TabPane
from clide.models.problems import Problem from clide.models.problems import Problem
from clide.models.todos import TodoItem from clide.models.todos import ProjectTodoItem, TodoItem
from clide.widgets.components.jira_view import JiraView from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.todos_view import TodosView from clide.widgets.components.todos_view import TodosView
class ContextPanel(Vertical): class ContextPanel(Vertical):
"""Right context panel with Problems, TODOs, and Jira integration.""" """Right context panel with Jira, TODOs, and Problems tabs."""
DEFAULT_CSS = """ DEFAULT_CSS = """
ContextPanel { ContextPanel {
@@ -61,16 +62,31 @@ class ContextPanel(Vertical):
super().__init__() super().__init__()
class TodoClicked(Message): class TodoClicked(Message):
"""Emitted when a TODO is clicked.""" """Emitted when a code TODO is clicked."""
def __init__(self, item: TodoItem) -> None: def __init__(self, item: TodoItem) -> None:
self.item = item self.item = item
super().__init__() super().__init__()
class ProjectTodoClicked(Message):
"""Emitted when a project TODO (from TODO.md) is clicked."""
def __init__(self, item: ProjectTodoItem) -> None:
self.item = item
super().__init__()
class JiraRefreshRequested(Message): class JiraRefreshRequested(Message):
"""Emitted when Jira refresh is requested.""" """Emitted when Jira refresh is requested."""
pass pass
class TodoMdCreated(Message):
"""Emitted when TODO.md has been created."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
# Reactive state with counts for tab badges # Reactive state with counts for tab badges
problem_count: reactive[int] = reactive(0) problem_count: reactive[int] = reactive(0)
todo_count: reactive[int] = reactive(0) todo_count: reactive[int] = reactive(0)
@@ -79,20 +95,22 @@ class ContextPanel(Vertical):
def __init__( def __init__(
self, self,
jira_enabled: bool = True, jira_enabled: bool = True,
project_path: Path | None = None,
**kwargs, **kwargs,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._jira_enabled = jira_enabled self._jira_enabled = jira_enabled
self._project_path = project_path or Path.cwd()
self.id = "panel-context" self.id = "panel-context"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with TabbedContent(id="context-tabs"): with TabbedContent(id="context-tabs"):
with TabPane("Problems", id="context-problems"):
yield ProblemsView(id="problems-view")
with TabPane("TODOs", id="context-todos"):
yield TodosView(id="todos-view")
with TabPane("Jira", id="context-jira"): with TabPane("Jira", id="context-jira"):
yield JiraView(enabled=self._jira_enabled, id="jira-view") yield JiraView(enabled=self._jira_enabled, id="jira-view")
with TabPane("TODOs", id="context-todos"):
yield TodosView(project_path=self._project_path, id="todos-view")
with TabPane("Problems", id="context-problems"):
yield ProblemsView(id="problems-view")
# Tab bar with counts at bottom # Tab bar with counts at bottom
with Horizontal(classes="context-tab-bar"): with Horizontal(classes="context-tab-bar"):
yield Static("", id="tab-counts") yield Static("", id="tab-counts")
@@ -120,15 +138,15 @@ class ContextPanel(Vertical):
problem_style = "error-count" if self.problem_count > 0 else "success-count" problem_style = "error-count" if self.problem_count > 0 else "success-count"
todo_style = "warning-count" if self.todo_count > 0 else "success-count" todo_style = "warning-count" if self.todo_count > 0 else "success-count"
# Build count display # Build count display (order matches tab order: TODOs, Problems)
parts = [] parts = []
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
if self.problem_count > 0: if self.problem_count > 0:
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]") parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
else: else:
parts.append(f"[{problem_style}]✓ 0[/]") parts.append(f"[{problem_style}]✓ 0[/]")
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
counts.update(" │ ".join(parts)) counts.update(" │ ".join(parts))
except Exception: except Exception:
pass pass
@@ -142,12 +160,19 @@ class ContextPanel(Vertical):
except Exception: except Exception:
pass pass
def update_todos(self, items: list[TodoItem]) -> None: def update_todos(
self,
items: list[TodoItem],
project_items: list[ProjectTodoItem] | None = None,
) -> None:
"""Update TODOs view and count.""" """Update TODOs view and count."""
self.todo_count = len(items) project_items = project_items or []
# Count includes both code TODOs and unchecked project TODOs
unchecked_project = sum(1 for p in project_items if not p.checked)
self.todo_count = len(items) + unchecked_project
try: try:
view = self.query_one("#todos-view", TodosView) view = self.query_one("#todos-view", TodosView)
view.update_items(items) view.update_items(items, project_items)
except Exception: except Exception:
pass pass
@@ -201,12 +226,26 @@ class ContextPanel(Vertical):
self.post_message(self.ProblemClicked(event.problem)) self.post_message(self.ProblemClicked(event.problem))
def on_todos_view_todo_clicked(self, event: TodosView.TodoClicked) -> None: def on_todos_view_todo_clicked(self, event: TodosView.TodoClicked) -> None:
"""Forward todo click.""" """Forward code todo click."""
self.post_message(self.TodoClicked(event.item)) self.post_message(self.TodoClicked(event.item))
def on_todos_view_project_todo_clicked(
self,
event: TodosView.ProjectTodoClicked,
) -> None:
"""Forward project todo click."""
self.post_message(self.ProjectTodoClicked(event.item))
def on_jira_view_refresh_requested( def on_jira_view_refresh_requested(
self, self,
event: JiraView.RefreshRequested, event: JiraView.RefreshRequested,
) -> None: ) -> None:
"""Forward Jira refresh request.""" """Forward Jira refresh request."""
self.post_message(self.JiraRefreshRequested()) self.post_message(self.JiraRefreshRequested())
def on_todos_view_todo_md_created(
self,
event: TodosView.TodoMdCreated,
) -> None:
"""Forward TODO.md created event."""
self.post_message(self.TodoMdCreated(event.path))
+5
View File
@@ -111,6 +111,11 @@ select = [
] ]
ignore = [ ignore = [
"E501", # line too long (handled by formatter) "E501", # line too long (handled by formatter)
"ARG002", # unused method argument (Textual event handlers require these)
"SIM102", # nested if (sometimes clearer)
"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)
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]