todo-agent 0.1.1__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,399 @@
1
+ """
2
+ Formatters for CLI output with unicode characters and consistent styling.
3
+ """
4
+
5
+ from typing import Any, Dict
6
+
7
+ from rich.align import Align
8
+ from rich.box import ROUNDED
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ # CLI width configuration
14
+ CLI_WIDTH = 100
15
+ PANEL_WIDTH = CLI_WIDTH - 2 # Leave 2 characters for borders
16
+
17
+
18
+ class TaskFormatter:
19
+ """Formats task-related output with unicode characters and consistent styling."""
20
+
21
+ @staticmethod
22
+ def format_task_list(raw_tasks: str) -> Text:
23
+ """
24
+ Format a raw task list with unicode characters and numbering.
25
+
26
+ Args:
27
+ raw_tasks: Raw task output from todo.sh
28
+ title: Title for the task list
29
+
30
+ Returns:
31
+ Formatted task list as Rich Text object
32
+ """
33
+ if not raw_tasks.strip():
34
+ return Text("No tasks found.")
35
+
36
+ lines = raw_tasks.strip().split("\n")
37
+ formatted_text = Text()
38
+ task_count = 0
39
+
40
+ # Add header
41
+ formatted_text.append("Tasks", style="bold blue")
42
+ formatted_text.append("\n\n")
43
+
44
+ for line in lines:
45
+ line = line.strip()
46
+ # Skip empty lines, separators, and todo.sh's own summary line
47
+ if line and line != "--" and not line.startswith("TODO:"):
48
+ task_count += 1
49
+ # Parse todo.txt format and make it more readable
50
+ formatted_task = TaskFormatter._format_single_task(line, task_count)
51
+ # Create a Text object that respects ANSI codes
52
+ task_text = Text.from_ansi(formatted_task)
53
+ formatted_text.append(task_text)
54
+ formatted_text.append("\n")
55
+
56
+ # Add task count at the end
57
+ if task_count > 0:
58
+ formatted_text.append("\n")
59
+ formatted_text.append(f"TODO: {task_count} of {task_count} tasks shown")
60
+ else:
61
+ formatted_text = Text("No tasks found.")
62
+
63
+ return formatted_text
64
+
65
+ @staticmethod
66
+ def _format_single_task(task_line: str, task_number: int) -> str:
67
+ """
68
+ Format a single task line with unicode characters.
69
+
70
+ Args:
71
+ task_line: Raw task line from todo.sh
72
+ task_number: Task number for display
73
+
74
+ Returns:
75
+ Formatted task string
76
+ """
77
+ # Parse todo.txt format: "1 (A) 2025-08-29 Clean cat box @home +chores due:2025-08-29"
78
+ parts = task_line.split(
79
+ " ", 1
80
+ ) # Split on first space to separate number from rest
81
+ if len(parts) < 2:
82
+ return f" {task_number:2d} │ │ {task_line}"
83
+
84
+ rest = parts[1]
85
+
86
+ # Extract priority if present (format: "(A)")
87
+ priority = ""
88
+ description = rest
89
+
90
+ if rest.startswith("(") and ")" in rest:
91
+ priority_end = rest.find(")")
92
+ priority = rest[1:priority_end]
93
+ description = rest[priority_end + 1 :].strip()
94
+
95
+ # Format with unicode characters
96
+ if priority:
97
+ formatted_line = f" {task_number:2d} │ {priority} │ {description}"
98
+ else:
99
+ formatted_line = f" {task_number:2d} │ │ {description}"
100
+
101
+ return formatted_line
102
+
103
+ @staticmethod
104
+ def format_projects(raw_projects: str) -> str:
105
+ """
106
+ Format project list with unicode characters.
107
+
108
+ Args:
109
+ raw_projects: Raw project output from todo.sh
110
+
111
+ Returns:
112
+ Formatted project list string
113
+ """
114
+ if not raw_projects.strip():
115
+ return "No projects found."
116
+
117
+ lines = raw_projects.strip().split("\n")
118
+ formatted_lines = []
119
+
120
+ for i, project in enumerate(lines, 1):
121
+ if project.strip():
122
+ # Remove the + prefix and format nicely
123
+ clean_project = project.strip().lstrip("+")
124
+ formatted_lines.append(f" {i:2d} │ {clean_project}")
125
+
126
+ if formatted_lines:
127
+ return "\n".join(formatted_lines)
128
+ else:
129
+ return "No projects found."
130
+
131
+ @staticmethod
132
+ def format_contexts(raw_contexts: str) -> str:
133
+ """
134
+ Format context list with unicode characters.
135
+
136
+ Args:
137
+ raw_contexts: Raw context output from todo.sh
138
+
139
+ Returns:
140
+ Formatted context list string
141
+ """
142
+ if not raw_contexts.strip():
143
+ return "No contexts found."
144
+
145
+ lines = raw_contexts.strip().split("\n")
146
+ formatted_lines = []
147
+
148
+ for i, context in enumerate(lines, 1):
149
+ if context.strip():
150
+ # Remove the @ prefix and format nicely
151
+ clean_context = context.strip().lstrip("@")
152
+ formatted_lines.append(f" {i:2d} │ {clean_context}")
153
+
154
+ if formatted_lines:
155
+ return "\n".join(formatted_lines)
156
+ else:
157
+ return "No contexts found."
158
+
159
+
160
+ class ResponseFormatter:
161
+ """Formats LLM responses and other output with consistent styling."""
162
+
163
+ @staticmethod
164
+ def format_response(response: str) -> str:
165
+ """
166
+ Format an LLM response with consistent styling.
167
+
168
+ Args:
169
+ response: Raw response text
170
+ title: Title for the response panel
171
+
172
+ Returns:
173
+ Formatted response string
174
+ """
175
+ # If response contains task lists, format them nicely
176
+ if "No tasks found" in response or "1." in response:
177
+ # This might be a task list response, try to format it
178
+ lines = response.split("\n")
179
+ formatted_lines = []
180
+
181
+ for line in lines:
182
+ if line.strip().startswith(
183
+ ("1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9.")
184
+ ):
185
+ # This looks like a numbered task list, format it
186
+ parts = line.split(".", 1)
187
+ if len(parts) == 2:
188
+ number = parts[0].strip()
189
+ content = parts[1].strip()
190
+ formatted_lines.append(f" {number:>2} │ {content}")
191
+ else:
192
+ formatted_lines.append(line)
193
+ else:
194
+ formatted_lines.append(line)
195
+
196
+ return "\n".join(formatted_lines)
197
+
198
+ return response
199
+
200
+ @staticmethod
201
+ def format_error(error_message: str) -> str:
202
+ """
203
+ Format error messages consistently.
204
+
205
+ Args:
206
+ error_message: Error message to format
207
+
208
+ Returns:
209
+ Formatted error string
210
+ """
211
+ return f"❌ {error_message}"
212
+
213
+ @staticmethod
214
+ def format_success(message: str) -> str:
215
+ """
216
+ Format success messages consistently.
217
+
218
+ Args:
219
+ message: Success message to format
220
+
221
+ Returns:
222
+ Formatted success string
223
+ """
224
+ return f"✅ {message}"
225
+
226
+
227
+ class StatsFormatter:
228
+ """Formats statistics and overview information."""
229
+
230
+ @staticmethod
231
+ def format_overview(overview: str) -> str:
232
+ """
233
+ Format task overview with unicode characters.
234
+
235
+ Args:
236
+ overview: Raw overview string
237
+
238
+ Returns:
239
+ Formatted overview string
240
+ """
241
+ if "Task Overview:" in overview:
242
+ lines = overview.split("\n")
243
+ formatted_lines = []
244
+
245
+ for line in lines:
246
+ if line.startswith("- Active tasks:"):
247
+ formatted_lines.append(f"📋 {line[2:]}")
248
+ elif line.startswith("- Completed tasks:"):
249
+ formatted_lines.append(f"✅ {line[2:]}")
250
+ else:
251
+ formatted_lines.append(line)
252
+
253
+ return "\n".join(formatted_lines)
254
+
255
+ return overview
256
+
257
+
258
+ class TableFormatter:
259
+ """Creates rich tables for various data displays."""
260
+
261
+ @staticmethod
262
+ def create_command_table() -> Table:
263
+ """Create a table for displaying available commands."""
264
+ table = Table(
265
+ title="Available Commands",
266
+ box=ROUNDED,
267
+ show_header=True,
268
+ header_style="bold magenta",
269
+ width=PANEL_WIDTH,
270
+ )
271
+
272
+ table.add_column("Command", style="cyan", width=12)
273
+ table.add_column("Description", style="white")
274
+
275
+ commands = [
276
+ ("clear", "Clear conversation history"),
277
+ ("stats", "Show conversation statistics"),
278
+ ("help", "Show this help message"),
279
+ ("about", "Show application information"),
280
+ ("list", "List all tasks (no LLM interaction)"),
281
+ ("quit", "Exit the application"),
282
+ ]
283
+
284
+ for cmd, desc in commands:
285
+ table.add_row(cmd, desc)
286
+
287
+ return table
288
+
289
+ @staticmethod
290
+ def create_stats_table(summary: Dict[str, Any]) -> Table:
291
+ """Create a table for displaying conversation statistics."""
292
+ table = Table(
293
+ title="Conversation Statistics",
294
+ box=ROUNDED,
295
+ show_header=True,
296
+ header_style="bold magenta",
297
+ width=PANEL_WIDTH,
298
+ )
299
+
300
+ table.add_column("Metric", style="cyan", width=20)
301
+ table.add_column("Value", style="white")
302
+
303
+ # Basic stats
304
+ table.add_row("Total Messages", str(summary["total_messages"]))
305
+ table.add_row("User Messages", str(summary["user_messages"]))
306
+ table.add_row("Assistant Messages", str(summary["assistant_messages"]))
307
+ table.add_row("Tool Messages", str(summary["tool_messages"]))
308
+ table.add_row("Estimated Tokens", str(summary["estimated_tokens"]))
309
+
310
+ # Thinking time stats if available
311
+ if "thinking_time_count" in summary and summary["thinking_time_count"] > 0:
312
+ table.add_row("", "") # Empty row for spacing
313
+ table.add_row(
314
+ "Total Thinking Time", f"{summary['total_thinking_time']:.2f}s"
315
+ )
316
+ table.add_row(
317
+ "Average Thinking Time", f"{summary['average_thinking_time']:.2f}s"
318
+ )
319
+ table.add_row("Min Thinking Time", f"{summary['min_thinking_time']:.2f}s")
320
+ table.add_row("Max Thinking Time", f"{summary['max_thinking_time']:.2f}s")
321
+ table.add_row("Requests with Timing", str(summary["thinking_time_count"]))
322
+
323
+ return table
324
+
325
+
326
+ class PanelFormatter:
327
+ """Creates rich panels for various content displays."""
328
+
329
+ @staticmethod
330
+ def create_header_panel() -> Panel:
331
+ """Create the application header panel."""
332
+ header_text = Text("Todo.sh LLM Agent", style="bold blue")
333
+ return Panel(
334
+ Align.center(header_text),
335
+ title="🤖",
336
+ border_style="dim",
337
+ box=ROUNDED,
338
+ width=PANEL_WIDTH + 2,
339
+ )
340
+
341
+ @staticmethod
342
+ def create_task_panel(content: str, title: str = "📋 Current Tasks") -> Panel:
343
+ """Create a panel for displaying task lists."""
344
+ return Panel(
345
+ content, title=title, border_style="dim", box=ROUNDED, width=PANEL_WIDTH
346
+ )
347
+
348
+ @staticmethod
349
+ def create_response_panel(content: str, title: str = "🤖 Assistant") -> Panel:
350
+ """Create a panel for displaying LLM responses."""
351
+ return Panel(
352
+ content, title=title, border_style="dim", box=ROUNDED, width=PANEL_WIDTH
353
+ )
354
+
355
+ @staticmethod
356
+ def create_error_panel(content: str, title: str = "❌ Error") -> Panel:
357
+ """Create a panel for displaying errors."""
358
+ return Panel(
359
+ content, title=title, border_style="red", box=ROUNDED, width=PANEL_WIDTH
360
+ )
361
+
362
+ @staticmethod
363
+ def create_about_panel() -> Panel:
364
+ """Create a panel for displaying about information."""
365
+ from todo_agent._version import __commit_id__, __version__
366
+
367
+ about_content = Text()
368
+ about_content.append("Todo.sh LLM Agent\n", style="bold blue")
369
+ about_content.append("\n")
370
+ about_content.append(
371
+ "A natural language interface for todo.sh task management\n", style="white"
372
+ )
373
+ about_content.append("powered by LLM function calling.\n", style="white")
374
+ about_content.append("\n")
375
+ about_content.append("Version: ", style="cyan")
376
+ about_content.append(f"{__version__}\n", style="white")
377
+ if __commit_id__:
378
+ about_content.append("Commit: ", style="cyan")
379
+ about_content.append(f"{__commit_id__}\n", style="white")
380
+ about_content.append("\n")
381
+ about_content.append(
382
+ "Transform natural language into todo.sh commands:\n", style="italic"
383
+ )
384
+ about_content.append("• 'add buy groceries to shopping list'\n", style="dim")
385
+ about_content.append("• 'show my work tasks'\n", style="dim")
386
+ about_content.append("• 'mark task 3 as done'\n", style="dim")
387
+ about_content.append("\n")
388
+ about_content.append("GitHub: ", style="cyan")
389
+ about_content.append(
390
+ "https://github.com/codeprimate/todo-agent\n", style="blue"
391
+ )
392
+
393
+ return Panel(
394
+ Align.center(about_content),
395
+ title="i About",
396
+ border_style="dim",
397
+ box=ROUNDED,
398
+ width=PANEL_WIDTH + 2,
399
+ )
@@ -2,14 +2,14 @@
2
2
  Tool definitions and schemas for LLM function calling.
3
3
  """
4
4
 
5
- from typing import Any, Dict, List, Optional
5
+ from typing import Any, Callable, Dict, List, Optional
6
6
 
7
7
  try:
8
8
  from todo_agent.core.todo_manager import TodoManager
9
9
  from todo_agent.infrastructure.logger import Logger
10
10
  except ImportError:
11
- from core.todo_manager import TodoManager
12
- from infrastructure.logger import Logger
11
+ from core.todo_manager import TodoManager # type: ignore[no-redef]
12
+ from infrastructure.logger import Logger # type: ignore[no-redef]
13
13
 
14
14
 
15
15
  class ToolCallHandler:
@@ -36,7 +36,7 @@ class ToolCallHandler:
36
36
  "to avoid asking the user for clarification when you can find the answer yourself."
37
37
  ),
38
38
  "parameters": {"type": "object", "properties": {}, "required": []},
39
- }
39
+ },
40
40
  },
41
41
  {
42
42
  "type": "function",
@@ -51,7 +51,7 @@ class ToolCallHandler:
51
51
  "to avoid asking the user for clarification when you can find the answer yourself."
52
52
  ),
53
53
  "parameters": {"type": "object", "properties": {}, "required": []},
54
- }
54
+ },
55
55
  },
56
56
  {
57
57
  "type": "function",
@@ -82,7 +82,7 @@ class ToolCallHandler:
82
82
  },
83
83
  "required": [],
84
84
  },
85
- }
85
+ },
86
86
  },
87
87
  {
88
88
  "type": "function",
@@ -155,7 +155,7 @@ class ToolCallHandler:
155
155
  },
156
156
  "required": [],
157
157
  },
158
- }
158
+ },
159
159
  },
160
160
  {
161
161
  "type": "function",
@@ -198,7 +198,7 @@ class ToolCallHandler:
198
198
  },
199
199
  "required": ["description"],
200
200
  },
201
- }
201
+ },
202
202
  },
203
203
  {
204
204
  "type": "function",
@@ -222,7 +222,7 @@ class ToolCallHandler:
222
222
  },
223
223
  "required": ["task_number"],
224
224
  },
225
- }
225
+ },
226
226
  },
227
227
  {
228
228
  "type": "function",
@@ -247,7 +247,7 @@ class ToolCallHandler:
247
247
  },
248
248
  "required": ["task_number", "new_description"],
249
249
  },
250
- }
250
+ },
251
251
  },
252
252
  {
253
253
  "type": "function",
@@ -271,7 +271,7 @@ class ToolCallHandler:
271
271
  },
272
272
  "required": ["task_number", "text"],
273
273
  },
274
- }
274
+ },
275
275
  },
276
276
  {
277
277
  "type": "function",
@@ -295,7 +295,7 @@ class ToolCallHandler:
295
295
  },
296
296
  "required": ["task_number", "text"],
297
297
  },
298
- }
298
+ },
299
299
  },
300
300
  {
301
301
  "type": "function",
@@ -323,7 +323,7 @@ class ToolCallHandler:
323
323
  },
324
324
  "required": ["task_number"],
325
325
  },
326
- }
326
+ },
327
327
  },
328
328
  {
329
329
  "type": "function",
@@ -348,7 +348,7 @@ class ToolCallHandler:
348
348
  },
349
349
  "required": ["task_number", "priority"],
350
350
  },
351
- }
351
+ },
352
352
  },
353
353
  {
354
354
  "type": "function",
@@ -368,7 +368,7 @@ class ToolCallHandler:
368
368
  },
369
369
  "required": ["task_number"],
370
370
  },
371
- }
371
+ },
372
372
  },
373
373
  {
374
374
  "type": "function",
@@ -379,7 +379,7 @@ class ToolCallHandler:
379
379
  "an overview, summary, or statistics about their tasks."
380
380
  ),
381
381
  "parameters": {"type": "object", "properties": {}, "required": []},
382
- }
382
+ },
383
383
  },
384
384
  {
385
385
  "type": "function",
@@ -408,7 +408,7 @@ class ToolCallHandler:
408
408
  },
409
409
  "required": ["task_number", "destination"],
410
410
  },
411
- }
411
+ },
412
412
  },
413
413
  {
414
414
  "type": "function",
@@ -420,7 +420,7 @@ class ToolCallHandler:
420
420
  "their todo list or archive completed tasks."
421
421
  ),
422
422
  "parameters": {"type": "object", "properties": {}, "required": []},
423
- }
423
+ },
424
424
  },
425
425
  {
426
426
  "type": "function",
@@ -432,16 +432,15 @@ class ToolCallHandler:
432
432
  "in the list."
433
433
  ),
434
434
  "parameters": {"type": "object", "properties": {}, "required": []},
435
- }
435
+ },
436
436
  },
437
-
438
437
  ]
439
438
 
440
439
  def _format_tool_signature(self, tool_name: str, arguments: Dict[str, Any]) -> str:
441
440
  """Format tool signature with parameters for logging."""
442
441
  if not arguments:
443
442
  return f"{tool_name}()"
444
-
443
+
445
444
  # Format parameters as key=value pairs
446
445
  param_parts = []
447
446
  for key, value in arguments.items():
@@ -450,7 +449,7 @@ class ToolCallHandler:
450
449
  param_parts.append(f"{key}='{value}'")
451
450
  else:
452
451
  param_parts.append(f"{key}={value}")
453
-
452
+
454
453
  return f"{tool_name}({', '.join(param_parts)})"
455
454
 
456
455
  def execute_tool(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
@@ -458,10 +457,11 @@ class ToolCallHandler:
458
457
  tool_name = tool_call["function"]["name"]
459
458
  arguments = tool_call["function"]["arguments"]
460
459
  tool_call_id = tool_call.get("id", "unknown")
461
-
460
+
462
461
  # Handle arguments that might be a string (JSON) or already a dict
463
462
  if isinstance(arguments, str):
464
463
  import json
464
+
465
465
  try:
466
466
  arguments = json.loads(arguments)
467
467
  if self.logger:
@@ -470,10 +470,10 @@ class ToolCallHandler:
470
470
  if self.logger:
471
471
  self.logger.warning(f"Failed to parse JSON arguments: {e}")
472
472
  arguments = {}
473
-
473
+
474
474
  # Format tool signature with parameters
475
475
  tool_signature = self._format_tool_signature(tool_name, arguments)
476
-
476
+
477
477
  # Log function name with signature at INFO level
478
478
  if self.logger:
479
479
  self.logger.info(f"Executing tool: {tool_signature} (ID: {tool_call_id})")
@@ -486,7 +486,7 @@ class ToolCallHandler:
486
486
  self.logger.debug(f"Arguments: {tool_call['function']['arguments']}")
487
487
 
488
488
  # Map tool names to todo_manager methods
489
- method_map = {
489
+ method_map: Dict[str, Callable[..., Any]] = {
490
490
  "list_projects": self.todo_manager.list_projects,
491
491
  "list_contexts": self.todo_manager.list_contexts,
492
492
  "list_tasks": self.todo_manager.list_tasks,
@@ -515,50 +515,57 @@ class ToolCallHandler:
515
515
  "output": f"ERROR: {error_msg}",
516
516
  "error": True,
517
517
  "error_type": "unknown_tool",
518
- "error_details": error_msg
518
+ "error_details": error_msg,
519
519
  }
520
520
 
521
521
  method = method_map[tool_name]
522
-
522
+
523
523
  # Log method call details
524
524
  if self.logger:
525
525
  self.logger.debug(f"Calling method: {tool_name}")
526
-
526
+
527
527
  try:
528
528
  result = method(**arguments)
529
-
529
+
530
530
  # Log successful output at DEBUG level
531
531
  if self.logger:
532
532
  self.logger.debug(f"=== TOOL EXECUTION SUCCESS ===")
533
533
  self.logger.debug(f"Tool: {tool_name}")
534
534
  self.logger.debug(f"Raw result: ====\n{result}\n====")
535
-
535
+
536
536
  # For list results, log the count
537
537
  if isinstance(result, list):
538
538
  self.logger.debug(f"Result count: {len(result)}")
539
539
  # For string results, log the length
540
540
  elif isinstance(result, str):
541
541
  self.logger.debug(f"Result length: {len(result)}")
542
-
543
- return {"tool_call_id": tool_call_id, "name": tool_name, "output": result, "error": False}
544
-
542
+
543
+ return {
544
+ "tool_call_id": tool_call_id,
545
+ "name": tool_name,
546
+ "output": result,
547
+ "error": False,
548
+ }
549
+
545
550
  except Exception as e:
546
551
  # Log error details
547
552
  if self.logger:
548
553
  self.logger.error(f"=== TOOL EXECUTION FAILED ===")
549
554
  self.logger.error(f"Tool: {tool_name}")
550
555
  self.logger.error(f"Error type: {type(e).__name__}")
551
- self.logger.error(f"Error message: {str(e)}")
556
+ self.logger.error(f"Error message: {e!s}")
552
557
  self.logger.exception(f"Exception details for {tool_name}")
553
-
558
+
554
559
  # Return structured error information instead of raising
555
560
  error_type = type(e).__name__
556
561
  error_message = str(e)
557
-
562
+
558
563
  # Provide user-friendly error messages based on error type
559
564
  if "FileNotFoundError" in error_type or "todo.sh" in error_message.lower():
560
565
  user_message = f"Todo.sh command failed: {error_message}. Please ensure todo.sh is properly installed and configured."
561
- elif "IndexError" in error_type or "task" in error_message.lower() and "not found" in error_message.lower():
566
+ elif "IndexError" in error_type or (
567
+ "task" in error_message.lower() and "not found" in error_message.lower()
568
+ ):
562
569
  user_message = f"Task not found: {error_message}. The task may have been completed or deleted."
563
570
  elif "ValueError" in error_type:
564
571
  user_message = f"Invalid input: {error_message}. Please check the task format or parameters."
@@ -566,7 +573,7 @@ class ToolCallHandler:
566
573
  user_message = f"Permission denied: {error_message}. Please check file permissions for todo.txt files."
567
574
  else:
568
575
  user_message = f"Operation failed: {error_message}"
569
-
576
+
570
577
  return {
571
578
  "tool_call_id": tool_call_id,
572
579
  "name": tool_name,
@@ -574,5 +581,5 @@ class ToolCallHandler:
574
581
  "error": True,
575
582
  "error_type": error_type,
576
583
  "error_details": error_message,
577
- "user_message": user_message
584
+ "user_message": user_message,
578
585
  }