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
<!--
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.
## Core Features
+53 -13
View File
@@ -19,13 +19,13 @@ from clide.extensions.manager import ExtensionManager
from clide.models.config import ClideSettings
from clide.services.claude_events import (
ClaudeEvent,
FileReadEvent,
FileEditEvent,
FileReadEvent,
FileWriteEvent,
setup_event_parsing,
)
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.themes.registry import get_all_themes, get_theme
from clide.widgets.panels.claude import ClaudePanel
@@ -242,6 +242,7 @@ class ClideApp(App[None]):
# Right context panel
yield ContextPanel(
jira_enabled=self.settings.jira_enabled,
project_path=self.workdir,
)
yield Footer()
@@ -293,6 +294,7 @@ class ClideApp(App[None]):
thread-safe, but we create the Message on the main thread to avoid any
potential Textual threading issues.
"""
def post_file_event():
self.post_message(FileEventMessage(event))
@@ -353,9 +355,7 @@ class ClideApp(App[None]):
pass
# Trigger extension hook
self.extension_manager.trigger_claude_event(
"file_read", {"path": str(event.path)}
)
self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
elif isinstance(event, FileEditEvent):
# Claude edited a file - notify user
@@ -363,9 +363,7 @@ class ClideApp(App[None]):
self.notify(f"Claude edited: {event.path.name}", severity="information")
# Trigger extension hook
self.extension_manager.trigger_claude_event(
"file_edit", {"path": str(event.path)}
)
self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
elif isinstance(event, FileWriteEvent):
# 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")
# Trigger extension hook
self.extension_manager.trigger_claude_event(
"file_write", {"path": str(event.path)}
)
self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
def _apply_user_settings(self) -> None:
"""Apply saved user settings on startup."""
@@ -438,9 +434,9 @@ class ClideApp(App[None]):
async def _refresh_todos(self) -> None:
"""Refresh TODOs."""
todos = await self.todos_controller.scan()
code_todos, project_todos = await self.todos_controller.scan()
context = self.query_one(ContextPanel)
context.update_todos(todos)
context.update_todos(code_todos, project_todos)
async def _refresh_jira(self) -> None:
"""Refresh Jira content."""
@@ -643,6 +639,31 @@ class ClideApp(App[None]):
workspace = self.query_one(WorkspacePanel)
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(
self,
_event: ContextPanel.JiraRefreshRequested,
@@ -749,3 +770,22 @@ class ClideApp(App[None]):
) -> None:
"""Handle workspace close request."""
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 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
@@ -16,8 +22,14 @@ class TodosController:
class TodosUpdated(Message):
"""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.project_items = project_items
self.summary = summary
super().__init__()
@@ -39,9 +51,14 @@ class TodosController:
@property
def items(self) -> list[TodoItem]:
"""Get list of TODO items."""
"""Get list of code TODO 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
def summary(self) -> TodosSummary:
"""Get TODOs summary."""
@@ -49,21 +66,29 @@ class TodosController:
@property
def total_count(self) -> int:
"""Get total TODO count."""
"""Get total code TODO count."""
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.
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.project_items = project_items
self._state.summary = summary
return items, summary
return items, project_items, summary
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
"""Filter TODOs by type.
@@ -128,11 +153,11 @@ class TodosController:
grouped[item.file_path].append(item)
return grouped
async def scan(self) -> list[TodoItem]:
async def scan(self) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
"""Scan for TODOs and return items.
Returns:
List of TODO items
Tuple of (code items, project items)
"""
items, _ = await self.refresh()
return items
items, project_items, _ = await self.refresh()
return items, project_items
+46 -1
View File
@@ -19,6 +19,30 @@ class TodoType(str, Enum):
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):
"""A single TODO comment found in code."""
@@ -61,12 +85,19 @@ class TodosSummary(BaseModel):
fixme_count: int = 0
hack_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
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
@property
def project_total(self) -> int:
"""Total number of project TODOs."""
return self.project_todo_count + self.project_done_count
@property
def display_text(self) -> str:
"""Text for tab badge."""
@@ -79,10 +110,12 @@ class TodosState(BaseModel):
model_config = ConfigDict(strict=True)
items: list[TodoItem] = []
project_items: list[ProjectTodoItem] = [] # Items from TODO.md
summary: TodosSummary = TodosSummary()
filter_type: TodoType | None = None
selected_index: int | None = None
group_by_file: bool = True
show_completed_project_todos: bool = False # Toggle for checked items
def items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODO items for a specific file."""
@@ -91,3 +124,15 @@ class TodosState(BaseModel):
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
"""Get TODO items of a specific type."""
return [item for item in self.items if item.todo_type == todo_type]
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
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
@@ -16,33 +16,66 @@ class TodoScanner:
re.IGNORECASE,
)
# Pattern to match markdown checkboxes: - [ ] or - [x]
CHECKBOX_PATTERN = re.compile(r"^(\s*)-\s*\[([ xX])\]\s*(.+)$")
# File extensions to scan
SCAN_EXTENSIONS = {
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
".go", ".rs", ".rb", ".php", ".css", ".scss", ".html", ".vue",
".svelte", ".md", ".sh", ".bash", ".yaml", ".yml", ".toml",
".py",
".js",
".ts",
".jsx",
".tsx",
".java",
".c",
".cpp",
".h",
".go",
".rs",
".rb",
".php",
".css",
".scss",
".html",
".vue",
".svelte",
".md",
".sh",
".bash",
".yaml",
".yml",
".toml",
}
def __init__(self, project_path: Path) -> None:
self.project_path = project_path
self._process = ProcessService(cwd=project_path)
async def scan(self) -> tuple[list[TodoItem], TodosSummary]:
"""Scan project for TODO comments.
async def scan(
self,
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
"""Scan project for TODO comments and TODO.md items.
Returns:
Tuple of (todo items, summary)
Tuple of (code todo items, project todo items, summary)
"""
items: list[TodoItem] = []
# Use ripgrep if available for speed
result = await self._process.run(
"rg", "--line-number", "--no-heading",
"-e", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
"--type-add", "code:*.py",
"--type-add", "code:*.js",
"--type-add", "code:*.ts",
"--type", "code",
"rg",
"--line-number",
"--no-heading",
"-e",
r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
"--type-add",
"code:*.py",
"--type-add",
"code:*.js",
"--type-add",
"code:*.ts",
"--type",
"code",
".",
)
@@ -52,20 +85,76 @@ class TodoScanner:
# Fallback to Python-based scanning
items = await self._scan_with_python()
# Parse TODO.md if it exists
project_items = self._parse_todo_md()
# Create summary
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
other_count = len(items) - todo_count - fixme_count - hack_count
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(
todo_count=todo_count,
fixme_count=fixme_count,
hack_count=hack_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]:
"""Parse ripgrep output into TodoItems."""
@@ -99,14 +188,16 @@ class TodoScanner:
except ValueError:
todo_type = TodoType.TODO
items.append(TodoItem(
file_path=file_path,
line=line_num,
column=content.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=content.strip(),
))
items.append(
TodoItem(
file_path=file_path,
line=line_num,
column=content.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=content.strip(),
)
)
return items
@@ -137,14 +228,16 @@ class TodoScanner:
except ValueError:
todo_type = TodoType.TODO
items.append(TodoItem(
file_path=file_path.relative_to(self.project_path),
line=line_num,
column=line.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=line.strip(),
))
items.append(
TodoItem(
file_path=file_path.relative_to(self.project_path),
line=line_num,
column=line.find(todo_type_str) + 1,
todo_type=todo_type,
text=todo_text,
context_line=line.strip(),
)
)
except (OSError, UnicodeDecodeError):
continue
+33 -21
View File
@@ -1,37 +1,39 @@
"""
pyte
~~~~
pyte
~~~~
`pyte` implements a mix of VT100, VT220 and VT520 specification,
and aims to support most of the `TERM=linux` functionality.
`pyte` implements a mix of VT100, VT220 and VT520 specification,
and aims to support most of the `TERM=linux` functionality.
: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.
"""
__all__ = (
"Screen", "DiffScreen", "HistoryScreen", "DebugScreen",
"Stream", "ByteStream",
"Screen",
"DiffScreen",
"HistoryScreen",
"DebugScreen",
"Stream",
"ByteStream",
# Clide additions
"set_debug_logger", "set_event_callback",
"set_debug_logger",
"get_debug_logger",
"set_event_callback",
)
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
from . import modes
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):
@@ -44,8 +46,18 @@ def set_debug_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__:
def dis(chars: Union[bytes, str]) -> None:
def dis(chars: bytes | str) -> None:
"""A :func:`dis.dis` for terminals."""
if isinstance(chars, str):
chars = chars.encode("utf-8")
+258 -26
View File
@@ -1,20 +1,54 @@
"""TODOs view component."""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
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):
"""A single TODO item."""
"""A single code TODO item."""
def __init__(self, item: TodoItem) -> None:
super().__init__()
self.item = item
self.is_project_item = False
def compose(self) -> ComposeResult:
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):
"""View for TODO/FIXME comments."""
"""View for TODO/FIXME comments and project TODOs."""
DEFAULT_CSS = """
TodosView {
@@ -42,8 +95,31 @@ class TodosView(Vertical):
padding: 0 1;
}
TodosView .section-header {
height: 1;
background: $panel;
padding: 0 1;
color: $text-muted;
}
TodosView ListView {
height: auto;
max-height: 50%;
}
TodosView #code-todos-list {
height: 1fr;
max-height: none;
}
TodosView Collapsible {
padding: 0;
border: none;
}
TodosView CollapsibleTitle {
background: $panel;
padding: 0 1;
}
TodosView .todo { color: $primary; }
@@ -52,59 +128,215 @@ class TodosView(Vertical):
TodosView .xxx { color: $error; }
TodosView .note { color: $secondary; }
TodosView .bug { color: $error; }
TodosView .project { color: $accent; }
TodosView .empty-message {
padding: 2;
text-align: center;
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):
"""Emitted when a TODO is clicked."""
"""Emitted when a code TODO is clicked."""
def __init__(self, item: TodoItem) -> None:
self.item = item
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)
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:
count = len(self._items)
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header")
total = len(self._items) + len(self._project_items)
yield Static(f"TODOs ({total})", classes="todos-header", id="todos-header")
if self._items:
yield ListView(
*[TodoListItem(item) for item in self._items],
id="todos-list",
# Create TODO.md section (shown when file doesn't exist)
with Vertical(classes="create-todo-section", id="create-todo-section"):
yield Static(
"No TODO.md found in project",
classes="create-todo-message",
)
else:
yield Static("No TODOs found ✓", classes="empty-message")
yield Button("Create TODO.md", id="create-todo-btn", variant="primary")
def update_items(self, items: list[TodoItem]) -> None:
"""Update the TODOs list."""
self._items = items
# Project TODOs section (collapsible)
with Collapsible(title="Project TODOs", id="project-todos-section"):
yield ListView(id="project-todos-list")
# Update header
header = self.query_one("#todos-header", Static)
header.update(f"TODOs ({len(items)})")
# Code TODOs section
yield Static("Code TODOs", classes="section-header", id="code-todos-header")
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:
todos_list = self.query_one("#todos-list", ListView)
todos_list.clear()
for item in items:
todos_list.append(TodoListItem(item))
create_section = self.query_one("#create-todo-section", Vertical)
project_section = self.query_one("#project-todos-section", Collapsible)
project_list = self.query_one("#project-todos-list", ListView)
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:
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]:
"""Filter by TODO type."""
"""Filter code TODOs by type."""
return [i for i in self._items if i.todo_type == todo_type]
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle item selection."""
if isinstance(event.item, TodoListItem):
self.post_message(self.TodoClicked(event.item.item))
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.widget import Widget
from clide.services.settings_service import get_settings_service
# Use vendored pyte with diagnostic logging support
from clide.vendor import pyte
from clide.services.settings_service import get_settings_service
if TYPE_CHECKING:
from textual.app import ComposeResult
@@ -46,6 +47,7 @@ def _setup_terminal_debug_logging() -> None:
def debug_logger(message: str) -> None:
"""Log debug message with timestamp."""
import datetime
timestamp = datetime.datetime.now().isoformat()
log_file.write(f"[{timestamp}] {message}\n")
log_file.flush()
@@ -153,6 +155,7 @@ class TerminalDisplay(Widget, can_focus=True):
# Send SIGWINCH to notify the child process of resize
if self._pid is not None:
import signal
try:
os.kill(self._pid, signal.SIGWINCH)
except OSError:
@@ -231,11 +234,11 @@ class TerminalDisplay(Widget, can_focus=True):
# Kitty keyboard protocol, bracketed paste mode queries, etc.
_UNSUPPORTED_ESCAPES = re.compile(
r"\x1b\[[\=\>\<][0-9;]*[a-zA-Z]" # Kitty keyboard protocol (=, >, or < prefix)
r"|\x1b\[\?[0-9;]*u" # Kitty keyboard query
r"|\x1b\[\?[0-9;]*c" # Device attributes query
r"|\x1b\[>[0-9;]*c" # Secondary device attributes
r"|\x1b\[\?[0-9;]*u" # Kitty keyboard query
r"|\x1b\[\?[0-9;]*c" # Device attributes query
r"|\x1b\[>[0-9;]*c" # Secondary device attributes
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:
@@ -417,7 +420,11 @@ class TerminalDisplay(Widget, can_focus=True):
screen_rows = self._rows
# 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:
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)
# Debug logging for render pipeline
debug_logger = getattr(pyte, '_debug_logger', None)
debug_logger = pyte.get_debug_logger()
log_this_line = False
for x in range(cols_to_render):
@@ -450,16 +457,15 @@ class TerminalDisplay(Widget, can_focus=True):
if 0x2500 <= code <= 0x257F:
log_this_line = True
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
if len(char_data) == 1:
code = ord(char_data)
# Control characters (except space)
if code < 32 and code != 0:
char_data = " "
# DEL and C1 control characters
elif 127 <= code <= 159:
if code < 32 and code != 0 or 127 <= code <= 159:
char_data = " "
# Braille patterns (U+2800-U+28FF) - used for spinners
# 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
if log_this_line and debug_logger:
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:
debug_logger(f"RENDER y={y} seg[{i}]: '{seg_text}' (repr: {repr(seg_text)})")
@@ -665,6 +671,7 @@ class ClaudePanel(Vertical):
class ClaudeStarted(Message):
"""Emitted when Claude Code process starts."""
pass
# 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.containers import Horizontal, Vertical
@@ -8,14 +9,14 @@ from textual.reactive import reactive
from textual.widgets import Static, TabbedContent, TabPane
from clide.models.problems import Problem
from clide.models.todos import TodoItem
from clide.models.todos import ProjectTodoItem, TodoItem
from clide.widgets.components.jira_view import JiraView
from clide.widgets.components.problems_view import ProblemsView
from clide.widgets.components.todos_view import TodosView
class ContextPanel(Vertical):
"""Right context panel with Problems, TODOs, and Jira integration."""
"""Right context panel with Jira, TODOs, and Problems tabs."""
DEFAULT_CSS = """
ContextPanel {
@@ -61,16 +62,31 @@ class ContextPanel(Vertical):
super().__init__()
class TodoClicked(Message):
"""Emitted when a TODO is clicked."""
"""Emitted when a code TODO is clicked."""
def __init__(self, item: TodoItem) -> None:
self.item = item
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):
"""Emitted when Jira refresh is requested."""
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
problem_count: reactive[int] = reactive(0)
todo_count: reactive[int] = reactive(0)
@@ -79,20 +95,22 @@ class ContextPanel(Vertical):
def __init__(
self,
jira_enabled: bool = True,
project_path: Path | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._jira_enabled = jira_enabled
self._project_path = project_path or Path.cwd()
self.id = "panel-context"
def compose(self) -> ComposeResult:
with TabbedContent(id="context-tabs"):
with TabPane("Problems", id="context-problems"):
yield ProblemsView(id="problems-view")
with TabPane("TODOs", id="context-todos"):
yield TodosView(id="todos-view")
with TabPane("Jira", id="context-jira"):
yield JiraView(enabled=self._jira_enabled, id="jira-view")
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
with Horizontal(classes="context-tab-bar"):
yield Static("", id="tab-counts")
@@ -120,15 +138,15 @@ class ContextPanel(Vertical):
problem_style = "error-count" if self.problem_count > 0 else "success-count"
todo_style = "warning-count" if self.todo_count > 0 else "success-count"
# Build count display
# Build count display (order matches tab order: TODOs, Problems)
parts = []
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
if self.problem_count > 0:
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
else:
parts.append(f"[{problem_style}]✓ 0[/]")
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
counts.update("".join(parts))
except Exception:
pass
@@ -142,12 +160,19 @@ class ContextPanel(Vertical):
except Exception:
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."""
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:
view = self.query_one("#todos-view", TodosView)
view.update_items(items)
view.update_items(items, project_items)
except Exception:
pass
@@ -201,12 +226,26 @@ class ContextPanel(Vertical):
self.post_message(self.ProblemClicked(event.problem))
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))
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(
self,
event: JiraView.RefreshRequested,
) -> None:
"""Forward Jira refresh request."""
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 = [
"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]