This is page 1 of 2. Use http://codebase.md/mryanmyn/task-manager-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── app │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── api.py │ │ └── cli.py │ ├── core │ │ ├── __init__.py │ │ ├── plan_manager.py │ │ └── task_manager.py │ └── ui │ ├── __init__.py │ ├── input_handler.py │ ├── terminal_ui.py │ └── ui_components.py ├── img.png ├── main.py ├── mcp_guidelines │ ├── __init__.py │ └── llms_full.txt ├── mcp_server_fixed.py ├── MCP-README.md ├── pyproject.toml ├── README.md ├── setup.py └── tests ├── __init__.py └── test_mcp_server.py ``` # Files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Terminal Task Tracker 2 | 3 | A terminal-based task tracking application with a three-pane layout for managing tasks and project plans. 4 | 5 | # Image 6 | 7 |  8 | 9 | ## Features 10 | 11 | - Three-pane terminal UI: 12 | - Task list (top left) 13 | - Task details (top right) 14 | - Project plan (bottom, full width) 15 | - Task management: 16 | - Create, view, edit, and delete tasks 17 | - Set priorities and status 18 | - Add detailed descriptions 19 | - Project plan management: 20 | - Define high-level project steps 21 | - Track step completion 22 | - Reorder steps 23 | - Complete API for programmatic access 24 | - Command-line interface for scripting 25 | - Data persistence 26 | 27 | ## Installation 28 | 29 | ```bash 30 | # Clone the repository 31 | git clone https://github.com/yourusername/terminal-task-tracker.git 32 | cd terminal-task-tracker 33 | 34 | # Install dependencies 35 | pip install -e . 36 | ``` 37 | 38 | ## Usage 39 | 40 | ### Terminal UI 41 | 42 | To start the terminal UI: 43 | 44 | ```bash 45 | python -m main.py 46 | ``` 47 | 48 | Key bindings: 49 | - `Tab`: Cycle between windows 50 | - `Up/Down`: Navigate lists 51 | - `Enter`: Select task (in task list) 52 | - `n`: New item (in task list or plan) 53 | - `e`: Edit item 54 | - `d`: Delete item 55 | - `Space`: Toggle completion (in plan) 56 | - `Esc`: Exit 57 | 58 | ### Command-line Interface 59 | 60 | The CLI provides access to all functionality: 61 | 62 | ```bash 63 | # List all tasks 64 | python -m app.api.cli task list 65 | 66 | # Add a new task 67 | python -m app.api.cli task add "Implement feature X" --description "Details about feature X" --priority 2 68 | 69 | # Mark a plan step as completed 70 | python -m app.api.cli plan toggle STEP_ID 71 | 72 | # Export data to JSON 73 | python -m app.api.cli export data.json 74 | ``` 75 | 76 | ### API Usage 77 | 78 | ```python 79 | from app.core.task_manager import TaskManager 80 | from app.core.plan_manager import PlanManager 81 | from app.api.api import TaskTrackerAPI 82 | 83 | # Initialize managers 84 | task_manager = TaskManager("tasks.json") 85 | plan_manager = PlanManager("plan.json") 86 | 87 | # Create API 88 | api = TaskTrackerAPI(task_manager, plan_manager) 89 | 90 | # Add a task 91 | task = api.add_task("Implement feature X", "Details about feature X", priority=2) 92 | 93 | # Add a plan step 94 | step = api.add_plan_step("Design architecture for shared operations module") 95 | 96 | # Mark step as completed 97 | api.toggle_plan_step(step["id"]) 98 | 99 | # Save data 100 | api.save_all() 101 | ``` 102 | 103 | ## Project Structure 104 | 105 | ``` 106 | terminal-task-tracker/ 107 | ├── app/ 108 | │ ├── __init__.py 109 | │ ├── core/ # Business logic 110 | │ │ ├── __init__.py 111 | │ │ ├── task_manager.py 112 | │ │ └── plan_manager.py 113 | │ ├── ui/ # Terminal UI 114 | │ │ ├── __init__.py 115 | │ │ ├── terminal_ui.py 116 | │ │ ├── ui_components.py 117 | │ │ └── input_handler.py 118 | │ └── api/ # API and CLI 119 | │ ├── __init__.py 120 | │ ├── api.py 121 | │ └── cli.py 122 | ├── main.py # Main application entry point 123 | └── README.md 124 | ``` 125 | 126 | ## Data Storage 127 | 128 | By default, data is stored in the `~/.tasktracker` directory: 129 | - `tasks.json`: Tasks data 130 | - `plan.json`: Project plan data 131 | - `notes.json`: Notes data 132 | 133 | ## License 134 | 135 | MIT ``` -------------------------------------------------------------------------------- /mcp_guidelines/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # API and CLI module ``` -------------------------------------------------------------------------------- /app/ui/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Terminal UI module ``` -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Core business logic module ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for TaskTracker MCP package. 3 | """ ``` -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Terminal Task Tracker application package ``` -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- ```python 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="terminal-task-tracker", 5 | version="0.1.0", 6 | description="A terminal-based task tracking application with a three-pane layout", 7 | author="Your Name", 8 | author_email="[email protected]", 9 | packages=find_packages(), 10 | entry_points={ 11 | "console_scripts": [ 12 | "tasktracker=main:main", 13 | ], 14 | }, 15 | python_requires=">=3.6", 16 | classifiers=[ 17 | "Development Status :: 3 - Alpha", 18 | "Environment :: Console :: Curses", 19 | "Intended Audience :: Developers", 20 | "Intended Audience :: End Users/Desktop", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.6", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Topic :: Office/Business :: Scheduling", 28 | "Topic :: Utilities" 29 | ], 30 | ) ``` -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Terminal Task Tracker - Main Application Entry Point 4 | 5 | A terminal-based task tracking application with a three-pane layout 6 | for managing tasks and project plans. 7 | """ 8 | 9 | import os 10 | import sys 11 | 12 | from app.core.task_manager import TaskManager 13 | from app.core.plan_manager import PlanManager 14 | from app.api.api import TaskTrackerAPI 15 | from app.ui.terminal_ui import TerminalUI 16 | 17 | 18 | def main(): 19 | """Main application entry point.""" 20 | try: 21 | # Create data directory if it doesn't exist 22 | home_dir = os.path.expanduser("~") 23 | data_dir = os.path.join(home_dir, ".tasktracker") 24 | os.makedirs(data_dir, exist_ok=True) 25 | 26 | # Initialize managers 27 | task_manager = TaskManager() 28 | plan_manager = PlanManager() 29 | 30 | # Create API 31 | api = TaskTrackerAPI(task_manager, plan_manager) 32 | 33 | # Run terminal UI 34 | ui = TerminalUI(api) 35 | ui.run() 36 | 37 | # Save data on exit 38 | api.save_all() 39 | 40 | return 0 41 | except Exception as e: 42 | print(f"Error: {str(e)}", file=sys.stderr) 43 | return 1 44 | 45 | 46 | if __name__ == "__main__": 47 | sys.exit(main()) ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tasktracker-mcp" 7 | version = "0.1.0" 8 | description = "A terminal-based task tracking application with MCP integration" 9 | readme = "README.md" 10 | authors = [ 11 | {name = "Your Name", email = "[email protected]"}, 12 | ] 13 | license = {text = "MIT"} 14 | requires-python = ">=3.8" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | ] 24 | dependencies = [ 25 | "mcp>=0.1.0", 26 | ] 27 | 28 | [project.optional-dependencies] 29 | dev = [ 30 | "ruff>=0", 31 | "pytest>=7.0.0", 32 | "pytest-cov>=4.0.0", 33 | "pyright>=0", 34 | ] 35 | 36 | [tool.hatch.build.targets.wheel] 37 | packages = ["app"] 38 | 39 | [tool.ruff] 40 | line-length = 88 41 | target-version = "py38" 42 | 43 | [tool.ruff.lint] 44 | select = [ 45 | "E", # pycodestyle errors 46 | "F", # pyflakes 47 | "I", # isort 48 | ] 49 | 50 | [tool.pyright] 51 | include = ["app", "mcp_server.py"] 52 | typeCheckingMode = "basic" 53 | reportMissingTypeStubs = false 54 | 55 | [tool.pytest.ini_options] 56 | testpaths = ["tests"] 57 | python_files = "test_*.py" 58 | 59 | [project.scripts] 60 | tasktracker-mcp = "mcp_server:main" ``` -------------------------------------------------------------------------------- /tests/test_mcp_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the TaskTracker MCP server. 3 | """ 4 | import json 5 | import pytest 6 | from unittest.mock import MagicMock, patch 7 | 8 | # Import the MCP server 9 | import sys 10 | import os 11 | 12 | # Add the parent directory to the path 13 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 14 | 15 | import mcp_server 16 | 17 | 18 | @pytest.fixture 19 | def mock_api(): 20 | """Create a mock TaskTrackerAPI for testing.""" 21 | mock = MagicMock() 22 | 23 | # Set up return values for get_all_tasks 24 | mock.get_all_tasks.return_value = [ 25 | { 26 | "id": "test-task-1", 27 | "title": "Test Task 1", 28 | "description": "Description for test task 1", 29 | "priority": 1, 30 | "status": "not_started", 31 | "created_at": "2025-04-08T00:00:00", 32 | "updated_at": "2025-04-08T00:00:00" 33 | } 34 | ] 35 | 36 | # Set up return values for get_task 37 | mock.get_task.return_value = { 38 | "id": "test-task-1", 39 | "title": "Test Task 1", 40 | "description": "Description for test task 1", 41 | "priority": 1, 42 | "status": "not_started", 43 | "created_at": "2025-04-08T00:00:00", 44 | "updated_at": "2025-04-08T00:00:00" 45 | } 46 | 47 | # Set up return values for add_task 48 | mock.add_task.return_value = { 49 | "id": "new-task-1", 50 | "title": "New Task", 51 | "description": "Description for new task", 52 | "priority": 1, 53 | "status": "not_started", 54 | "created_at": "2025-04-08T00:00:00", 55 | "updated_at": "2025-04-08T00:00:00" 56 | } 57 | 58 | return mock 59 | 60 | 61 | @patch("mcp_server.api") 62 | def test_get_all_tasks(mock_api_module, mock_api): 63 | """Test the get_all_tasks resource.""" 64 | # Set the mock API 65 | mock_api_module.get_all_tasks.return_value = mock_api.get_all_tasks.return_value 66 | 67 | # Call the function 68 | result = mcp_server.get_all_tasks() 69 | 70 | # Assert the result 71 | expected = json.dumps(mock_api.get_all_tasks.return_value, indent=2) 72 | assert result == expected 73 | mock_api_module.get_all_tasks.assert_called_once() 74 | 75 | 76 | @patch("mcp_server.api") 77 | def test_get_task(mock_api_module, mock_api): 78 | """Test the get_task resource.""" 79 | # Set the mock API 80 | mock_api_module.get_task.return_value = mock_api.get_task.return_value 81 | 82 | # Call the function 83 | result = mcp_server.get_task("test-task-1") 84 | 85 | # Assert the result 86 | expected = json.dumps(mock_api.get_task.return_value, indent=2) 87 | assert result == expected 88 | mock_api_module.get_task.assert_called_once_with("test-task-1") 89 | 90 | 91 | @patch("mcp_server.api") 92 | def test_add_task(mock_api_module, mock_api): 93 | """Test the add_task tool.""" 94 | # Set the mock API 95 | mock_api_module.add_task.return_value = mock_api.add_task.return_value 96 | 97 | # Call the function 98 | result = mcp_server.add_task( 99 | title="New Task", 100 | description="Description for new task", 101 | priority=1, 102 | status="not_started" 103 | ) 104 | 105 | # Assert the result 106 | assert result == mock_api.add_task.return_value 107 | mock_api_module.add_task.assert_called_once_with( 108 | "New Task", "Description for new task", 1, "not_started" 109 | ) 110 | mock_api_module.save_all.assert_called_once() 111 | 112 | 113 | def test_add_task_prompt(): 114 | """Test the add_task_prompt.""" 115 | result = mcp_server.add_task_prompt( 116 | title="Test Task", 117 | description="Task description" 118 | ) 119 | 120 | assert "Test Task" in result 121 | assert "Task description" in result 122 | 123 | 124 | def test_create_plan_prompt(): 125 | """Test the create_plan_prompt.""" 126 | result = mcp_server.create_plan_prompt() 127 | 128 | assert "project plan" in result.lower() 129 | assert "clear steps" in result.lower() ``` -------------------------------------------------------------------------------- /app/core/task_manager.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | import os 3 | import uuid 4 | from datetime import datetime 5 | 6 | 7 | class TaskManager: 8 | def __init__(self, file_path=None, notes_file_path=None): 9 | """Initialize the TaskManager with optional file paths.""" 10 | home_dir = os.path.expanduser("~") 11 | task_dir = os.path.join(home_dir, ".tasktracker") 12 | os.makedirs(task_dir, exist_ok=True) 13 | 14 | if file_path is None: 15 | file_path = os.path.join(task_dir, "tasks.json") 16 | 17 | if notes_file_path is None: 18 | notes_file_path = os.path.join(task_dir, "notes.txt") 19 | 20 | self.file_path = file_path 21 | self.notes_file_path = notes_file_path 22 | self.tasks = self._load_tasks() 23 | self.notes = self._load_notes() 24 | 25 | def _load_tasks(self): 26 | """Load tasks from the file or return an empty list if file doesn't exist.""" 27 | if os.path.exists(self.file_path): 28 | try: 29 | with open(self.file_path, 'r') as f: 30 | return json.load(f) 31 | except json.JSONDecodeError: 32 | return [] 33 | return [] 34 | 35 | def reload_tasks(self): 36 | """Reload tasks from file (for external changes like MCP).""" 37 | self.tasks = self._load_tasks() 38 | 39 | def _load_notes(self): 40 | """Load notes from file or return empty string if file doesn't exist.""" 41 | if os.path.exists(self.notes_file_path): 42 | try: 43 | with open(self.notes_file_path, 'r') as f: 44 | return f.read() 45 | except: 46 | return "" 47 | return "" 48 | 49 | def reload_notes(self): 50 | """Reload notes from file (for external changes like MCP).""" 51 | self.notes = self._load_notes() 52 | 53 | def save_tasks(self): 54 | """Save tasks to the file.""" 55 | with open(self.file_path, 'w') as f: 56 | json.dump(self.tasks, f, indent=2) 57 | 58 | def save_notes(self, notes_text): 59 | """Save notes to the file.""" 60 | self.notes = notes_text 61 | with open(self.notes_file_path, 'w') as f: 62 | f.write(notes_text) 63 | 64 | def get_notes(self): 65 | """Get the current notes.""" 66 | return self.notes 67 | 68 | def get_all_tasks(self): 69 | """Return all tasks.""" 70 | return self.tasks 71 | 72 | def get_task(self, task_id): 73 | """Get a task by ID.""" 74 | for task in self.tasks: 75 | if task["id"] == task_id: 76 | return task 77 | return None 78 | 79 | def add_task(self, title, description="", priority=1, status="not_started"): 80 | """Add a new task.""" 81 | # Validate status 82 | valid_statuses = ["not_started", "in_progress", "completed"] 83 | if status not in valid_statuses: 84 | status = "not_started" 85 | 86 | task = { 87 | "id": str(uuid.uuid4()), 88 | "title": title, 89 | "description": description, 90 | "priority": priority, 91 | "status": status, 92 | "created_at": datetime.now().isoformat(), 93 | "updated_at": datetime.now().isoformat() 94 | } 95 | self.tasks.append(task) 96 | self.save_tasks() 97 | return task 98 | 99 | def update_task(self, task_id, **kwargs): 100 | """Update a task by ID.""" 101 | task = self.get_task(task_id) 102 | if task: 103 | for key, value in kwargs.items(): 104 | if key in task and key not in ["id", "created_at"]: 105 | task[key] = value 106 | task["updated_at"] = datetime.now().isoformat() 107 | self.save_tasks() 108 | return task 109 | return None 110 | 111 | def delete_task(self, task_id): 112 | """Delete a task by ID.""" 113 | task = self.get_task(task_id) 114 | if task: 115 | self.tasks.remove(task) 116 | self.save_tasks() 117 | return True 118 | return False ``` -------------------------------------------------------------------------------- /app/api/api.py: -------------------------------------------------------------------------------- ```python 1 | from app.core.task_manager import TaskManager 2 | from app.core.plan_manager import PlanManager 3 | 4 | 5 | class TaskTrackerAPI: 6 | def __init__(self, task_manager=None, plan_manager=None): 7 | """Initialize the TaskTrackerAPI with task and plan managers.""" 8 | self.task_manager = task_manager or TaskManager() 9 | self.plan_manager = plan_manager or PlanManager() 10 | 11 | # Task methods 12 | def get_all_tasks(self): 13 | """Get all tasks.""" 14 | return self.task_manager.get_all_tasks() 15 | 16 | def get_task(self, task_id): 17 | """Get a task by ID.""" 18 | return self.task_manager.get_task(task_id) 19 | 20 | def add_task(self, title, description="", priority=1, status="pending"): 21 | """Add a new task.""" 22 | return self.task_manager.add_task(title, description, priority, status) 23 | 24 | def update_task(self, task_id, **kwargs): 25 | """Update a task by ID.""" 26 | return self.task_manager.update_task(task_id, **kwargs) 27 | 28 | def delete_task(self, task_id): 29 | """Delete a task by ID.""" 30 | return self.task_manager.delete_task(task_id) 31 | 32 | # Plan methods 33 | def get_all_plan_steps(self): 34 | """Get all plan steps.""" 35 | return self.plan_manager.get_all_steps() 36 | 37 | def get_plan_step(self, step_id): 38 | """Get a plan step by ID.""" 39 | return self.plan_manager.get_step(step_id) 40 | 41 | def add_plan_step(self, name, description="", details="", order=None, completed=False): 42 | """Add a new plan step.""" 43 | return self.plan_manager.add_step(name, description, details, order, completed) 44 | 45 | def update_plan_step(self, step_id, **kwargs): 46 | """Update a plan step by ID.""" 47 | return self.plan_manager.update_step(step_id, **kwargs) 48 | 49 | def toggle_plan_step(self, step_id): 50 | """Toggle the completion status of a plan step.""" 51 | return self.plan_manager.toggle_step(step_id) 52 | 53 | def delete_plan_step(self, step_id): 54 | """Delete a plan step by ID.""" 55 | return self.plan_manager.delete_step(step_id) 56 | 57 | def reorder_plan_steps(self): 58 | """Reorder plan steps to ensure consistent ordering.""" 59 | return self.plan_manager.reorder_steps() 60 | 61 | # Notes methods 62 | def get_notes(self): 63 | """Get the notes.""" 64 | return self.task_manager.get_notes() 65 | 66 | def save_notes(self, notes_text): 67 | """Save notes.""" 68 | self.task_manager.save_notes(notes_text) 69 | return True 70 | 71 | # Data management 72 | def save_all(self): 73 | """Save all data to files.""" 74 | self.task_manager.save_tasks() 75 | self.plan_manager.save_plan() 76 | return True 77 | 78 | def reload_all(self): 79 | """Reload all data from files (for external changes like MCP).""" 80 | self.task_manager.reload_tasks() 81 | self.task_manager.reload_notes() 82 | self.plan_manager.reload_plan() 83 | return True 84 | 85 | def export_data(self, file_path): 86 | """Export all data to a single JSON file.""" 87 | import json 88 | data = { 89 | "tasks": self.get_all_tasks(), 90 | "plan": self.get_all_plan_steps(), 91 | "notes": self.get_notes() 92 | } 93 | with open(file_path, 'w') as f: 94 | json.dump(data, f, indent=2) 95 | return True 96 | 97 | def import_data(self, file_path): 98 | """Import data from a JSON file.""" 99 | import json 100 | try: 101 | with open(file_path, 'r') as f: 102 | data = json.load(f) 103 | 104 | # Clear existing data 105 | self.task_manager.tasks = data.get("tasks", []) 106 | self.plan_manager.plan_steps = data.get("plan", []) 107 | 108 | # Import notes if available 109 | if "notes" in data: 110 | self.task_manager.save_notes(data["notes"]) 111 | 112 | # Save imported data 113 | self.save_all() 114 | return True 115 | except (json.JSONDecodeError, FileNotFoundError) as e: 116 | print(f"Error importing data: {e}") 117 | return False ``` -------------------------------------------------------------------------------- /MCP-README.md: -------------------------------------------------------------------------------- ```markdown 1 | # TaskTracker MCP Server 2 | 3 | TaskTracker MCP is a Model Context Protocol compatible version of the Terminal Task Tracker application. It exposes task and project plan management capabilities through MCP, allowing Language Models to interact with your task tracking data. 4 | 5 | ## Features 6 | 7 | - **Resources**: Access tasks, plans, and notes data 8 | - **Tools**: Create, update, and delete tasks and plan steps 9 | - **Prompts**: Templates for common task management activities 10 | 11 | ## Installation 12 | 13 | ```bash 14 | # Using uv (recommended) 15 | uv add -e . 16 | 17 | # Or with pip 18 | pip install -e . 19 | ``` 20 | 21 | ## Usage with Claude Desktop 22 | 23 | ```bash 24 | # Install in Claude Desktop 25 | mcp install mcp_server_fixed.py 26 | 27 | # Run with MCP Inspector 28 | mcp dev mcp_server_fixed.py 29 | ``` 30 | 31 | NOTE: If you encounter errors with `mcp_server.py`, please use the fixed version `mcp_server_fixed.py` instead. The fixed version uses a global API instance for resources and properly handles context parameters for tools. 32 | 33 | ## Running Directly 34 | 35 | ```bash 36 | # Run server directly 37 | python mcp_server_fixed.py 38 | 39 | # Or using MCP CLI 40 | mcp run mcp_server_fixed.py 41 | ``` 42 | 43 | ## Resources 44 | 45 | TaskTracker exposes the following resources: 46 | 47 | - `tasks://all` - List all tasks 48 | - `tasks://{task_id}` - Get a specific task 49 | - `plan://all` - List all plan steps 50 | - `plan://{step_id}` - Get a specific plan step 51 | - `notes://all` - Get all notes 52 | 53 | ## Tools 54 | 55 | TaskTracker provides the following tools: 56 | 57 | ### Task Tools 58 | - `get_all_tasks_tool` - Get all tasks 59 | - `get_task_tool` - Get a specific task by ID 60 | - `add_task` - Create a new task 61 | - `update_task` - Update an existing task 62 | - `delete_task` - Delete a task 63 | 64 | ### Plan Tools 65 | - `get_all_plan_steps_tool` - Get all plan steps 66 | - `get_plan_step_tool` - Get a specific plan step by ID 67 | - `add_plan_step` - Create a new plan step 68 | - `update_plan_step` - Update an existing plan step 69 | - `delete_plan_step` - Delete a plan step 70 | - `toggle_plan_step` - Toggle completion status 71 | 72 | ### Notes and Data Tools 73 | - `get_notes_tool` - Get all notes 74 | - `save_notes` - Save notes 75 | - `export_data` - Export all data to JSON 76 | - `import_data` - Import data from JSON 77 | 78 | ## Prompts 79 | 80 | TaskTracker includes the following prompts: 81 | 82 | - `add_task_prompt` - Template for adding a new task 83 | - `create_plan_prompt` - Template for creating a project plan 84 | 85 | ## Example Interactions 86 | 87 | ### Adding and Retrieving Tasks 88 | 89 | ``` 90 | > call-tool add_task "Implement login feature" "Create authentication endpoints for user login" 1 "not_started" 91 | { 92 | "id": "550e8400-e29b-41d4-a716-446655440000", 93 | "title": "Implement login feature", 94 | "description": "Create authentication endpoints for user login", 95 | "priority": 1, 96 | "status": "not_started", 97 | "created_at": "2025-04-08T14:32:15.123456", 98 | "updated_at": "2025-04-08T14:32:15.123456" 99 | } 100 | 101 | > call-tool get_all_tasks_tool 102 | [ 103 | { 104 | "id": "550e8400-e29b-41d4-a716-446655440000", 105 | "title": "Implement login feature", 106 | "description": "Create authentication endpoints for user login", 107 | "priority": 1, 108 | "status": "not_started", 109 | "created_at": "2025-04-08T14:32:15.123456", 110 | "updated_at": "2025-04-08T14:32:15.123456" 111 | } 112 | ] 113 | 114 | > read-resource tasks://all 115 | [ 116 | { 117 | "id": "550e8400-e29b-41d4-a716-446655440000", 118 | "title": "Implement login feature", 119 | "description": "Create authentication endpoints for user login", 120 | "priority": 1, 121 | "status": "not_started", 122 | "created_at": "2025-04-08T14:32:15.123456", 123 | "updated_at": "2025-04-08T14:32:15.123456" 124 | } 125 | ] 126 | ``` 127 | 128 | ### Managing Project Plans 129 | 130 | ``` 131 | > call-tool add_plan_step "Design API endpoints" "Create OpenAPI specification for endpoints" "Include authentication routes" 0 false 132 | { 133 | "id": "550e8400-e29b-41d4-a716-446655440001", 134 | "name": "Design API endpoints", 135 | "description": "Create OpenAPI specification for endpoints", 136 | "details": "Include authentication routes", 137 | "order": 0, 138 | "completed": false, 139 | "created_at": "2025-04-08T14:33:15.123456", 140 | "updated_at": "2025-04-08T14:33:15.123456" 141 | } 142 | 143 | > call-tool get_all_plan_steps_tool 144 | [ 145 | { 146 | "id": "550e8400-e29b-41d4-a716-446655440001", 147 | "name": "Design API endpoints", 148 | "description": "Create OpenAPI specification for endpoints", 149 | "details": "Include authentication routes", 150 | "order": 0, 151 | "completed": false, 152 | "created_at": "2025-04-08T14:33:15.123456", 153 | "updated_at": "2025-04-08T14:33:15.123456" 154 | } 155 | ] 156 | ``` 157 | 158 | ## License 159 | 160 | MIT ``` -------------------------------------------------------------------------------- /app/core/plan_manager.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | import os 3 | import uuid 4 | from datetime import datetime 5 | 6 | 7 | class PlanManager: 8 | def __init__(self, file_path=None): 9 | """Initialize the PlanManager with an optional file path.""" 10 | if file_path is None: 11 | home_dir = os.path.expanduser("~") 12 | plan_dir = os.path.join(home_dir, ".tasktracker") 13 | os.makedirs(plan_dir, exist_ok=True) 14 | file_path = os.path.join(plan_dir, "plan.json") 15 | 16 | self.file_path = file_path 17 | self.plan_steps = self._load_plan() 18 | 19 | def _load_plan(self): 20 | """Load plan steps from the file or return an empty list if file doesn't exist.""" 21 | if os.path.exists(self.file_path): 22 | try: 23 | with open(self.file_path, 'r') as f: 24 | data = json.load(f) 25 | # Ensure we always return a list 26 | if isinstance(data, list): 27 | return data 28 | elif isinstance(data, dict) and "steps" in data: 29 | return data["steps"] 30 | else: 31 | # If it's not a list or doesn't have steps key, return empty list 32 | return [] 33 | except (json.JSONDecodeError, IOError): 34 | return [] 35 | return [] 36 | 37 | def reload_plan(self): 38 | """Reload plan steps from file (for external changes like MCP).""" 39 | self.plan_steps = self._load_plan() 40 | 41 | def save_plan(self): 42 | """Save plan steps to the file.""" 43 | # Ensure plan_steps is a list before saving 44 | if not isinstance(self.plan_steps, list): 45 | self.plan_steps = [] 46 | 47 | with open(self.file_path, 'w') as f: 48 | json.dump(self.plan_steps, f, indent=2) 49 | 50 | def get_all_steps(self): 51 | """Return all plan steps.""" 52 | # Ensure we always return a list 53 | if not isinstance(self.plan_steps, list): 54 | self.plan_steps = [] 55 | return self.plan_steps 56 | 57 | def get_step(self, step_id): 58 | """Get a plan step by ID.""" 59 | # Ensure plan_steps is a list 60 | if not isinstance(self.plan_steps, list): 61 | self.plan_steps = [] 62 | return None 63 | 64 | for step in self.plan_steps: 65 | if step["id"] == step_id: 66 | return step 67 | return None 68 | 69 | def add_step(self, name, description="", details="", order=None, completed=False): 70 | """Add a new plan step.""" 71 | # Ensure plan_steps is a list 72 | if not isinstance(self.plan_steps, list): 73 | self.plan_steps = [] 74 | 75 | if order is None: 76 | # Place at the end by default 77 | order = len(self.plan_steps) 78 | 79 | step = { 80 | "id": str(uuid.uuid4()), 81 | "name": name, 82 | "description": description, 83 | "details": details, 84 | "order": order, 85 | "completed": completed, 86 | "created_at": datetime.now().isoformat(), 87 | "updated_at": datetime.now().isoformat() 88 | } 89 | 90 | # Insert at the specified order 91 | self.plan_steps.append(step) 92 | self.reorder_steps() 93 | self.save_plan() 94 | return step 95 | 96 | def update_step(self, step_id, **kwargs): 97 | """Update a plan step by ID.""" 98 | step = self.get_step(step_id) 99 | if step: 100 | for key, value in kwargs.items(): 101 | if key in step and key not in ["id", "created_at"]: 102 | step[key] = value 103 | step["updated_at"] = datetime.now().isoformat() 104 | 105 | # If order changed, reorder all steps 106 | if "order" in kwargs: 107 | self.reorder_steps() 108 | 109 | self.save_plan() 110 | return step 111 | return None 112 | 113 | def toggle_step(self, step_id): 114 | """Toggle the completion status of a plan step.""" 115 | step = self.get_step(step_id) 116 | if step: 117 | step["completed"] = not step["completed"] 118 | step["updated_at"] = datetime.now().isoformat() 119 | self.save_plan() 120 | return step 121 | return None 122 | 123 | def delete_step(self, step_id): 124 | """Delete a plan step by ID.""" 125 | # Ensure plan_steps is a list 126 | if not isinstance(self.plan_steps, list): 127 | self.plan_steps = [] 128 | return False 129 | 130 | step = self.get_step(step_id) 131 | if step: 132 | self.plan_steps.remove(step) 133 | self.reorder_steps() 134 | self.save_plan() 135 | return True 136 | return False 137 | 138 | def reorder_steps(self): 139 | """Reorder steps to ensure consistent ordering.""" 140 | # Ensure plan_steps is a list 141 | if not isinstance(self.plan_steps, list): 142 | self.plan_steps = [] 143 | return 144 | 145 | # Sort by the current order 146 | self.plan_steps.sort(key=lambda x: x.get("order", 0)) 147 | 148 | # Update order field to match actual position 149 | for i, step in enumerate(self.plan_steps): 150 | step["order"] = i ``` -------------------------------------------------------------------------------- /app/api/cli.py: -------------------------------------------------------------------------------- ```python 1 | import argparse 2 | import sys 3 | import os 4 | 5 | from app.api.api import TaskTrackerAPI 6 | from app.core.task_manager import TaskManager 7 | from app.core.plan_manager import PlanManager 8 | 9 | 10 | def create_parser(): 11 | """Create the command line argument parser.""" 12 | parser = argparse.ArgumentParser(description='Terminal Task Tracker CLI') 13 | subparsers = parser.add_subparsers(dest='command', help='Command to execute') 14 | 15 | # Task commands 16 | task_parser = subparsers.add_parser('task', help='Task operations') 17 | task_subparsers = task_parser.add_subparsers(dest='subcommand', help='Task subcommand') 18 | 19 | # task list 20 | task_list_parser = task_subparsers.add_parser('list', help='List all tasks') 21 | 22 | # task show 23 | task_show_parser = task_subparsers.add_parser('show', help='Show task details') 24 | task_show_parser.add_argument('task_id', help='Task ID') 25 | 26 | # task add 27 | task_add_parser = task_subparsers.add_parser('add', help='Add a new task') 28 | task_add_parser.add_argument('title', help='Task title') 29 | task_add_parser.add_argument('--description', '-d', help='Task description') 30 | task_add_parser.add_argument('--priority', '-p', type=int, choices=[1, 2, 3], default=1, 31 | help='Task priority (1=Low, 2=Medium, 3=High)') 32 | task_add_parser.add_argument('--status', '-s', 33 | choices=['not_started', 'in_progress', 'completed'], 34 | default='not_started', help='Task status') 35 | 36 | # task update 37 | task_update_parser = task_subparsers.add_parser('update', help='Update a task') 38 | task_update_parser.add_argument('task_id', help='Task ID') 39 | task_update_parser.add_argument('--title', '-t', help='Task title') 40 | task_update_parser.add_argument('--description', '-d', help='Task description') 41 | task_update_parser.add_argument('--priority', '-p', type=int, choices=[1, 2, 3], 42 | help='Task priority (1=Low, 2=Medium, 3=High)') 43 | task_update_parser.add_argument('--status', '-s', 44 | choices=['not_started', 'in_progress', 'completed'], 45 | help='Task status') 46 | 47 | # task delete 48 | task_delete_parser = task_subparsers.add_parser('delete', help='Delete a task') 49 | task_delete_parser.add_argument('task_id', help='Task ID') 50 | 51 | # Plan commands 52 | plan_parser = subparsers.add_parser('plan', help='Plan operations') 53 | plan_subparsers = plan_parser.add_subparsers(dest='subcommand', help='Plan subcommand') 54 | 55 | # plan list 56 | plan_list_parser = plan_subparsers.add_parser('list', help='List all plan steps') 57 | 58 | # plan show 59 | plan_show_parser = plan_subparsers.add_parser('show', help='Show plan step details') 60 | plan_show_parser.add_argument('step_id', help='Step ID') 61 | 62 | # plan add 63 | plan_add_parser = plan_subparsers.add_parser('add', help='Add a new plan step') 64 | plan_add_parser.add_argument('name', help='Step name') 65 | plan_add_parser.add_argument('--description', '-d', help='Brief description') 66 | plan_add_parser.add_argument('--details', '-D', help='Detailed information') 67 | plan_add_parser.add_argument('--order', '-o', type=int, help='Step order (position in plan)') 68 | plan_add_parser.add_argument('--completed', '-c', action='store_true', 69 | help='Mark step as completed') 70 | 71 | # plan update 72 | plan_update_parser = plan_subparsers.add_parser('update', help='Update a plan step') 73 | plan_update_parser.add_argument('step_id', help='Step ID') 74 | plan_update_parser.add_argument('--name', '-n', help='Step name') 75 | plan_update_parser.add_argument('--description', '-d', help='Brief description') 76 | plan_update_parser.add_argument('--details', '-D', help='Detailed information') 77 | plan_update_parser.add_argument('--order', '-o', type=int, help='Step order (position in plan)') 78 | 79 | # plan toggle 80 | plan_toggle_parser = plan_subparsers.add_parser('toggle', 81 | help='Toggle completion status of a plan step') 82 | plan_toggle_parser.add_argument('step_id', help='Step ID') 83 | 84 | # plan delete 85 | plan_delete_parser = plan_subparsers.add_parser('delete', help='Delete a plan step') 86 | plan_delete_parser.add_argument('step_id', help='Step ID') 87 | 88 | # Export/Import commands 89 | export_parser = subparsers.add_parser('export', help='Export data to JSON file') 90 | export_parser.add_argument('file_path', help='Path to export file') 91 | 92 | import_parser = subparsers.add_parser('import', help='Import data from JSON file') 93 | import_parser.add_argument('file_path', help='Path to import file') 94 | 95 | return parser 96 | 97 | 98 | def main(): 99 | """Main CLI entry point.""" 100 | parser = create_parser() 101 | args = parser.parse_args() 102 | 103 | # Initialize API 104 | api = TaskTrackerAPI() 105 | 106 | if not args.command: 107 | parser.print_help() 108 | return 109 | 110 | try: 111 | # Task commands 112 | if args.command == 'task': 113 | if args.subcommand == 'list': 114 | tasks = api.get_all_tasks() 115 | if not tasks: 116 | print("No tasks found.") 117 | else: 118 | print(f"{'ID':<36} {'Title':<30} {'Priority':<8} {'Status':<12}") 119 | print("-" * 90) 120 | for task in tasks: 121 | print(f"{task['id']:<36} {task['title'][:30]:<30} {task['priority']:<8} {task['status']:<12}") 122 | 123 | elif args.subcommand == 'show': 124 | task = api.get_task(args.task_id) 125 | if task: 126 | print(f"ID: {task['id']}") 127 | print(f"Title: {task['title']}") 128 | print(f"Description: {task['description']}") 129 | print(f"Priority: {task['priority']}") 130 | print(f"Status: {task['status']}") 131 | print(f"Created: {task['created_at']}") 132 | print(f"Updated: {task['updated_at']}") 133 | else: 134 | print(f"Task not found: {args.task_id}") 135 | 136 | elif args.subcommand == 'add': 137 | task = api.add_task( 138 | args.title, 139 | args.description or "", 140 | args.priority, 141 | args.status 142 | ) 143 | print(f"Task added: {task['id']}") 144 | 145 | elif args.subcommand == 'update': 146 | # Collect the fields to update 147 | update_fields = {} 148 | if args.title: 149 | update_fields['title'] = args.title 150 | if args.description: 151 | update_fields['description'] = args.description 152 | if args.priority: 153 | update_fields['priority'] = args.priority 154 | if args.status: 155 | update_fields['status'] = args.status 156 | 157 | task = api.update_task(args.task_id, **update_fields) 158 | if task: 159 | print(f"Task updated: {task['id']}") 160 | else: 161 | print(f"Task not found: {args.task_id}") 162 | 163 | elif args.subcommand == 'delete': 164 | result = api.delete_task(args.task_id) 165 | if result: 166 | print(f"Task deleted: {args.task_id}") 167 | else: 168 | print(f"Task not found: {args.task_id}") 169 | 170 | else: 171 | parser.print_help() 172 | 173 | # Plan commands 174 | elif args.command == 'plan': 175 | if args.subcommand == 'list': 176 | steps = api.get_all_plan_steps() 177 | if not steps: 178 | print("No plan steps found.") 179 | else: 180 | print(f"{'Order':<6} {'Completed':<10} {'ID':<36} {'Description'}") 181 | print("-" * 90) 182 | for step in steps: 183 | completed = "[x]" if step['completed'] else "[ ]" 184 | print(f"{step['order']:<6} {completed:<10} {step['id']:<36} {step['description']}") 185 | 186 | elif args.subcommand == 'show': 187 | step = api.get_plan_step(args.step_id) 188 | if step: 189 | completed = "Yes" if step['completed'] else "No" 190 | print(f"ID: {step['id']}") 191 | print(f"Name: {step.get('name', 'N/A')}") 192 | print(f"Description: {step.get('description', '')}") 193 | print(f"Order: {step.get('order', 0)}") 194 | print(f"Completed: {completed}") 195 | 196 | # Print details if available 197 | details = step.get('details', '') 198 | if details: 199 | print("\nDetails:") 200 | print(details) 201 | 202 | print(f"\nCreated: {step.get('created_at', 'N/A')}") 203 | print(f"Updated: {step.get('updated_at', 'N/A')}") 204 | else: 205 | print(f"Plan step not found: {args.step_id}") 206 | 207 | elif args.subcommand == 'add': 208 | step = api.add_plan_step( 209 | args.name, 210 | args.description or "", 211 | args.details or "", 212 | args.order, 213 | args.completed 214 | ) 215 | print(f"Plan step added: {step['id']}") 216 | 217 | elif args.subcommand == 'update': 218 | # Collect the fields to update 219 | update_fields = {} 220 | if args.name: 221 | update_fields['name'] = args.name 222 | if args.description: 223 | update_fields['description'] = args.description 224 | if args.details: 225 | update_fields['details'] = args.details 226 | if args.order is not None: 227 | update_fields['order'] = args.order 228 | 229 | step = api.update_plan_step(args.step_id, **update_fields) 230 | if step: 231 | print(f"Plan step updated: {step['id']}") 232 | else: 233 | print(f"Plan step not found: {args.step_id}") 234 | 235 | elif args.subcommand == 'toggle': 236 | step = api.toggle_plan_step(args.step_id) 237 | if step: 238 | completed = "completed" if step['completed'] else "not completed" 239 | print(f"Plan step {args.step_id} marked as {completed}") 240 | else: 241 | print(f"Plan step not found: {args.step_id}") 242 | 243 | elif args.subcommand == 'delete': 244 | result = api.delete_plan_step(args.step_id) 245 | if result: 246 | print(f"Plan step deleted: {args.step_id}") 247 | else: 248 | print(f"Plan step not found: {args.step_id}") 249 | 250 | else: 251 | parser.print_help() 252 | 253 | # Export/Import commands 254 | elif args.command == 'export': 255 | result = api.export_data(args.file_path) 256 | if result: 257 | print(f"Data exported to {args.file_path}") 258 | else: 259 | print("Error exporting data") 260 | 261 | elif args.command == 'import': 262 | result = api.import_data(args.file_path) 263 | if result: 264 | print(f"Data imported from {args.file_path}") 265 | else: 266 | print("Error importing data") 267 | 268 | else: 269 | parser.print_help() 270 | 271 | except Exception as e: 272 | print(f"Error: {str(e)}") 273 | return 1 274 | 275 | return 0 276 | 277 | 278 | if __name__ == "__main__": 279 | sys.exit(main()) ``` -------------------------------------------------------------------------------- /app/ui/terminal_ui.py: -------------------------------------------------------------------------------- ```python 1 | import curses 2 | import traceback 3 | import os 4 | import time 5 | 6 | from app.ui.ui_components import TaskListWindow, TaskDetailWindow, PlanWindow, NotesWindow, InputDialog, ConfirmDialog 7 | from app.ui.input_handler import InputHandler, FocusArea 8 | 9 | 10 | class TerminalUI: 11 | def __init__(self, api): 12 | """Initialize the terminal UI with a reference to the API.""" 13 | self.api = api 14 | self.stdscr = None 15 | self.task_list_win = None 16 | self.task_detail_win = None 17 | self.plan_win = None 18 | self.notes_win = None 19 | self.input_handler = None 20 | self.notes_visible = True # Flag to control notes visibility 21 | 22 | # File modification tracking 23 | self.last_tasks_mtime = 0 24 | self.last_plan_mtime = 0 25 | self.last_notes_mtime = 0 26 | self.last_check_time = 0 27 | self.file_check_interval = 1.0 # Check for file changes every second 28 | 29 | def run(self): 30 | """Run the terminal UI.""" 31 | try: 32 | # Start curses application 33 | curses.wrapper(self._main) 34 | except Exception as e: 35 | # If an error occurs, restore terminal and show traceback 36 | if self.stdscr: 37 | # Reset timeout to blocking before exiting to prevent potential issues 38 | self.stdscr.timeout(-1) 39 | curses.endwin() 40 | print(f"An error occurred: {str(e)}") 41 | traceback.print_exc() 42 | 43 | def _main(self, stdscr): 44 | """Main function for the curses application.""" 45 | self.stdscr = stdscr 46 | 47 | # Set up curses 48 | curses.curs_set(0) # Hide cursor 49 | stdscr.clear() 50 | 51 | # Set up input handler 52 | self.input_handler = InputHandler(self) 53 | 54 | # Create initial layout 55 | self._create_layout() 56 | 57 | # Initial data load 58 | try: 59 | self.refresh_tasks() 60 | self.refresh_plan() 61 | self.refresh_notes() 62 | 63 | # Initialize last modified times after initial load 64 | task_file = self.api.task_manager.file_path 65 | plan_file = self.api.plan_manager.file_path 66 | notes_file = self.api.task_manager.notes_file_path 67 | 68 | if os.path.exists(task_file): 69 | self.last_tasks_mtime = os.path.getmtime(task_file) 70 | if os.path.exists(plan_file): 71 | self.last_plan_mtime = os.path.getmtime(plan_file) 72 | if os.path.exists(notes_file): 73 | self.last_notes_mtime = os.path.getmtime(notes_file) 74 | 75 | self.last_check_time = time.time() 76 | 77 | except Exception as e: 78 | self.show_message(f"Error loading data: {str(e)}") 79 | 80 | # Main event loop 81 | while True: 82 | # Check for external file changes (e.g., from MCP) 83 | self.check_file_changes() 84 | 85 | # Update the screen 86 | stdscr.refresh() 87 | 88 | # Configure timeout for getch to allow polling for file changes 89 | stdscr.timeout(100) # 100ms timeout 90 | 91 | # Get input (returns -1 if no input available) 92 | key = stdscr.getch() 93 | 94 | # Reset timeout to blocking mode if we actually got a key 95 | if key != -1: 96 | stdscr.timeout(-1) 97 | # Handle input (exit if handler returns False) 98 | if not self.input_handler.handle_input(key): 99 | break 100 | else: 101 | # No input, just continue the loop to check for file changes 102 | continue 103 | 104 | def _create_layout(self): 105 | """Create the initial window layout.""" 106 | screen_height, screen_width = self.stdscr.getmaxyx() 107 | 108 | # Calculate dimensions for initial layout 109 | top_height = screen_height // 2 110 | main_width = screen_width - 30 # Reserve 30 cols for notes on the right 111 | task_width = main_width // 2 112 | detail_width = main_width - task_width 113 | plan_height = screen_height - top_height 114 | notes_width = screen_width - main_width 115 | 116 | # Create windows 117 | self.task_list_win = TaskListWindow( 118 | self.stdscr, top_height, task_width, 0, 0, "Tasks" 119 | ) 120 | 121 | self.task_detail_win = TaskDetailWindow( 122 | self.stdscr, top_height, detail_width, 0, task_width, "Task Details" 123 | ) 124 | 125 | self.plan_win = PlanWindow( 126 | self.stdscr, plan_height, main_width, top_height, 0, "Project Plan" 127 | ) 128 | 129 | self.notes_win = NotesWindow( 130 | self.stdscr, screen_height, notes_width, 0, main_width, "Notes" 131 | ) 132 | 133 | # Initial refresh 134 | self.task_list_win.refresh() 135 | self.task_detail_win.refresh() 136 | self.plan_win.refresh() 137 | 138 | if self.notes_visible: 139 | self.notes_win.refresh() 140 | 141 | def toggle_notes_visibility(self): 142 | """Toggle the visibility of the notes window.""" 143 | self.notes_visible = not self.notes_visible 144 | 145 | # If hiding and notes is the active focus, change focus to tasks 146 | if not self.notes_visible and self.input_handler.focus == FocusArea.NOTES: 147 | self.input_handler.focus = FocusArea.TASKS 148 | self.update_focus(FocusArea.TASKS) 149 | 150 | # Redraw layout (this will resize all windows accordingly) 151 | self._resize_layout() 152 | 153 | # If notes are hidden, make sure we redraw all other windows 154 | if not self.notes_visible: 155 | # Ensure each window is refreshed with its contents 156 | self.task_list_win.refresh_content() 157 | self.task_detail_win.refresh_content() 158 | self.plan_win.refresh_content() 159 | 160 | # Refresh the stdscr to ensure proper redraw of everything 161 | self.stdscr.refresh() 162 | 163 | return self.notes_visible 164 | 165 | def _resize_layout(self): 166 | """Resize the window layout.""" 167 | screen_height, screen_width = self.stdscr.getmaxyx() 168 | 169 | # Calculate dimensions based on notes visibility 170 | if self.notes_visible: 171 | main_width = screen_width - 30 # Reserve 30 cols for notes on the right 172 | notes_width = screen_width - main_width 173 | else: 174 | main_width = screen_width # Use full width when notes are hidden 175 | notes_width = 0 176 | 177 | top_height = screen_height // 2 178 | task_width = main_width // 2 179 | detail_width = main_width - task_width 180 | plan_height = screen_height - top_height 181 | 182 | # Resize windows 183 | self.task_list_win.resize(top_height, task_width, 0, 0) 184 | self.task_detail_win.resize(top_height, detail_width, 0, task_width) 185 | self.plan_win.resize(plan_height, main_width, top_height, 0) 186 | 187 | # Only resize notes window if visible 188 | if self.notes_visible: 189 | self.notes_win.resize(screen_height, notes_width, 0, main_width) 190 | 191 | # Refresh content 192 | self.task_list_win.refresh_content() 193 | self.task_detail_win.refresh_content() 194 | self.plan_win.refresh_content() 195 | 196 | # Only refresh notes if visible 197 | if self.notes_visible: 198 | self.notes_win.refresh_content() 199 | 200 | def refresh_tasks(self): 201 | """Refresh task list and details.""" 202 | tasks = self.api.get_all_tasks() 203 | 204 | # Sort tasks by priority (high to low) and then by status 205 | tasks.sort(key=lambda x: (-x['priority'], x['status'])) 206 | 207 | self.task_list_win.set_tasks(tasks) 208 | 209 | # Update task details if there's a selected task 210 | selected_task = self.task_list_win.get_selected_task() 211 | self.task_detail_win.set_task(selected_task) 212 | 213 | def refresh_plan(self): 214 | """Refresh the project plan.""" 215 | try: 216 | steps = self.api.get_all_plan_steps() 217 | 218 | # Validate steps before setting them 219 | if steps is None: 220 | steps = [] 221 | 222 | # Steps are already sorted by order in the API 223 | self.plan_win.set_steps(steps) 224 | except Exception as e: 225 | # Handle errors gracefully 226 | self.plan_win.set_steps([]) 227 | raise Exception(f"Failed to load plan: {str(e)}") 228 | 229 | def refresh_notes(self): 230 | """Load and refresh the notes content.""" 231 | notes = self.api.get_notes() 232 | self.notes_win.set_notes(notes) 233 | 234 | def save_notes(self): 235 | """Save the current notes content.""" 236 | notes_text = self.notes_win.get_notes() 237 | self.api.save_notes(notes_text) 238 | 239 | def check_file_changes(self): 240 | """Check if any data files have been modified externally (like by MCP).""" 241 | try: 242 | current_time = time.time() 243 | 244 | # Only check periodically to reduce file system access 245 | if current_time - self.last_check_time < self.file_check_interval: 246 | return False 247 | 248 | self.last_check_time = current_time 249 | changes_detected = False 250 | 251 | # Get file paths from the API's managers 252 | task_file = self.api.task_manager.file_path 253 | plan_file = self.api.plan_manager.file_path 254 | notes_file = self.api.task_manager.notes_file_path 255 | 256 | # Check if any data files have been modified 257 | tasks_changed = os.path.exists(task_file) and os.path.getmtime(task_file) > self.last_tasks_mtime 258 | plan_changed = os.path.exists(plan_file) and os.path.getmtime(plan_file) > self.last_plan_mtime 259 | notes_changed = os.path.exists(notes_file) and os.path.getmtime(notes_file) > self.last_notes_mtime 260 | 261 | if tasks_changed or plan_changed or notes_changed: 262 | # Update last modified times 263 | if tasks_changed: 264 | self.last_tasks_mtime = os.path.getmtime(task_file) 265 | if plan_changed: 266 | self.last_plan_mtime = os.path.getmtime(plan_file) 267 | if notes_changed: 268 | self.last_notes_mtime = os.path.getmtime(notes_file) 269 | 270 | try: 271 | # Reload all data from files 272 | self.api.reload_all() 273 | 274 | # Refresh UI components with individual try-except blocks 275 | try: 276 | if tasks_changed: 277 | self.refresh_tasks() 278 | except Exception as e: 279 | # Silently handle task refresh error 280 | pass 281 | 282 | try: 283 | if plan_changed: 284 | self.refresh_plan() 285 | except Exception as e: 286 | # Silently handle plan refresh error 287 | pass 288 | 289 | try: 290 | if notes_changed: 291 | self.refresh_notes() 292 | except Exception as e: 293 | # Silently handle notes refresh error 294 | pass 295 | 296 | changes_detected = True 297 | except Exception as e: 298 | # If reload fails, try to continue without crashing 299 | pass 300 | 301 | return changes_detected 302 | except Exception as e: 303 | # Fail silently if file checking itself fails 304 | return False 305 | 306 | def update_focus(self, focus): 307 | """Update the UI focus.""" 308 | # Reset all titles first 309 | self.task_list_win.set_title("Tasks") 310 | self.task_detail_win.set_title("Task Details") 311 | self.plan_win.set_title("Project Plan") 312 | if self.notes_visible: 313 | self.notes_win.set_title("Notes") 314 | 315 | # Highlight the active window by changing its title 316 | if focus == FocusArea.TASKS: 317 | self.task_list_win.set_title("Tasks [Active]") 318 | elif focus == FocusArea.DETAILS: 319 | self.task_detail_win.set_title("Task Details [Active]") 320 | elif focus == FocusArea.PLAN: 321 | self.plan_win.set_title("Project Plan [Active]") 322 | elif focus == FocusArea.NOTES and self.notes_visible: 323 | self.notes_win.set_title("Notes [Active]") 324 | 325 | # Clear screen to remove artifacts 326 | self.stdscr.erase() 327 | self.stdscr.refresh() 328 | 329 | # Refresh the content of all windows 330 | self.task_list_win.refresh_content() 331 | self.task_detail_win.refresh_content() 332 | self.plan_win.refresh_content() 333 | 334 | # Only refresh notes if visible 335 | if self.notes_visible: 336 | self.notes_win.refresh_content() 337 | 338 | def show_input_dialog(self, title, prompts, initial_values=None): 339 | """Show an input dialog and return the entered values or None if canceled.""" 340 | dialog = InputDialog(self.stdscr, title, prompts, initial_values) 341 | result = dialog.show() 342 | 343 | # Redraw the entire screen after dialog closes 344 | self.stdscr.clear() 345 | self.stdscr.refresh() 346 | self._resize_layout() 347 | 348 | return result 349 | 350 | def show_confirm_dialog(self, title, message): 351 | """Show a confirmation dialog and return True if confirmed, False otherwise.""" 352 | dialog = ConfirmDialog(self.stdscr, title, message) 353 | result = dialog.show() 354 | 355 | # Redraw the entire screen after dialog closes 356 | self.stdscr.clear() 357 | self.stdscr.refresh() 358 | self._resize_layout() 359 | 360 | return result 361 | 362 | def show_message(self, message): 363 | """Show a temporary message at the bottom of the screen.""" 364 | screen_height, screen_width = self.stdscr.getmaxyx() 365 | 366 | # Create a small window for the message 367 | msg_height = 3 368 | msg_width = min(len(message) + 4, screen_width - 4) 369 | msg_y = (screen_height - msg_height) // 2 370 | msg_x = (screen_width - msg_width) // 2 371 | 372 | # Create message window 373 | msg_win = self.stdscr.subwin(msg_height, msg_width, msg_y, msg_x) 374 | msg_win.box() 375 | msg_win.addstr(1, 2, message[:msg_width - 4]) 376 | msg_win.addstr(msg_height - 1, 2, "Press any key to continue") 377 | msg_win.refresh() 378 | 379 | # Wait for a key press 380 | self.stdscr.getch() 381 | 382 | # Redraw the entire screen 383 | self.stdscr.clear() 384 | self.stdscr.refresh() 385 | self._resize_layout() ``` -------------------------------------------------------------------------------- /app/ui/input_handler.py: -------------------------------------------------------------------------------- ```python 1 | import curses 2 | from enum import Enum 3 | 4 | class FocusArea(Enum): 5 | TASKS = 0 6 | DETAILS = 1 7 | PLAN = 2 8 | NOTES = 3 9 | 10 | 11 | class InputHandler: 12 | def __init__(self, terminal_ui): 13 | """Initialize the input handler with a reference to the terminal UI.""" 14 | self.terminal_ui = terminal_ui 15 | self.focus = FocusArea.TASKS 16 | 17 | def handle_input(self, key): 18 | """ 19 | Handle keyboard input and dispatch to appropriate handlers. 20 | Returns True if the application should continue, False if it should exit. 21 | """ 22 | # If notes is in edit mode, we need special handling 23 | if self.focus == FocusArea.NOTES and self.terminal_ui.notes_win.edit_mode: 24 | # Escape exits edit mode in notes 25 | if key == 27: # Escape 26 | self.terminal_ui.notes_win.toggle_edit_mode() 27 | self.terminal_ui.save_notes() 28 | return True 29 | 30 | # All other keys are processed by the notes window 31 | try: 32 | self.terminal_ui.notes_win.handle_key(key) 33 | return True 34 | except Exception as e: 35 | self.terminal_ui.show_message(f"Error in notes edit: {str(e)}") 36 | return True 37 | 38 | # Global keys (work in any context) 39 | if key == 27: # Escape 40 | return self._handle_escape() 41 | elif key == 9: # Tab 42 | self._cycle_focus() 43 | return True 44 | elif key == 24: # Ctrl+X - toggle notes visibility (ASCII 24 is Ctrl+X) 45 | self.terminal_ui.toggle_notes_visibility() 46 | return True 47 | 48 | # Focus-specific input handling 49 | if self.focus == FocusArea.TASKS: 50 | return self._handle_tasks_input(key) 51 | elif self.focus == FocusArea.DETAILS: 52 | return self._handle_details_input(key) 53 | elif self.focus == FocusArea.PLAN: 54 | return self._handle_plan_input(key) 55 | elif self.focus == FocusArea.NOTES: 56 | return self._handle_notes_input(key) 57 | 58 | return True 59 | 60 | def _handle_escape(self): 61 | """Handle the escape key - confirm exit.""" 62 | # Reset timeout to blocking for the confirmation dialog 63 | self.terminal_ui.stdscr.timeout(-1) 64 | 65 | confirm = self.terminal_ui.show_confirm_dialog( 66 | "Exit Confirmation", 67 | "Are you sure you want to exit? Any unsaved changes will be lost." 68 | ) 69 | return not confirm # Return False to exit if confirmed 70 | 71 | def _cycle_focus(self): 72 | """Cycle through the focus areas.""" 73 | focus_order = list(FocusArea) 74 | current_idx = focus_order.index(self.focus) 75 | 76 | # Skip Notes focus if it's not visible 77 | if not self.terminal_ui.notes_visible: 78 | # Create a filtered list without the NOTES enum 79 | focus_order = [f for f in focus_order if f != FocusArea.NOTES] 80 | 81 | # Find the next focus in our (potentially filtered) list 82 | next_idx = (focus_order.index(self.focus) + 1) % len(focus_order) 83 | self.focus = focus_order[next_idx] 84 | 85 | # Update UI with new focus 86 | self.terminal_ui.update_focus(self.focus) 87 | 88 | # Force a complete UI redraw to fix rendering artifacts 89 | self.terminal_ui._resize_layout() 90 | 91 | def _handle_tasks_input(self, key): 92 | """Handle input while focused on the task list.""" 93 | if key == curses.KEY_UP: 94 | self.terminal_ui.task_list_win.select_prev() 95 | self._update_task_details() 96 | 97 | elif key == curses.KEY_DOWN: 98 | self.terminal_ui.task_list_win.select_next() 99 | self._update_task_details() 100 | 101 | elif key in (10, 13, curses.KEY_ENTER): # Enter (different codes) 102 | # Toggle completion status when Enter is pressed 103 | self._toggle_selected_task() 104 | self._update_task_details() 105 | 106 | elif key == ord(' '): # Space 107 | # Toggle completion status when Space is pressed 108 | self._toggle_selected_task() 109 | self._update_task_details() 110 | 111 | elif key == ord('n'): # New task 112 | self._new_task() 113 | 114 | elif key == ord('e'): # Edit task 115 | self._edit_task() 116 | 117 | elif key == ord('d'): # Delete task 118 | self._delete_task() 119 | 120 | return True 121 | 122 | def _handle_details_input(self, key): 123 | """Handle input while focused on the task details.""" 124 | # There's not much to do in the details view except view 125 | # Maybe implement scrolling for long descriptions later 126 | return True 127 | 128 | def _handle_notes_input(self, key): 129 | """Handle input while focused on the notes.""" 130 | if key == ord('e'): # Edit notes 131 | self.terminal_ui.notes_win.toggle_edit_mode() 132 | return True 133 | 134 | return True 135 | 136 | def _handle_plan_input(self, key): 137 | """Handle input while focused on the project plan.""" 138 | if key == curses.KEY_UP: 139 | self.terminal_ui.plan_win.select_prev() 140 | 141 | elif key == curses.KEY_DOWN: 142 | self.terminal_ui.plan_win.select_next() 143 | 144 | elif key in (10, 13, curses.KEY_ENTER): # Enter (different codes) 145 | # Toggle completion when Enter is pressed 146 | self._toggle_plan_step() 147 | 148 | elif key == ord(' '): # Toggle completion with Space 149 | self._toggle_plan_step() 150 | 151 | elif key == ord('d'): # Toggle details view 152 | self.terminal_ui.plan_win.toggle_details() 153 | 154 | elif key == ord('n'): # New plan step 155 | self._new_plan_step() 156 | 157 | elif key == ord('e'): # Edit plan step 158 | self._edit_plan_step() 159 | 160 | elif key == ord('D'): # Delete plan step (capital D to avoid conflict with details) 161 | self._delete_plan_step() 162 | 163 | return True 164 | 165 | def _update_task_details(self): 166 | """Update the task details window with the selected task.""" 167 | task = self.terminal_ui.task_list_win.get_selected_task() 168 | self.terminal_ui.task_detail_win.set_task(task) 169 | 170 | def _new_task(self): 171 | """Create a new task.""" 172 | prompts = ["Title", "Description", "Priority (1-3)"] 173 | values = self.terminal_ui.show_input_dialog("New Task", prompts) 174 | 175 | if values: 176 | title, description, priority_str = values 177 | 178 | # Validate priority 179 | try: 180 | priority = int(priority_str) if priority_str else 1 181 | if priority < 1 or priority > 3: 182 | priority = 1 183 | except ValueError: 184 | priority = 1 185 | 186 | # Add the task 187 | task = self.terminal_ui.api.add_task(title, description, priority) 188 | 189 | # Refresh task list 190 | self.terminal_ui.refresh_tasks() 191 | 192 | # Find and select the new task 193 | tasks = self.terminal_ui.api.get_all_tasks() 194 | for i, t in enumerate(tasks): 195 | if t["id"] == task["id"]: 196 | self.terminal_ui.task_list_win.selected_index = i 197 | self.terminal_ui.task_list_win.adjust_selection() 198 | self.terminal_ui.task_list_win.refresh_content() 199 | self._update_task_details() 200 | break 201 | 202 | def _edit_task(self): 203 | """Edit the selected task.""" 204 | task = self.terminal_ui.task_list_win.get_selected_task() 205 | if not task: 206 | return 207 | 208 | # Set up the edit dialog with current values 209 | prompts = ["Title", "Description", "Priority (1-3)", "Status"] 210 | values = [ 211 | task["title"], 212 | task["description"], 213 | str(task["priority"]), 214 | task["status"] 215 | ] 216 | 217 | new_values = self.terminal_ui.show_input_dialog("Edit Task", prompts, values) 218 | 219 | if new_values: 220 | title, description, priority_str, status = new_values 221 | 222 | # Validate priority 223 | try: 224 | priority = int(priority_str) if priority_str else task["priority"] 225 | if priority < 1 or priority > 3: 226 | priority = task["priority"] 227 | except ValueError: 228 | priority = task["priority"] 229 | 230 | # Validate status 231 | valid_statuses = ["not_started", "in_progress", "completed"] 232 | if status not in valid_statuses: 233 | status = task["status"] 234 | 235 | # Update the task 236 | self.terminal_ui.api.update_task( 237 | task["id"], 238 | title=title, 239 | description=description, 240 | priority=priority, 241 | status=status 242 | ) 243 | 244 | # Refresh task list and details 245 | self.terminal_ui.refresh_tasks() 246 | self._update_task_details() 247 | 248 | def _delete_task(self): 249 | """Delete the selected task.""" 250 | task = self.terminal_ui.task_list_win.get_selected_task() 251 | if not task: 252 | return 253 | 254 | confirm = self.terminal_ui.show_confirm_dialog( 255 | "Delete Task", 256 | f"Are you sure you want to delete the task '{task['title']}'?" 257 | ) 258 | 259 | if confirm: 260 | # Delete the task 261 | self.terminal_ui.api.delete_task(task["id"]) 262 | 263 | # Refresh task list 264 | self.terminal_ui.refresh_tasks() 265 | self._update_task_details() 266 | 267 | def _new_plan_step(self): 268 | """Create a new plan step.""" 269 | prompts = ["Name", "Description", "Details"] 270 | values = self.terminal_ui.show_input_dialog("New Plan Step", prompts) 271 | 272 | try: 273 | if values and values[0]: # At least the name should be provided 274 | # Add the plan step 275 | name = values[0] 276 | description = values[1] if len(values) > 1 else "" 277 | details = values[2] if len(values) > 2 else "" 278 | 279 | step = self.terminal_ui.api.add_plan_step( 280 | name=name, 281 | description=description, 282 | details=details 283 | ) 284 | 285 | # Refresh plan 286 | self.terminal_ui.refresh_plan() 287 | 288 | # Only try to find and select the new step if it was successfully created 289 | if step and isinstance(step, dict) and "id" in step: 290 | steps = self.terminal_ui.api.get_all_plan_steps() 291 | for i, s in enumerate(steps): 292 | if s["id"] == step["id"]: 293 | self.terminal_ui.plan_win.selected_index = i 294 | self.terminal_ui.plan_win.adjust_selection() 295 | self.terminal_ui.plan_win.refresh_content() 296 | break 297 | except Exception as e: 298 | self.terminal_ui.show_message(f"Error creating plan step: {str(e)}") 299 | 300 | def _edit_plan_step(self): 301 | """Edit the selected plan step.""" 302 | step = self.terminal_ui.plan_win.get_selected_step() 303 | if not step: 304 | return 305 | 306 | # Set up the edit dialog with current values 307 | prompts = ["Name", "Description", "Details", "Order"] 308 | values = [ 309 | step.get("name", step.get("description", "")), 310 | step.get("description", ""), 311 | step.get("details", ""), 312 | str(step.get("order", 0)) 313 | ] 314 | 315 | new_values = self.terminal_ui.show_input_dialog("Edit Plan Step", prompts, values) 316 | 317 | if new_values: 318 | # Extract and validate values 319 | name = new_values[0] if len(new_values) > 0 else "" 320 | description = new_values[1] if len(new_values) > 1 else "" 321 | details = new_values[2] if len(new_values) > 2 else "" 322 | order_str = new_values[3] if len(new_values) > 3 else "" 323 | 324 | # Validate order 325 | try: 326 | order = int(order_str) if order_str else step.get("order", 0) 327 | if order < 0: 328 | order = step.get("order", 0) 329 | except ValueError: 330 | order = step.get("order", 0) 331 | 332 | # Update the plan step 333 | self.terminal_ui.api.update_plan_step( 334 | step["id"], 335 | name=name, 336 | description=description, 337 | details=details, 338 | order=order 339 | ) 340 | 341 | # Refresh plan 342 | self.terminal_ui.refresh_plan() 343 | 344 | def _delete_plan_step(self): 345 | """Delete the selected plan step.""" 346 | step = self.terminal_ui.plan_win.get_selected_step() 347 | if not step: 348 | return 349 | 350 | confirm = self.terminal_ui.show_confirm_dialog( 351 | "Delete Plan Step", 352 | f"Are you sure you want to delete the plan step '{step['description']}'?" 353 | ) 354 | 355 | if confirm: 356 | # Delete the plan step 357 | self.terminal_ui.api.delete_plan_step(step["id"]) 358 | 359 | # Refresh plan 360 | self.terminal_ui.refresh_plan() 361 | 362 | def _toggle_selected_task(self): 363 | """Cycle through task statuses (not_started -> in_progress -> completed -> not_started).""" 364 | task = self.terminal_ui.task_list_win.get_selected_task() 365 | if not task: 366 | return 367 | 368 | # Cycle through the statuses 369 | current_status = task.get("status", "not_started") 370 | 371 | # If it's an old "pending" status, treat it as "not_started" 372 | if current_status == "pending": 373 | current_status = "not_started" 374 | 375 | status_cycle = { 376 | "not_started": "in_progress", 377 | "in_progress": "completed", 378 | "completed": "not_started" 379 | } 380 | 381 | new_status = status_cycle.get(current_status, "not_started") 382 | 383 | # Update the task 384 | self.terminal_ui.api.update_task( 385 | task["id"], 386 | status=new_status 387 | ) 388 | 389 | # Refresh task list 390 | self.terminal_ui.refresh_tasks() 391 | 392 | def _toggle_plan_step(self): 393 | """Toggle the completion status of the selected plan step.""" 394 | step = self.terminal_ui.plan_win.get_selected_step() 395 | if not step: 396 | return 397 | 398 | # Toggle the plan step 399 | self.terminal_ui.api.toggle_plan_step(step["id"]) 400 | 401 | # Refresh plan 402 | self.terminal_ui.refresh_plan() ``` -------------------------------------------------------------------------------- /mcp_server_fixed.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | MCP-compatible server for the Terminal Task Tracker 4 | 5 | This server exposes the task tracker functionality through the Model Context Protocol (MCP). 6 | """ 7 | import json 8 | import os 9 | import logging 10 | from typing import Dict, List, Optional, Any, Union, AsyncIterator 11 | from contextlib import asynccontextmanager 12 | from collections.abc import AsyncIterator 13 | 14 | from mcp.server.fastmcp import FastMCP, Context, Image 15 | from app.core.task_manager import TaskManager 16 | from app.core.plan_manager import PlanManager 17 | from app.api.api import TaskTrackerAPI 18 | 19 | # Set up logging 20 | logging.basicConfig(level=logging.INFO, 21 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 22 | logger = logging.getLogger(__name__) 23 | 24 | # Initialize data files 25 | home_dir = os.path.expanduser("~") 26 | data_dir = os.path.join(home_dir, ".tasktracker") 27 | os.makedirs(data_dir, exist_ok=True) 28 | task_file = os.path.join(data_dir, "tasks.json") 29 | plan_file = os.path.join(data_dir, "plan.json") 30 | notes_file = os.path.join(data_dir, "notes.txt") 31 | 32 | # Global variable for API access from resources without URI parameters 33 | global_api = None 34 | 35 | # Set up lifespan context manager for the MCP server 36 | @asynccontextmanager 37 | async def lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 38 | """ 39 | Initialize and manage the task and plan managers for the server's lifespan. 40 | 41 | This ensures that we have a single consistent instance of the managers 42 | throughout the server's lifecycle. 43 | """ 44 | logger.info(f"Starting TaskTracker server with data directory: {data_dir}") 45 | 46 | # Initialize managers with explicit file paths 47 | task_manager = TaskManager(task_file, notes_file) 48 | plan_manager = PlanManager(plan_file) 49 | 50 | # Create API 51 | api = TaskTrackerAPI(task_manager, plan_manager) 52 | 53 | # Set global API for resources without URI parameters 54 | global global_api 55 | global_api = api 56 | 57 | try: 58 | # Yield the API instance to the server 59 | yield {"api": api} 60 | finally: 61 | # Ensure all data is saved on shutdown 62 | logger.info("Shutting down TaskTracker server, saving all data") 63 | api.save_all() 64 | 65 | # Create an MCP server with the lifespan manager 66 | mcp = FastMCP("TaskTracker", lifespan=lifespan) 67 | 68 | 69 | # === Resources === 70 | 71 | @mcp.resource("tasks://all") 72 | def get_all_tasks() -> str: 73 | """Get all tasks in the system as JSON.""" 74 | # Reload data from files first to ensure we have latest changes 75 | global_api.reload_all() 76 | tasks = global_api.get_all_tasks() 77 | return json.dumps(tasks, indent=2) 78 | 79 | 80 | @mcp.resource("tasks://{task_id}") 81 | def get_task(task_id: str) -> str: 82 | """Get a specific task by ID.""" 83 | # Reload data from files first to ensure we have latest changes 84 | global_api.reload_all() 85 | task = global_api.get_task(task_id) 86 | if task: 87 | return json.dumps(task, indent=2) 88 | return "Task not found" 89 | 90 | 91 | @mcp.resource("plan://all") 92 | def get_all_plan_steps() -> str: 93 | """Get all plan steps in the system as JSON.""" 94 | # Reload data from files first to ensure we have latest changes 95 | global_api.reload_all() 96 | steps = global_api.get_all_plan_steps() 97 | return json.dumps(steps, indent=2) 98 | 99 | 100 | @mcp.resource("plan://{step_id}") 101 | def get_plan_step(step_id: str) -> str: 102 | """Get a specific plan step by ID.""" 103 | # Reload data from files first to ensure we have latest changes 104 | global_api.reload_all() 105 | step = global_api.get_plan_step(step_id) 106 | if step: 107 | return json.dumps(step, indent=2) 108 | return "Plan step not found" 109 | 110 | 111 | @mcp.resource("notes://all") 112 | def get_notes() -> str: 113 | """Get all notes in the system.""" 114 | # Reload data from files first to ensure we have latest changes 115 | global_api.reload_all() 116 | return global_api.get_notes() 117 | 118 | 119 | # === Tools === 120 | 121 | @mcp.tool() 122 | def get_all_tasks_tool(ctx: Context) -> List[Dict[str, Any]]: 123 | """ 124 | Get all tasks in the system. 125 | 126 | Returns: 127 | List of all tasks 128 | """ 129 | api = ctx.request_context.lifespan_context["api"] 130 | # Reload data from files first to ensure we have latest changes 131 | api.reload_all() 132 | return api.get_all_tasks() 133 | 134 | 135 | @mcp.tool() 136 | def get_task_tool(task_id: str, ctx: Context) -> Dict[str, Any]: 137 | """ 138 | Get a specific task by ID. 139 | 140 | Args: 141 | task_id: The ID of the task to retrieve 142 | 143 | Returns: 144 | The task or an error message if not found 145 | """ 146 | api = ctx.request_context.lifespan_context["api"] 147 | # Reload data from files first to ensure we have latest changes 148 | api.reload_all() 149 | task = api.get_task(task_id) 150 | if task: 151 | return task 152 | return {"error": "Task not found"} 153 | 154 | 155 | @mcp.tool() 156 | def get_all_plan_steps_tool(ctx: Context) -> List[Dict[str, Any]]: 157 | """ 158 | Get all plan steps in the system. 159 | 160 | Returns: 161 | List of all plan steps 162 | """ 163 | api = ctx.request_context.lifespan_context["api"] 164 | # Reload data from files first to ensure we have latest changes 165 | api.reload_all() 166 | return api.get_all_plan_steps() 167 | 168 | 169 | @mcp.tool() 170 | def get_plan_step_tool(step_id: str, ctx: Context) -> Dict[str, Any]: 171 | """ 172 | Get a specific plan step by ID. 173 | 174 | Args: 175 | step_id: The ID of the plan step to retrieve 176 | 177 | Returns: 178 | The plan step or an error message if not found 179 | """ 180 | api = ctx.request_context.lifespan_context["api"] 181 | # Reload data from files first to ensure we have latest changes 182 | api.reload_all() 183 | step = api.get_plan_step(step_id) 184 | if step: 185 | return step 186 | return {"error": "Plan step not found"} 187 | 188 | 189 | @mcp.tool() 190 | def get_notes_tool(ctx: Context) -> str: 191 | """ 192 | Get all notes in the system. 193 | 194 | Returns: 195 | The notes text 196 | """ 197 | api = ctx.request_context.lifespan_context["api"] 198 | # Reload data from files first to ensure we have latest changes 199 | api.reload_all() 200 | return api.get_notes() 201 | 202 | 203 | @mcp.tool() 204 | def add_task(title: str, ctx: Context, description: str = "", priority: int = 1, 205 | status: str = "not_started") -> Dict[str, Any]: 206 | """ 207 | Add a new task to the system. 208 | 209 | Args: 210 | title: The title of the task 211 | ctx: The MCP context object 212 | description: A detailed description of the task 213 | priority: Priority level (1-3, with 1 being highest) 214 | status: Current status (not_started, in_progress, completed) 215 | 216 | Returns: 217 | The newly created task 218 | """ 219 | api = ctx.request_context.lifespan_context["api"] 220 | # Reload data from files first to ensure we have latest changes 221 | api.reload_all() 222 | task = api.add_task(title, description, priority, status) 223 | api.save_all() 224 | logger.info(f"Added task: {title} (ID: {task['id']})") 225 | return task 226 | 227 | 228 | @mcp.tool() 229 | def update_task(task_id: str, ctx: Context, title: Optional[str] = None, 230 | description: Optional[str] = None, priority: Optional[int] = None, 231 | status: Optional[str] = None) -> Dict[str, Any]: 232 | """ 233 | Update an existing task. 234 | 235 | Args: 236 | task_id: The ID of the task to update 237 | title: New title (optional) 238 | description: New description (optional) 239 | priority: New priority (optional) 240 | status: New status (optional) 241 | 242 | Returns: 243 | The updated task or None if task not found 244 | """ 245 | api = ctx.request_context.lifespan_context["api"] 246 | # Reload data from files first to ensure we have latest changes 247 | api.reload_all() 248 | 249 | kwargs = {} 250 | if title is not None: 251 | kwargs["title"] = title 252 | if description is not None: 253 | kwargs["description"] = description 254 | if priority is not None: 255 | kwargs["priority"] = priority 256 | if status is not None: 257 | kwargs["status"] = status 258 | 259 | task = api.update_task(task_id, **kwargs) 260 | if task: 261 | api.save_all() 262 | logger.info(f"Updated task ID: {task_id}") 263 | else: 264 | logger.warning(f"Failed to update task: {task_id} - Not found") 265 | return task or {"error": "Task not found"} 266 | 267 | 268 | @mcp.tool() 269 | def delete_task(task_id: str, ctx: Context) -> Dict[str, Any]: 270 | """ 271 | Delete a task. 272 | 273 | Args: 274 | task_id: The ID of the task to delete 275 | 276 | Returns: 277 | Success or failure message 278 | """ 279 | api = ctx.request_context.lifespan_context["api"] 280 | # Reload data from files first to ensure we have latest changes 281 | api.reload_all() 282 | 283 | result = api.delete_task(task_id) 284 | if result: 285 | api.save_all() 286 | logger.info(f"Deleted task ID: {task_id}") 287 | return {"success": True, "message": "Task deleted successfully"} 288 | logger.warning(f"Failed to delete task: {task_id} - Not found") 289 | return {"success": False, "message": "Task not found"} 290 | 291 | 292 | @mcp.tool() 293 | def add_plan_step(name: str, ctx: Context, description: str = "", details: str = "", 294 | order: Optional[int] = None, completed: bool = False) -> Dict[str, Any]: 295 | """ 296 | Add a new plan step. 297 | 298 | Args: 299 | name: The name of the plan step 300 | description: A brief description 301 | details: Detailed information about the step 302 | order: Position in the plan (optional) 303 | completed: Whether the step is completed 304 | 305 | Returns: 306 | The newly created plan step 307 | """ 308 | api = ctx.request_context.lifespan_context["api"] 309 | # Reload data from files first to ensure we have latest changes 310 | api.reload_all() 311 | 312 | step = api.add_plan_step(name, description, details, order, completed) 313 | api.save_all() 314 | logger.info(f"Added plan step: {name} (ID: {step['id']})") 315 | return step 316 | 317 | 318 | @mcp.tool() 319 | def update_plan_step(step_id: str, ctx: Context, name: Optional[str] = None, 320 | description: Optional[str] = None, details: Optional[str] = None, 321 | order: Optional[int] = None, completed: Optional[bool] = None) -> Dict[str, Any]: 322 | """ 323 | Update an existing plan step. 324 | 325 | Args: 326 | step_id: The ID of the step to update 327 | name: New name (optional) 328 | description: New description (optional) 329 | details: New details (optional) 330 | order: New order (optional) 331 | completed: New completion status (optional) 332 | 333 | Returns: 334 | The updated plan step or None if not found 335 | """ 336 | api = ctx.request_context.lifespan_context["api"] 337 | # Reload data from files first to ensure we have latest changes 338 | api.reload_all() 339 | 340 | kwargs = {} 341 | if name is not None: 342 | kwargs["name"] = name 343 | if description is not None: 344 | kwargs["description"] = description 345 | if details is not None: 346 | kwargs["details"] = details 347 | if order is not None: 348 | kwargs["order"] = order 349 | if completed is not None: 350 | kwargs["completed"] = completed 351 | 352 | step = api.update_plan_step(step_id, **kwargs) 353 | if step: 354 | api.save_all() 355 | logger.info(f"Updated plan step ID: {step_id}") 356 | else: 357 | logger.warning(f"Failed to update plan step: {step_id} - Not found") 358 | return step or {"error": "Plan step not found"} 359 | 360 | 361 | @mcp.tool() 362 | def delete_plan_step(step_id: str, ctx: Context) -> Dict[str, Any]: 363 | """ 364 | Delete a plan step. 365 | 366 | Args: 367 | step_id: The ID of the step to delete 368 | 369 | Returns: 370 | Success or failure message 371 | """ 372 | api = ctx.request_context.lifespan_context["api"] 373 | # Reload data from files first to ensure we have latest changes 374 | api.reload_all() 375 | 376 | result = api.delete_plan_step(step_id) 377 | if result: 378 | api.save_all() 379 | logger.info(f"Deleted plan step ID: {step_id}") 380 | return {"success": True, "message": "Plan step deleted successfully"} 381 | logger.warning(f"Failed to delete plan step: {step_id} - Not found") 382 | return {"success": False, "message": "Plan step not found"} 383 | 384 | 385 | @mcp.tool() 386 | def toggle_plan_step(step_id: str, ctx: Context) -> Dict[str, Any]: 387 | """ 388 | Toggle the completion status of a plan step. 389 | 390 | Args: 391 | step_id: The ID of the step to toggle 392 | 393 | Returns: 394 | The updated plan step or None if not found 395 | """ 396 | api = ctx.request_context.lifespan_context["api"] 397 | # Reload data from files first to ensure we have latest changes 398 | api.reload_all() 399 | 400 | step = api.toggle_plan_step(step_id) 401 | if step: 402 | api.save_all() 403 | logger.info(f"Toggled completion status of plan step ID: {step_id} to {step['completed']}") 404 | else: 405 | logger.warning(f"Failed to toggle plan step: {step_id} - Not found") 406 | return step or {"error": "Plan step not found"} 407 | 408 | 409 | @mcp.tool() 410 | def save_notes(notes_text: str, ctx: Context) -> Dict[str, Any]: 411 | """ 412 | Save notes to the system. 413 | 414 | Args: 415 | notes_text: The notes text to save 416 | 417 | Returns: 418 | Success message 419 | """ 420 | api = ctx.request_context.lifespan_context["api"] 421 | # Reload data from files first to ensure we have latest changes 422 | api.reload_all() 423 | 424 | api.save_notes(notes_text) 425 | api.save_all() 426 | logger.info("Notes saved") 427 | return {"success": True, "message": "Notes saved successfully"} 428 | 429 | 430 | @mcp.tool() 431 | def export_data(file_path: str, ctx: Context) -> Dict[str, Any]: 432 | """ 433 | Export all data to a JSON file. 434 | 435 | Args: 436 | file_path: Path to save the exported data 437 | 438 | Returns: 439 | Success or failure message 440 | """ 441 | api = ctx.request_context.lifespan_context["api"] 442 | # Reload data from files first to ensure we have latest changes 443 | api.reload_all() 444 | 445 | try: 446 | api.export_data(file_path) 447 | logger.info(f"Data exported to {file_path}") 448 | return {"success": True, "message": f"Data exported to {file_path}"} 449 | except Exception as e: 450 | logger.error(f"Export failed: {str(e)}") 451 | return {"success": False, "message": f"Export failed: {str(e)}"} 452 | 453 | 454 | @mcp.tool() 455 | def import_data(file_path: str, ctx: Context) -> Dict[str, Any]: 456 | """ 457 | Import data from a JSON file. 458 | 459 | Args: 460 | file_path: Path to the file containing the data to import 461 | 462 | Returns: 463 | Success or failure message 464 | """ 465 | api = ctx.request_context.lifespan_context["api"] 466 | # Reload data from files first to ensure we have latest changes 467 | api.reload_all() 468 | 469 | try: 470 | result = api.import_data(file_path) 471 | if result: 472 | logger.info(f"Data imported from {file_path}") 473 | return {"success": True, "message": "Data imported successfully"} 474 | logger.warning(f"Import failed from {file_path}") 475 | return {"success": False, "message": "Import failed"} 476 | except Exception as e: 477 | logger.error(f"Import failed: {str(e)}") 478 | return {"success": False, "message": f"Import failed: {str(e)}"} 479 | 480 | 481 | # === Prompts === 482 | 483 | @mcp.prompt() 484 | def add_task_prompt(title: str = "", description: str = "") -> str: 485 | """Create a prompt to add a new task.""" 486 | return f"""Please add a new task with the following details: 487 | Title: {title} 488 | Description: {description} 489 | 490 | Please provide any missing information and set the priority and status. 491 | """ 492 | 493 | 494 | @mcp.prompt() 495 | def create_plan_prompt() -> str: 496 | """Create a prompt to help create a new project plan.""" 497 | return """I need to create a new project plan. Please help me break down this project into clear steps. 498 | 499 | For each step, I need: 500 | 1. A clear name 501 | 2. A brief description 502 | 3. Any detailed information needed to complete the step 503 | 4. The logical order of the steps 504 | 505 | Please ask me about my project goals so you can help create an appropriate plan. 506 | """ 507 | 508 | 509 | # Define a main function for entry point 510 | def main(): 511 | """Run the MCP server.""" 512 | mcp.run() 513 | 514 | 515 | # Run the server if executed directly 516 | if __name__ == "__main__": 517 | main() ``` -------------------------------------------------------------------------------- /app/ui/ui_components.py: -------------------------------------------------------------------------------- ```python 1 | import curses 2 | 3 | class Window: 4 | def __init__(self, stdscr, height, width, y, x, title=""): 5 | """Initialize a window with a border and optional title.""" 6 | self.win = stdscr.subwin(height, width, y, x) 7 | self.height = height 8 | self.width = width 9 | self.title = title 10 | self.win.box() 11 | self.set_title(title) 12 | self.content_window = self.win.derwin(height - 2, width - 2, 1, 1) 13 | self.selected_index = 0 14 | self.scroll_offset = 0 15 | self.max_visible_items = height - 2 16 | 17 | def set_title(self, title): 18 | """Set the window title.""" 19 | self.title = title 20 | if title: 21 | title_str = f" {title} " 22 | x = max(1, (self.width - len(title_str)) // 2) 23 | try: 24 | self.win.addstr(0, x, title_str) 25 | except curses.error: 26 | # Fall back to a simpler title if there's an error 27 | try: 28 | self.win.addstr(0, 1, "Window") 29 | except: 30 | pass 31 | 32 | def clear(self): 33 | """Clear the content window.""" 34 | self.content_window.clear() 35 | 36 | def refresh(self): 37 | """Refresh the window and its content.""" 38 | self.win.box() 39 | self.set_title(self.title) 40 | self.win.refresh() 41 | self.content_window.refresh() 42 | 43 | def get_content_dimensions(self): 44 | """Get the usable dimensions of the content window.""" 45 | return self.height - 2, self.width - 2 46 | 47 | def resize(self, height, width, y, x): 48 | """Resize and move the window.""" 49 | self.height = height 50 | self.width = width 51 | self.win.resize(height, width) 52 | self.win.mvwin(y, x) 53 | self.content_window = self.win.derwin(height - 2, width - 2, 1, 1) 54 | self.max_visible_items = height - 2 55 | self.refresh() 56 | 57 | def display_message(self, message): 58 | """Display a message in the content window.""" 59 | self.clear() 60 | self.content_window.addstr(0, 0, message) 61 | self.refresh() 62 | 63 | 64 | class TaskListWindow(Window): 65 | def __init__(self, stdscr, height, width, y, x, title="Tasks"): 66 | """Initialize a task list window.""" 67 | super().__init__(stdscr, height, width, y, x, title) 68 | self.tasks = [] 69 | 70 | def set_tasks(self, tasks): 71 | """Set the tasks to display.""" 72 | self.tasks = tasks 73 | self.adjust_selection() 74 | self.refresh_content() 75 | 76 | def adjust_selection(self): 77 | """Adjust selection index and scroll offset to valid values.""" 78 | if not self.tasks: 79 | self.selected_index = 0 80 | self.scroll_offset = 0 81 | return 82 | 83 | # Ensure selected_index is in valid range 84 | if self.selected_index >= len(self.tasks): 85 | self.selected_index = len(self.tasks) - 1 86 | 87 | # Adjust scroll offset to keep selected item visible 88 | if self.selected_index < self.scroll_offset: 89 | self.scroll_offset = self.selected_index 90 | elif self.selected_index >= self.scroll_offset + self.max_visible_items: 91 | self.scroll_offset = self.selected_index - self.max_visible_items + 1 92 | 93 | def refresh_content(self): 94 | """Refresh the task list content.""" 95 | self.clear() 96 | content_height, content_width = self.get_content_dimensions() 97 | 98 | if not self.tasks: 99 | self.content_window.addstr(0, 0, "No tasks") 100 | self.refresh() 101 | return 102 | 103 | # Display visible tasks 104 | for i in range(min(self.max_visible_items, len(self.tasks))): 105 | idx = i + self.scroll_offset 106 | if idx >= len(self.tasks): 107 | break 108 | 109 | task = self.tasks[idx] 110 | 111 | # Highlight selected task 112 | if idx == self.selected_index: 113 | self.content_window.attron(curses.A_REVERSE) 114 | 115 | # Format priority indicator 116 | priority_markers = ["!", "!!", "!!!"] 117 | priority_str = priority_markers[task['priority'] - 1] if 1 <= task['priority'] <= 3 else "" 118 | 119 | # Format status with icons (using ASCII only to avoid Unicode display issues) 120 | status_map = { 121 | "not_started": "[ ]", # Empty square 122 | "in_progress": "[>]", # Right arrow (in progress) - ASCII version 123 | "completed": "[x]", # Checkmark - ASCII version 124 | # For backward compatibility 125 | "pending": "[ ]" 126 | } 127 | status_str = status_map.get(task['status'], "[ ]") 128 | 129 | # Truncate title if needed 130 | max_title_width = content_width - len(priority_str) - len(status_str) - 2 131 | title = task['title'] 132 | if len(title) > max_title_width: 133 | title = title[:max_title_width-3] + "..." 134 | 135 | # Display task line with error handling 136 | task_str = f"{status_str} {title} {priority_str}" 137 | try: 138 | self.content_window.addstr(i, 0, task_str) 139 | except curses.error: 140 | # Handle display errors gracefully 141 | try: 142 | # Try with a simpler string 143 | self.content_window.addstr(i, 0, f"Task {i+1}") 144 | except: 145 | pass 146 | 147 | if idx == self.selected_index: 148 | self.content_window.attroff(curses.A_REVERSE) 149 | 150 | self.refresh() 151 | 152 | def select_next(self): 153 | """Select the next task if available.""" 154 | if self.tasks and self.selected_index < len(self.tasks) - 1: 155 | self.selected_index += 1 156 | self.adjust_selection() 157 | self.refresh_content() 158 | 159 | def select_prev(self): 160 | """Select the previous task if available.""" 161 | if self.tasks and self.selected_index > 0: 162 | self.selected_index -= 1 163 | self.adjust_selection() 164 | self.refresh_content() 165 | 166 | def get_selected_task(self): 167 | """Get the currently selected task.""" 168 | if self.tasks and 0 <= self.selected_index < len(self.tasks): 169 | return self.tasks[self.selected_index] 170 | return None 171 | 172 | 173 | class TaskDetailWindow(Window): 174 | def __init__(self, stdscr, height, width, y, x, title="Task Details"): 175 | """Initialize a task detail window.""" 176 | super().__init__(stdscr, height, width, y, x, title) 177 | self.task = None 178 | 179 | def set_task(self, task): 180 | """Set the task to display details for.""" 181 | self.task = task 182 | self.refresh_content() 183 | 184 | def refresh_content(self): 185 | """Refresh the task detail content.""" 186 | self.clear() 187 | 188 | if not self.task: 189 | self.content_window.addstr(0, 0, "No task selected") 190 | self.refresh() 191 | return 192 | 193 | # Display task properties 194 | content_height, content_width = self.get_content_dimensions() 195 | 196 | # Map priority and status to more readable forms 197 | priority_map = {1: "Low", 2: "Medium", 3: "High"} 198 | status_map = { 199 | "not_started": "Not Started", 200 | "in_progress": "In Progress", 201 | "completed": "Completed", 202 | # For backward compatibility 203 | "pending": "Not Started" 204 | } 205 | 206 | priority = priority_map.get(self.task['priority'], "Unknown") 207 | status = status_map.get(self.task['status'], "Unknown") 208 | 209 | # Display task details 210 | y = 0 211 | self.content_window.addstr(y, 0, f"Title: {self.task['title']}") 212 | y += 2 213 | 214 | self.content_window.addstr(y, 0, f"Status: {status}") 215 | y += 1 216 | self.content_window.addstr(y, 0, f"Priority: {priority}") 217 | y += 2 218 | 219 | # Display description with word wrapping 220 | self.content_window.addstr(y, 0, "Description:") 221 | y += 1 222 | 223 | description = self.task['description'] or "No description provided." 224 | words = description.split() 225 | 226 | if words: 227 | line = "" 228 | for word in words: 229 | # Check if adding this word would exceed width 230 | if len(line) + len(word) + 1 > content_width: 231 | self.content_window.addstr(y, 0, line) 232 | y += 1 233 | line = word 234 | else: 235 | if line: 236 | line += " " + word 237 | else: 238 | line = word 239 | 240 | # Add the last line if it has content 241 | if line: 242 | self.content_window.addstr(y, 0, line) 243 | y += 1 244 | 245 | # Display created/updated timestamps 246 | y += 1 247 | created = self.task.get('created_at', '').split('T')[0] 248 | updated = self.task.get('updated_at', '').split('T')[0] 249 | 250 | if created: 251 | self.content_window.addstr(y, 0, f"Created: {created}") 252 | y += 1 253 | if updated and updated != created: 254 | self.content_window.addstr(y, 0, f"Updated: {updated}") 255 | 256 | self.refresh() 257 | 258 | 259 | class NotesWindow(Window): 260 | def __init__(self, stdscr, height, width, y, x, title="Notes"): 261 | """Initialize a notes window.""" 262 | super().__init__(stdscr, height, width, y, x, title) 263 | self.notes = "" 264 | self.edit_mode = False 265 | self.cursor_pos = 0 266 | self.scroll_offset = 0 267 | 268 | # Enable keypad for special key handling 269 | self.content_window.keypad(True) 270 | 271 | def set_notes(self, notes): 272 | """Set the notes to display.""" 273 | self.notes = notes if notes else "" 274 | self.refresh_content() 275 | 276 | def get_notes(self): 277 | """Get the current notes.""" 278 | return self.notes 279 | 280 | def toggle_edit_mode(self): 281 | """Toggle between view and edit mode.""" 282 | self.edit_mode = not self.edit_mode 283 | if self.edit_mode: 284 | curses.curs_set(1) # Show cursor 285 | else: 286 | curses.curs_set(0) # Hide cursor 287 | self.refresh_content() 288 | return self.edit_mode 289 | 290 | def handle_key(self, key): 291 | """Handle keyboard input in edit mode.""" 292 | if not self.edit_mode: 293 | return False 294 | 295 | try: 296 | # Simple key handling - safer approach 297 | if key in (10, 13, curses.KEY_ENTER): # Enter 298 | # Add a newline at end for simplicity 299 | self.notes += "\n" 300 | 301 | elif key in (curses.KEY_BACKSPACE, 127, 8): # Backspace 302 | # Remove last character if there are any 303 | if len(self.notes) > 0: 304 | self.notes = self.notes[:-1] 305 | 306 | elif 32 <= key <= 126: # Printable ASCII characters 307 | # Add character to the end 308 | self.notes += chr(key) 309 | 310 | # Refresh after any change 311 | self.refresh_content() 312 | return True 313 | 314 | except Exception as e: 315 | # Log error by adding to notes 316 | self.notes += f"\nError: {str(e)}\n" 317 | self.refresh_content() 318 | return True 319 | 320 | def adjust_scroll(self): 321 | """Adjust scroll offset to keep cursor visible.""" 322 | content_height, content_width = self.get_content_dimensions() 323 | 324 | # Count lines up to cursor 325 | lines_to_cursor = self.notes[:self.cursor_pos].count('\n') 326 | 327 | # Adjust scroll if cursor is off screen 328 | if lines_to_cursor < self.scroll_offset: 329 | self.scroll_offset = lines_to_cursor 330 | elif lines_to_cursor >= self.scroll_offset + content_height: 331 | self.scroll_offset = lines_to_cursor - content_height + 1 332 | 333 | def refresh_content(self): 334 | """Refresh the notes content.""" 335 | try: 336 | self.clear() 337 | content_height, content_width = self.get_content_dimensions() 338 | 339 | # Simplified content display 340 | if not self.notes: 341 | if self.edit_mode: 342 | self.content_window.addstr(0, 0, "Type to add notes...") 343 | else: 344 | self.content_window.addstr(0, 0, "No notes. Press 'e' to edit.") 345 | else: 346 | # Just display the most recent part of notes (last few lines) 347 | lines = self.notes.split('\n') 348 | 349 | # Display only what fits in the window 350 | max_lines = min(content_height - 1, len(lines)) 351 | start_line = max(0, len(lines) - max_lines) 352 | 353 | for i in range(max_lines): 354 | line_idx = start_line + i 355 | if line_idx < len(lines): 356 | # Truncate line if needed 357 | display_line = lines[line_idx] 358 | if len(display_line) > content_width - 1: 359 | display_line = display_line[:content_width - 1] 360 | 361 | self.content_window.addstr(i, 0, display_line) 362 | 363 | # Add help text at bottom 364 | if self.edit_mode and content_height > 1: 365 | help_text = "Esc: Save & exit edit mode" 366 | if len(help_text) > content_width - 1: 367 | help_text = help_text[:content_width - 1] 368 | self.content_window.addstr(content_height - 1, 0, help_text) 369 | 370 | # In edit mode, position cursor at the end of content 371 | if self.edit_mode: 372 | # Count displayed lines to find end position 373 | line_count = min(max_lines if 'max_lines' in locals() else 0, content_height - 1) 374 | if line_count > 0: 375 | self.content_window.move(line_count - 1, 0) 376 | else: 377 | self.content_window.move(0, 0) 378 | 379 | self.refresh() 380 | except Exception as e: 381 | # If there's an error, try a minimal refresh 382 | try: 383 | self.clear() 384 | self.content_window.addstr(0, 0, "Notes") 385 | self.refresh() 386 | except: 387 | pass 388 | 389 | 390 | class PlanWindow(Window): 391 | def __init__(self, stdscr, height, width, y, x, title="Project Plan"): 392 | """Initialize a project plan window.""" 393 | super().__init__(stdscr, height, width, y, x, title) 394 | self.steps = [] 395 | self.selected_index = 0 396 | self.scroll_offset = 0 397 | self.show_details = False # Flag to control if details are shown 398 | 399 | def set_steps(self, steps): 400 | """Set the plan steps to display.""" 401 | self.steps = steps 402 | self.adjust_selection() 403 | self.refresh_content() 404 | 405 | def adjust_selection(self): 406 | """Adjust selection index and scroll offset to valid values.""" 407 | if not self.steps: 408 | self.selected_index = 0 409 | self.scroll_offset = 0 410 | return 411 | 412 | # Ensure selected_index is in valid range 413 | if self.selected_index < 0: 414 | self.selected_index = 0 415 | elif self.selected_index >= len(self.steps): 416 | self.selected_index = max(0, len(self.steps) - 1) 417 | 418 | # Adjust scroll offset to keep selected item visible 419 | if self.selected_index < self.scroll_offset: 420 | self.scroll_offset = self.selected_index 421 | elif self.selected_index >= self.scroll_offset + self.max_visible_items: 422 | self.scroll_offset = max(0, self.selected_index - self.max_visible_items + 1) 423 | 424 | def refresh_content(self): 425 | """Refresh the plan content.""" 426 | self.clear() 427 | content_height, content_width = self.get_content_dimensions() 428 | 429 | if not self.steps: 430 | self.content_window.addstr(0, 0, "No plan steps") 431 | self.refresh() 432 | return 433 | 434 | selected_step = self.get_selected_step() 435 | 436 | # If showing details for the selected step 437 | if self.show_details and selected_step: 438 | self._display_step_details(selected_step, content_height, content_width) 439 | return 440 | 441 | # Otherwise display the list of steps 442 | list_height = min(content_height, len(self.steps)) 443 | 444 | # Display visible steps 445 | for i in range(min(self.max_visible_items, len(self.steps))): 446 | idx = i + self.scroll_offset 447 | if idx >= len(self.steps): 448 | break 449 | 450 | try: 451 | step = self.steps[idx] 452 | 453 | # Highlight selected step 454 | if idx == self.selected_index: 455 | self.content_window.attron(curses.A_REVERSE) 456 | 457 | # Format step with order and completion status 458 | completion_status = "[x]" if step['completed'] else "[ ]" 459 | 460 | # Get name or fallback to description for backward compatibility 461 | name = step.get('name', step.get('description', 'Unnamed step')) 462 | 463 | # Truncate name if needed 464 | max_name_width = content_width - 10 465 | if len(name) > max_name_width: 466 | name = name[:max_name_width-3] + "..." 467 | 468 | # Display step line with safe index access 469 | order = step.get('order', 0) 470 | step_str = f"{order + 1:2d}. {completion_status} {name}" 471 | try: 472 | self.content_window.addstr(i, 0, step_str) 473 | except curses.error: 474 | # Handle display errors gracefully 475 | try: 476 | # Try with a simpler string 477 | self.content_window.addstr(i, 0, f"Step {order + 1}") 478 | except: 479 | pass 480 | 481 | if idx == self.selected_index: 482 | self.content_window.attroff(curses.A_REVERSE) 483 | except (IndexError, KeyError) as e: 484 | # Handle any index errors gracefully 485 | self.content_window.addstr(i, 0, f"Error displaying step: {str(e)}") 486 | 487 | # Add a help line at the bottom if there's space 488 | if content_height > list_height + 1: 489 | help_text = "Enter/Space: Toggle completion | D: Show/hide details" 490 | try: 491 | self.content_window.addstr(content_height - 1, 0, help_text) 492 | except curses.error: 493 | pass # Skip help text if it doesn't fit 494 | 495 | self.refresh() 496 | 497 | def _display_step_details(self, step, height, width): 498 | """Display detailed information for a plan step.""" 499 | y = 0 500 | 501 | # Display step name 502 | name = step.get('name', 'Unnamed step') 503 | self.content_window.addstr(y, 0, f"Name: {name}") 504 | y += 2 505 | 506 | # Display completion status 507 | completed = "Completed" if step.get('completed', False) else "Not completed" 508 | self.content_window.addstr(y, 0, f"Status: {completed}") 509 | y += 2 510 | 511 | # Display description 512 | description = step.get('description', '') 513 | if description: 514 | self.content_window.addstr(y, 0, "Description:") 515 | y += 1 516 | 517 | # Word wrap description 518 | words = description.split() 519 | line = "" 520 | for word in words: 521 | if len(line) + len(word) + 1 > width: 522 | self.content_window.addstr(y, 0, line) 523 | y += 1 524 | line = word 525 | else: 526 | if line: 527 | line += " " + word 528 | else: 529 | line = word 530 | 531 | if line: 532 | self.content_window.addstr(y, 0, line) 533 | y += 1 534 | 535 | y += 1 536 | 537 | # Display detailed information 538 | details = step.get('details', '') 539 | if details: 540 | self.content_window.addstr(y, 0, "Details:") 541 | y += 1 542 | 543 | # Word wrap details 544 | words = details.split() 545 | line = "" 546 | for word in words: 547 | if len(line) + len(word) + 1 > width: 548 | self.content_window.addstr(y, 0, line) 549 | y += 1 550 | line = word 551 | else: 552 | if line: 553 | line += " " + word 554 | else: 555 | line = word 556 | 557 | if line: 558 | self.content_window.addstr(y, 0, line) 559 | y += 1 560 | 561 | # Add a help line at the bottom 562 | help_text = "D: Return to plan list" 563 | self.content_window.addstr(height - 1, 0, help_text) 564 | 565 | self.refresh() 566 | 567 | def select_next(self): 568 | """Select the next step if available.""" 569 | if self.steps and self.selected_index < len(self.steps) - 1: 570 | self.selected_index += 1 571 | self.adjust_selection() 572 | self.refresh_content() 573 | 574 | def select_prev(self): 575 | """Select the previous step if available.""" 576 | if self.steps and self.selected_index > 0: 577 | self.selected_index -= 1 578 | self.adjust_selection() 579 | self.refresh_content() 580 | 581 | def get_selected_step(self): 582 | """Get the currently selected plan step.""" 583 | try: 584 | if self.steps and 0 <= self.selected_index < len(self.steps): 585 | return self.steps[self.selected_index] 586 | except (IndexError, KeyError): 587 | pass 588 | return None 589 | 590 | def toggle_details(self): 591 | """Toggle between displaying the step list and the details of the selected step.""" 592 | if self.get_selected_step(): 593 | self.show_details = not self.show_details 594 | self.refresh_content() 595 | return True 596 | return False 597 | 598 | 599 | class InputDialog: 600 | def __init__(self, stdscr, title, prompts, initial_values=None): 601 | """ 602 | Initialize an input dialog with multiple fields. 603 | 604 | Args: 605 | stdscr: The main curses window 606 | title: Dialog title 607 | prompts: List of field prompts 608 | initial_values: List of initial values for fields (optional) 609 | """ 610 | self.stdscr = stdscr 611 | self.title = title 612 | self.prompts = prompts 613 | 614 | # Initialize with empty values or provided initial values 615 | if initial_values is None: 616 | self.values = ["" for _ in range(len(prompts))] 617 | else: 618 | self.values = initial_values.copy() 619 | 620 | # Dialog dimensions 621 | screen_height, screen_width = stdscr.getmaxyx() 622 | self.width = min(60, screen_width - 4) 623 | self.height = len(prompts) * 2 + 4 # 2 lines per field + borders + buttons 624 | 625 | # Center dialog 626 | self.y = (screen_height - self.height) // 2 627 | self.x = (screen_width - self.width) // 2 628 | 629 | # Create window 630 | self.win = stdscr.subwin(self.height, self.width, self.y, self.x) 631 | self.win.keypad(True) # Enable keypad mode for special keys 632 | self.current_field = 0 633 | self.cursor_pos = len(self.values[0]) if self.values and self.values[0] else 0 634 | 635 | def show(self): 636 | """Show the dialog and handle input.""" 637 | curses.curs_set(1) # Show cursor 638 | 639 | # Enable special keys like backspace 640 | self.win.keypad(True) 641 | 642 | # Main input loop 643 | while True: 644 | self.draw() 645 | key = self.win.getch() 646 | 647 | if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter (different codes) 648 | curses.curs_set(0) # Hide cursor 649 | return self.values 650 | 651 | elif key == 27: # Escape 652 | curses.curs_set(0) # Hide cursor 653 | return None 654 | 655 | elif key == curses.KEY_UP and self.current_field > 0: 656 | self.current_field -= 1 657 | self.cursor_pos = len(self.values[self.current_field]) 658 | 659 | elif key == curses.KEY_DOWN and self.current_field < len(self.prompts) - 1: 660 | self.current_field += 1 661 | self.cursor_pos = len(self.values[self.current_field]) 662 | 663 | elif key == 9: # Tab 664 | self.current_field = (self.current_field + 1) % len(self.prompts) 665 | self.cursor_pos = len(self.values[self.current_field]) 666 | 667 | elif key == curses.KEY_LEFT and self.cursor_pos > 0: 668 | self.cursor_pos -= 1 669 | 670 | elif key == curses.KEY_RIGHT and self.cursor_pos < len(self.values[self.current_field]): 671 | self.cursor_pos += 1 672 | 673 | elif key in (curses.KEY_BACKSPACE, 127, 8): # Different backspace codes 674 | if self.cursor_pos > 0: 675 | self.values[self.current_field] = ( 676 | self.values[self.current_field][:self.cursor_pos - 1] + 677 | self.values[self.current_field][self.cursor_pos:] 678 | ) 679 | self.cursor_pos -= 1 680 | 681 | elif key == curses.KEY_DC: # Delete 682 | if self.cursor_pos < len(self.values[self.current_field]): 683 | self.values[self.current_field] = ( 684 | self.values[self.current_field][:self.cursor_pos] + 685 | self.values[self.current_field][self.cursor_pos + 1:] 686 | ) 687 | 688 | elif 32 <= key <= 126: # Printable characters 689 | self.values[self.current_field] = ( 690 | self.values[self.current_field][:self.cursor_pos] + 691 | chr(key) + 692 | self.values[self.current_field][self.cursor_pos:] 693 | ) 694 | self.cursor_pos += 1 695 | 696 | def draw(self): 697 | """Draw the dialog box and input fields.""" 698 | self.win.clear() 699 | self.win.box() 700 | 701 | # Draw title 702 | if self.title: 703 | title_str = f" {self.title} " 704 | x = max(1, (self.width - len(title_str)) // 2) 705 | self.win.addstr(0, x, title_str) 706 | 707 | # Draw input fields 708 | for i, prompt in enumerate(self.prompts): 709 | y = i * 2 + 1 710 | self.win.addstr(y, 2, f"{prompt}:") 711 | 712 | # Draw input field 713 | field_x = 2 714 | field_y = y + 1 715 | field_width = self.width - 4 716 | field_value = self.values[i] 717 | 718 | # Draw input value 719 | self.win.addstr(field_y, field_x, field_value) 720 | 721 | # Draw cursor if this is the active field 722 | if i == self.current_field: 723 | self.win.move(field_y, field_x + self.cursor_pos) 724 | 725 | # Draw instructions 726 | self.win.addstr(self.height - 1, 2, "Enter: Save | Esc: Cancel") 727 | 728 | self.win.refresh() 729 | 730 | 731 | class ConfirmDialog: 732 | def __init__(self, stdscr, title, message): 733 | """Initialize a confirmation dialog.""" 734 | self.stdscr = stdscr 735 | self.title = title 736 | self.message = message 737 | 738 | # Dialog dimensions 739 | screen_height, screen_width = stdscr.getmaxyx() 740 | self.width = min(50, screen_width - 4) 741 | 742 | # Calculate height based on message length 743 | message_lines = (len(message) // (self.width - 4)) + 1 744 | self.height = message_lines + 4 # Message + borders + buttons 745 | 746 | # Center dialog 747 | self.y = (screen_height - self.height) // 2 748 | self.x = (screen_width - self.width) // 2 749 | 750 | # Create window 751 | self.win = stdscr.subwin(self.height, self.width, self.y, self.x) 752 | self.selected = 0 # 0 = No, 1 = Yes 753 | 754 | def show(self): 755 | """Show the dialog and handle input.""" 756 | # Enable keypad mode for special keys 757 | self.win.keypad(True) 758 | 759 | # Main input loop 760 | while True: 761 | self.draw() 762 | key = self.win.getch() 763 | 764 | if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter (different codes) 765 | return self.selected == 1 # Return True if "Yes" selected 766 | 767 | elif key == 27: # Escape 768 | return False 769 | 770 | elif key == curses.KEY_LEFT or key == curses.KEY_RIGHT: 771 | self.selected = 1 - self.selected # Toggle between 0 and 1 772 | 773 | # Add handling for y/n keys 774 | elif key in (ord('y'), ord('Y')): 775 | return True 776 | 777 | elif key in (ord('n'), ord('N')): 778 | return False 779 | 780 | def draw(self): 781 | """Draw the confirmation dialog.""" 782 | self.win.clear() 783 | self.win.box() 784 | 785 | # Draw title 786 | if self.title: 787 | title_str = f" {self.title} " 788 | x = max(1, (self.width - len(title_str)) // 2) 789 | self.win.addstr(0, x, title_str) 790 | 791 | # Draw message 792 | message_words = self.message.split() 793 | line = "" 794 | y = 1 795 | 796 | for word in message_words: 797 | if len(line) + len(word) + 1 <= self.width - 4: 798 | if line: 799 | line += " " + word 800 | else: 801 | line = word 802 | else: 803 | self.win.addstr(y, 2, line) 804 | y += 1 805 | line = word 806 | 807 | if line: 808 | self.win.addstr(y, 2, line) 809 | 810 | # Draw buttons 811 | button_y = self.height - 2 812 | no_x = self.width // 3 - 2 813 | yes_x = 2 * self.width // 3 - 2 814 | 815 | if self.selected == 0: 816 | self.win.attron(curses.A_REVERSE) 817 | self.win.addstr(button_y, no_x, " No ") 818 | if self.selected == 0: 819 | self.win.attroff(curses.A_REVERSE) 820 | 821 | if self.selected == 1: 822 | self.win.attron(curses.A_REVERSE) 823 | self.win.addstr(button_y, yes_x, " Yes ") 824 | if self.selected == 1: 825 | self.win.attroff(curses.A_REVERSE) 826 | 827 | self.win.refresh() ```