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
|
||||
|
||||
<!--
|
||||
Clide Integration: This file is parsed by Clide's TODO panel.
|
||||
|
||||
Format:
|
||||
- Use ## for sections and ### for subsections
|
||||
- Use markdown checkboxes: - [ ] for open items, - [x] for completed
|
||||
- Items appear in the TODOs panel grouped by section
|
||||
- Click an item in Clide to jump to this file at that line
|
||||
|
||||
For AI agents: Add new items under the appropriate section using the
|
||||
checkbox format. Mark items as done with [x] when completed.
|
||||
-->
|
||||
|
||||
Long-term open items for Clide development.
|
||||
|
||||
## Core Features
|
||||
|
||||
+53
-13
@@ -19,13 +19,13 @@ from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
from clide.services.claude_events import (
|
||||
ClaudeEvent,
|
||||
FileReadEvent,
|
||||
FileEditEvent,
|
||||
FileReadEvent,
|
||||
FileWriteEvent,
|
||||
setup_event_parsing,
|
||||
)
|
||||
from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching
|
||||
from clide.services.settings_service import SettingsService, get_settings_service
|
||||
from clide.services.settings_service import get_settings_service
|
||||
from clide.services.syntax_service import register_languages
|
||||
from clide.themes.registry import get_all_themes, get_theme
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
@@ -242,6 +242,7 @@ class ClideApp(App[None]):
|
||||
# Right context panel
|
||||
yield ContextPanel(
|
||||
jira_enabled=self.settings.jira_enabled,
|
||||
project_path=self.workdir,
|
||||
)
|
||||
|
||||
yield Footer()
|
||||
@@ -293,6 +294,7 @@ class ClideApp(App[None]):
|
||||
thread-safe, but we create the Message on the main thread to avoid any
|
||||
potential Textual threading issues.
|
||||
"""
|
||||
|
||||
def post_file_event():
|
||||
self.post_message(FileEventMessage(event))
|
||||
|
||||
@@ -353,9 +355,7 @@ class ClideApp(App[None]):
|
||||
pass
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event(
|
||||
"file_read", {"path": str(event.path)}
|
||||
)
|
||||
self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
|
||||
|
||||
elif isinstance(event, FileEditEvent):
|
||||
# Claude edited a file - notify user
|
||||
@@ -363,9 +363,7 @@ class ClideApp(App[None]):
|
||||
self.notify(f"Claude edited: {event.path.name}", severity="information")
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event(
|
||||
"file_edit", {"path": str(event.path)}
|
||||
)
|
||||
self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
|
||||
|
||||
elif isinstance(event, FileWriteEvent):
|
||||
# Claude created/wrote a file - notify user
|
||||
@@ -374,9 +372,7 @@ class ClideApp(App[None]):
|
||||
self.notify(f"Claude wrote: {event.path.name}", severity="information")
|
||||
|
||||
# Trigger extension hook
|
||||
self.extension_manager.trigger_claude_event(
|
||||
"file_write", {"path": str(event.path)}
|
||||
)
|
||||
self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
|
||||
|
||||
def _apply_user_settings(self) -> None:
|
||||
"""Apply saved user settings on startup."""
|
||||
@@ -438,9 +434,9 @@ class ClideApp(App[None]):
|
||||
|
||||
async def _refresh_todos(self) -> None:
|
||||
"""Refresh TODOs."""
|
||||
todos = await self.todos_controller.scan()
|
||||
code_todos, project_todos = await self.todos_controller.scan()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_todos(todos)
|
||||
context.update_todos(code_todos, project_todos)
|
||||
|
||||
async def _refresh_jira(self) -> None:
|
||||
"""Refresh Jira content."""
|
||||
@@ -643,6 +639,31 @@ class ClideApp(App[None]):
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(item.file_path, line=item.line)
|
||||
|
||||
async def on_context_panel_project_todo_clicked(
|
||||
self,
|
||||
event: ContextPanel.ProjectTodoClicked,
|
||||
) -> None:
|
||||
"""Handle project TODO click - open TODO.md at line."""
|
||||
item = event.item
|
||||
todo_path = self.workdir / "TODO.md"
|
||||
if todo_path.exists():
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(todo_path, line=item.line)
|
||||
|
||||
async def on_context_panel_todo_md_created(
|
||||
self,
|
||||
event: ContextPanel.TodoMdCreated,
|
||||
) -> None:
|
||||
"""Handle TODO.md creation - refresh TODOs and open file."""
|
||||
# Refresh TODOs to pick up the new file
|
||||
await self._refresh_todos()
|
||||
# Open the new file in editor
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.open_file(event.path)
|
||||
self.notify("Created TODO.md")
|
||||
|
||||
async def on_context_panel_jira_refresh_requested(
|
||||
self,
|
||||
_event: ContextPanel.JiraRefreshRequested,
|
||||
@@ -749,3 +770,22 @@ class ClideApp(App[None]):
|
||||
) -> None:
|
||||
"""Handle workspace close request."""
|
||||
self.workspace_visible = False
|
||||
|
||||
def on_sidebar_panel_claude_command_requested(
|
||||
self,
|
||||
event: SidebarPanel.ClaudeCommandRequested,
|
||||
) -> None:
|
||||
"""Handle Claude command request from git panel.
|
||||
|
||||
Sends skill commands (e.g., /commit) to Claude terminal.
|
||||
Ensures the git-workflow skill is installed before sending.
|
||||
"""
|
||||
# Ensure skill is available
|
||||
self.git_controller._ensure_git_skill()
|
||||
|
||||
# Send command to Claude terminal
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.send_input(event.command)
|
||||
|
||||
# Focus Claude panel so user can see the response
|
||||
self.action_focus_claude()
|
||||
|
||||
+37
-12
@@ -5,7 +5,13 @@ from pathlib import Path
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
|
||||
from clide.models.todos import (
|
||||
ProjectTodoItem,
|
||||
TodoItem,
|
||||
TodosState,
|
||||
TodosSummary,
|
||||
TodoType,
|
||||
)
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
|
||||
@@ -16,8 +22,14 @@ class TodosController:
|
||||
class TodosUpdated(Message):
|
||||
"""Emitted when TODOs list is updated."""
|
||||
|
||||
def __init__(self, items: list[TodoItem], summary: TodosSummary) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
items: list[TodoItem],
|
||||
project_items: list[ProjectTodoItem],
|
||||
summary: TodosSummary,
|
||||
) -> None:
|
||||
self.items = items
|
||||
self.project_items = project_items
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
@@ -39,9 +51,14 @@ class TodosController:
|
||||
|
||||
@property
|
||||
def items(self) -> list[TodoItem]:
|
||||
"""Get list of TODO items."""
|
||||
"""Get list of code TODO items."""
|
||||
return self._state.items
|
||||
|
||||
@property
|
||||
def project_items(self) -> list[ProjectTodoItem]:
|
||||
"""Get list of project TODO items from TODO.md."""
|
||||
return self._state.project_items
|
||||
|
||||
@property
|
||||
def summary(self) -> TodosSummary:
|
||||
"""Get TODOs summary."""
|
||||
@@ -49,21 +66,29 @@ class TodosController:
|
||||
|
||||
@property
|
||||
def total_count(self) -> int:
|
||||
"""Get total TODO count."""
|
||||
"""Get total code TODO count."""
|
||||
return self._state.summary.total
|
||||
|
||||
async def refresh(self) -> tuple[list[TodoItem], TodosSummary]:
|
||||
@property
|
||||
def project_count(self) -> int:
|
||||
"""Get total project TODO count."""
|
||||
return self._state.summary.project_total
|
||||
|
||||
async def refresh(
|
||||
self,
|
||||
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
|
||||
"""Refresh TODOs from project.
|
||||
|
||||
Returns:
|
||||
Tuple of (items, summary)
|
||||
Tuple of (code items, project items, summary)
|
||||
"""
|
||||
items, summary = await self._scanner.scan()
|
||||
items, project_items, summary = await self._scanner.scan()
|
||||
|
||||
self._state.items = items
|
||||
self._state.project_items = project_items
|
||||
self._state.summary = summary
|
||||
|
||||
return items, summary
|
||||
return items, project_items, summary
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
|
||||
"""Filter TODOs by type.
|
||||
@@ -128,11 +153,11 @@ class TodosController:
|
||||
grouped[item.file_path].append(item)
|
||||
return grouped
|
||||
|
||||
async def scan(self) -> list[TodoItem]:
|
||||
async def scan(self) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
|
||||
"""Scan for TODOs and return items.
|
||||
|
||||
Returns:
|
||||
List of TODO items
|
||||
Tuple of (code items, project items)
|
||||
"""
|
||||
items, _ = await self.refresh()
|
||||
return items
|
||||
items, project_items, _ = await self.refresh()
|
||||
return items, project_items
|
||||
|
||||
+46
-1
@@ -19,6 +19,30 @@ class TodoType(str, Enum):
|
||||
REVIEW = "REVIEW"
|
||||
|
||||
|
||||
class ProjectTodoItem(BaseModel):
|
||||
"""A TODO item from TODO.md file."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
text: str
|
||||
section: str # Top-level section (## heading)
|
||||
subsection: str | None = None # Optional subsection (### heading)
|
||||
line: int # Line number in TODO.md
|
||||
checked: bool = False # Whether the checkbox is checked
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
"""Get full category path."""
|
||||
if self.subsection:
|
||||
return f"{self.section} › {self.subsection}"
|
||||
return self.section
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Icon for display."""
|
||||
return "☑" if self.checked else "☐"
|
||||
|
||||
|
||||
class TodoItem(BaseModel):
|
||||
"""A single TODO comment found in code."""
|
||||
|
||||
@@ -61,12 +85,19 @@ class TodosSummary(BaseModel):
|
||||
fixme_count: int = 0
|
||||
hack_count: int = 0
|
||||
other_count: int = 0
|
||||
project_todo_count: int = 0 # Count from TODO.md
|
||||
project_done_count: int = 0 # Checked items in TODO.md
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of TODOs."""
|
||||
"""Total number of code TODOs."""
|
||||
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
|
||||
|
||||
@property
|
||||
def project_total(self) -> int:
|
||||
"""Total number of project TODOs."""
|
||||
return self.project_todo_count + self.project_done_count
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
@@ -79,10 +110,12 @@ class TodosState(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
items: list[TodoItem] = []
|
||||
project_items: list[ProjectTodoItem] = [] # Items from TODO.md
|
||||
summary: TodosSummary = TodosSummary()
|
||||
filter_type: TodoType | None = None
|
||||
selected_index: int | None = None
|
||||
group_by_file: bool = True
|
||||
show_completed_project_todos: bool = False # Toggle for checked items
|
||||
|
||||
def items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODO items for a specific file."""
|
||||
@@ -91,3 +124,15 @@ class TodosState(BaseModel):
|
||||
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Get TODO items of a specific type."""
|
||||
return [item for item in self.items if item.todo_type == todo_type]
|
||||
|
||||
def project_items_by_section(self, section: str) -> list[ProjectTodoItem]:
|
||||
"""Get project TODO items for a specific section."""
|
||||
return [item for item in self.project_items if item.section == section]
|
||||
|
||||
def get_project_sections(self) -> list[str]:
|
||||
"""Get unique sections from project TODOs."""
|
||||
sections: list[str] = []
|
||||
for item in self.project_items:
|
||||
if item.section not in sections:
|
||||
sections.append(item.section)
|
||||
return sections
|
||||
|
||||
+123
-30
@@ -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
|
||||
|
||||
|
||||
Vendored
+33
-21
@@ -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")
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
"""TODOs view component."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import ListItem, ListView, Static
|
||||
from textual.widgets import Button, Collapsible, ListItem, ListView, Static
|
||||
|
||||
from clide.models.todos import TodoItem, TodoType
|
||||
from clide.models.todos import ProjectTodoItem, TodoItem, TodoType
|
||||
|
||||
# Template for new TODO.md files
|
||||
TODO_MD_TEMPLATE = """# TODO
|
||||
|
||||
<!--
|
||||
Clide Integration: This file is parsed by Clide's TODO panel.
|
||||
|
||||
Format:
|
||||
- Use ## for sections and ### for subsections
|
||||
- Use markdown checkboxes: - [ ] for open items, - [x] for completed
|
||||
- Items appear in the TODOs panel grouped by section
|
||||
- Click an item in Clide to jump to this file at that line
|
||||
|
||||
For AI agents: Add new items under the appropriate section using the
|
||||
checkbox format. Mark items as done with [x] when completed.
|
||||
-->
|
||||
|
||||
Project TODO items.
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] Add your first feature here
|
||||
- [ ] Another feature to implement
|
||||
|
||||
## Bugs
|
||||
|
||||
- [ ] Bug to fix
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Documentation to write
|
||||
"""
|
||||
|
||||
|
||||
class TodoListItem(ListItem):
|
||||
"""A single TODO item."""
|
||||
"""A single code TODO item."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
super().__init__()
|
||||
self.item = item
|
||||
self.is_project_item = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.type_icon
|
||||
@@ -28,8 +62,27 @@ class TodoListItem(ListItem):
|
||||
)
|
||||
|
||||
|
||||
class ProjectTodoListItem(ListItem):
|
||||
"""A single project TODO item from TODO.md."""
|
||||
|
||||
def __init__(self, item: ProjectTodoItem) -> None:
|
||||
super().__init__()
|
||||
self.item = item
|
||||
self.is_project_item = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.icon
|
||||
checked_style = "dim strike" if self.item.checked else ""
|
||||
category = f"[dim]{self.item.category}[/] " if self.item.subsection else ""
|
||||
|
||||
yield Static(
|
||||
f"[project]{icon}[/] {category}" f"[{checked_style}]{self.item.text}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class TodosView(Vertical):
|
||||
"""View for TODO/FIXME comments."""
|
||||
"""View for TODO/FIXME comments and project TODOs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TodosView {
|
||||
@@ -42,8 +95,31 @@ class TodosView(Vertical):
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TodosView .section-header {
|
||||
height: 1;
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
TodosView ListView {
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
TodosView #code-todos-list {
|
||||
height: 1fr;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
TodosView Collapsible {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
TodosView CollapsibleTitle {
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TodosView .todo { color: $primary; }
|
||||
@@ -52,59 +128,215 @@ class TodosView(Vertical):
|
||||
TodosView .xxx { color: $error; }
|
||||
TodosView .note { color: $secondary; }
|
||||
TodosView .bug { color: $error; }
|
||||
TodosView .project { color: $accent; }
|
||||
|
||||
TodosView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
TodosView .create-todo-section {
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
TodosView .create-todo-message {
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
TodosView #create-todo-btn {
|
||||
width: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
class TodoClicked(Message):
|
||||
"""Emitted when a TODO is clicked."""
|
||||
"""Emitted when a code TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, items: list[TodoItem] | None = None, **kwargs) -> None:
|
||||
class ProjectTodoClicked(Message):
|
||||
"""Emitted when a project TODO is clicked."""
|
||||
|
||||
def __init__(self, item: ProjectTodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class CreateTodoMdRequested(Message):
|
||||
"""Emitted when user wants to create a TODO.md file."""
|
||||
|
||||
pass
|
||||
|
||||
class TodoMdCreated(Message):
|
||||
"""Emitted when TODO.md has been created."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list[TodoItem] | None = None,
|
||||
project_items: list[ProjectTodoItem] | None = None,
|
||||
project_path: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._items = items or []
|
||||
self._project_items = project_items or []
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._has_todo_md = (self._project_path / "TODO.md").exists()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
count = len(self._items)
|
||||
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header")
|
||||
total = len(self._items) + len(self._project_items)
|
||||
yield Static(f"TODOs ({total})", classes="todos-header", id="todos-header")
|
||||
|
||||
if self._items:
|
||||
yield ListView(
|
||||
*[TodoListItem(item) for item in self._items],
|
||||
id="todos-list",
|
||||
# Create TODO.md section (shown when file doesn't exist)
|
||||
with Vertical(classes="create-todo-section", id="create-todo-section"):
|
||||
yield Static(
|
||||
"No TODO.md found in project",
|
||||
classes="create-todo-message",
|
||||
)
|
||||
else:
|
||||
yield Static("No TODOs found ✓", classes="empty-message")
|
||||
yield Button("Create TODO.md", id="create-todo-btn", variant="primary")
|
||||
|
||||
def update_items(self, items: list[TodoItem]) -> None:
|
||||
"""Update the TODOs list."""
|
||||
self._items = items
|
||||
# Project TODOs section (collapsible)
|
||||
with Collapsible(title="Project TODOs", id="project-todos-section"):
|
||||
yield ListView(id="project-todos-list")
|
||||
|
||||
# Update header
|
||||
header = self.query_one("#todos-header", Static)
|
||||
header.update(f"TODOs ({len(items)})")
|
||||
# Code TODOs section
|
||||
yield Static("Code TODOs", classes="section-header", id="code-todos-header")
|
||||
yield ListView(id="code-todos-list")
|
||||
|
||||
# Update list
|
||||
yield Static("No TODOs found", classes="empty-message", id="todos-empty")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the lists with items."""
|
||||
self._refresh_lists()
|
||||
|
||||
def _refresh_lists(self) -> None:
|
||||
"""Refresh both list views with current items."""
|
||||
try:
|
||||
todos_list = self.query_one("#todos-list", ListView)
|
||||
todos_list.clear()
|
||||
for item in items:
|
||||
todos_list.append(TodoListItem(item))
|
||||
create_section = self.query_one("#create-todo-section", Vertical)
|
||||
project_section = self.query_one("#project-todos-section", Collapsible)
|
||||
project_list = self.query_one("#project-todos-list", ListView)
|
||||
code_header = self.query_one("#code-todos-header", Static)
|
||||
code_list = self.query_one("#code-todos-list", ListView)
|
||||
empty_msg = self.query_one("#todos-empty", Static)
|
||||
|
||||
# Clear both lists
|
||||
project_list.clear()
|
||||
code_list.clear()
|
||||
|
||||
# Check if TODO.md exists
|
||||
self._has_todo_md = (self._project_path / "TODO.md").exists()
|
||||
|
||||
has_items = False
|
||||
|
||||
# Show create button if no TODO.md and no project items
|
||||
if not self._has_todo_md and not self._project_items:
|
||||
create_section.display = True
|
||||
project_section.display = False
|
||||
else:
|
||||
create_section.display = False
|
||||
|
||||
# Populate project TODOs
|
||||
if self._project_items:
|
||||
has_items = True
|
||||
# Group by section
|
||||
sections: dict[str, list[ProjectTodoItem]] = {}
|
||||
for item in self._project_items:
|
||||
if item.section not in sections:
|
||||
sections[item.section] = []
|
||||
sections[item.section].append(item)
|
||||
|
||||
# Add items (flat list, grouped display can be added later)
|
||||
for item in self._project_items:
|
||||
if not item.checked: # Only show unchecked by default
|
||||
project_list.append(ProjectTodoListItem(item))
|
||||
|
||||
unchecked = sum(1 for i in self._project_items if not i.checked)
|
||||
project_section.title = f"Project TODOs ({unchecked})"
|
||||
project_section.display = True
|
||||
else:
|
||||
project_section.display = False
|
||||
|
||||
# Populate code TODOs
|
||||
if self._items:
|
||||
has_items = True
|
||||
for item in self._items:
|
||||
code_list.append(TodoListItem(item))
|
||||
code_header.update(f"Code TODOs ({len(self._items)})")
|
||||
code_header.display = True
|
||||
code_list.display = True
|
||||
else:
|
||||
code_header.display = False
|
||||
code_list.display = False
|
||||
|
||||
# Show empty message only if no items at all and TODO.md exists
|
||||
empty_msg.display = not has_items and self._has_todo_md
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_items(
|
||||
self,
|
||||
items: list[TodoItem],
|
||||
project_items: list[ProjectTodoItem] | None = None,
|
||||
) -> None:
|
||||
"""Update both TODO lists."""
|
||||
self._items = items
|
||||
self._project_items = project_items or []
|
||||
|
||||
# Update header with total count
|
||||
try:
|
||||
unchecked_project = sum(1 for p in self._project_items if not p.checked)
|
||||
total = len(items) + unchecked_project
|
||||
header = self.query_one("#todos-header", Static)
|
||||
header.update(f"TODOs ({total})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Refresh the lists
|
||||
self._refresh_lists()
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Filter by TODO type."""
|
||||
"""Filter code TODOs by type."""
|
||||
return [i for i in self._items if i.todo_type == todo_type]
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, TodoListItem):
|
||||
self.post_message(self.TodoClicked(event.item.item))
|
||||
elif isinstance(event.item, ProjectTodoListItem):
|
||||
self.post_message(self.ProjectTodoClicked(event.item.item))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "create-todo-btn":
|
||||
self._create_todo_md()
|
||||
|
||||
def _create_todo_md(self) -> None:
|
||||
"""Create a new TODO.md file with template."""
|
||||
todo_path = self._project_path / "TODO.md"
|
||||
if todo_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
todo_path.write_text(TODO_MD_TEMPLATE)
|
||||
self._has_todo_md = True
|
||||
self.post_message(self.TodoMdCreated(todo_path))
|
||||
# Notify app to refresh TODOs
|
||||
self.post_message(self.CreateTodoMdRequested())
|
||||
except OSError as e:
|
||||
self.app.notify(f"Failed to create TODO.md: {e}", severity="error")
|
||||
|
||||
def set_project_path(self, path: Path) -> None:
|
||||
"""Update the project path."""
|
||||
self._project_path = path
|
||||
self._has_todo_md = (path / "TODO.md").exists()
|
||||
self._refresh_lists()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user