From 8b2d7c57efb96fa8be8cbc95335db5200181c729 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 1 Feb 2026 19:37:58 +0100 Subject: [PATCH] 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 --- .pre-commit-config.yaml | 21 ++ TODO.md | 13 ++ clide/app.py | 66 ++++-- clide/controllers/todos.py | 49 +++-- clide/models/todos.py | 47 +++- clide/services/todo_scanner.py | 153 ++++++++++--- clide/vendor/pyte/__init__.py | 54 +++-- clide/widgets/components/todos_view.py | 284 ++++++++++++++++++++++--- clide/widgets/panels/claude.py | 33 +-- clide/widgets/panels/context.py | 69 ++++-- pyproject.toml | 5 + 11 files changed, 663 insertions(+), 131 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..9dfc9d58 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/TODO.md b/TODO.md index bf7315bf..ccea40be 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,18 @@ # TODO + + Long-term open items for Clide development. ## Core Features diff --git a/clide/app.py b/clide/app.py index 12475999..f5e7fc11 100644 --- a/clide/app.py +++ b/clide/app.py @@ -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() diff --git a/clide/controllers/todos.py b/clide/controllers/todos.py index 4465838c..b3ab4434 100644 --- a/clide/controllers/todos.py +++ b/clide/controllers/todos.py @@ -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 diff --git a/clide/models/todos.py b/clide/models/todos.py index 4ec61391..ac4e0519 100644 --- a/clide/models/todos.py +++ b/clide/models/todos.py @@ -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 diff --git a/clide/services/todo_scanner.py b/clide/services/todo_scanner.py index c41d4c16..00dd8f5a 100644 --- a/clide/services/todo_scanner.py +++ b/clide/services/todo_scanner.py @@ -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 diff --git a/clide/vendor/pyte/__init__.py b/clide/vendor/pyte/__init__.py index 1a12c6d4..73b96fbf 100644 --- a/clide/vendor/pyte/__init__.py +++ b/clide/vendor/pyte/__init__.py @@ -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") diff --git a/clide/widgets/components/todos_view.py b/clide/widgets/components/todos_view.py index db29a1e5..421925c4 100644 --- a/clide/widgets/components/todos_view.py +++ b/clide/widgets/components/todos_view.py @@ -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 + + + +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() diff --git a/clide/widgets/panels/claude.py b/clide/widgets/panels/claude.py index adb5eceb..daab909a 100644 --- a/clide/widgets/panels/claude.py +++ b/clide/widgets/panels/claude.py @@ -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 diff --git a/clide/widgets/panels/context.py b/clide/widgets/panels/context.py index 453d8329..7187e0b5 100644 --- a/clide/widgets/panels/context.py +++ b/clide/widgets/panels/context.py @@ -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)) diff --git a/pyproject.toml b/pyproject.toml index 856f4771..5534c218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]