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:
co-authored by
Claude Opus 4.5
parent
8c80a3ee97
commit
8b2d7c57ef
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Vendored
+33
-21
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user