todo-agent 0.2.9__py3-none-any.whl → 0.3.2__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.
@@ -4,7 +4,7 @@ Subprocess wrapper for todo.sh operations.
4
4
 
5
5
  import os
6
6
  import subprocess # nosec B404
7
- from typing import Any, List, Optional
7
+ from typing import Any, List, Optional, TypedDict
8
8
 
9
9
  try:
10
10
  from todo_agent.core.exceptions import TodoShellError
@@ -12,6 +12,18 @@ except ImportError:
12
12
  from core.exceptions import TodoShellError # type: ignore[no-redef]
13
13
 
14
14
 
15
+ class TaskComponents(TypedDict):
16
+ """Type definition for task components."""
17
+
18
+ priority: str | None
19
+ description: str
20
+ projects: list[str]
21
+ contexts: list[str]
22
+ due: str | None
23
+
24
+ other_tags: list[str]
25
+
26
+
15
27
  class TodoShell:
16
28
  """Subprocess execution wrapper with error management."""
17
29
 
@@ -243,19 +255,19 @@ class TodoShell:
243
255
  def _extract_task_number(self, line: str) -> Optional[int]:
244
256
  """
245
257
  Extract task number from a line that may contain ANSI color codes.
246
-
258
+
247
259
  Args:
248
260
  line: Task line that may contain ANSI color codes
249
-
261
+
250
262
  Returns:
251
263
  Task number if found, None otherwise
252
264
  """
253
265
  from rich.text import Text
254
-
266
+
255
267
  # Use rich to properly handle ANSI color codes
256
268
  text = Text.from_ansi(line)
257
269
  clean_line = text.plain
258
-
270
+
259
271
  # Split on first space and check if first part is a number
260
272
  parts = clean_line.split(" ", 1)
261
273
  if parts and parts[0].isdigit():
@@ -319,7 +331,8 @@ class TodoShell:
319
331
  # Remove the project if it exists (with or without + prefix)
320
332
  project_to_remove = f"+{clean_project}"
321
333
  components["projects"] = [
322
- p for p in components["projects"]
334
+ p
335
+ for p in components["projects"]
323
336
  if p != project_to_remove and p != clean_project
324
337
  ]
325
338
  else:
@@ -345,7 +358,7 @@ class TodoShell:
345
358
  # Replace the task with the new description
346
359
  return self.replace(task_number, new_description)
347
360
 
348
- def _parse_task_components(self, task_line: str) -> dict:
361
+ def _parse_task_components(self, task_line: str) -> TaskComponents:
349
362
  """
350
363
  Parse a todo.txt task line into its components.
351
364
 
@@ -357,9 +370,10 @@ class TodoShell:
357
370
  """
358
371
  # Remove ANSI color codes first using rich
359
372
  from rich.text import Text
373
+
360
374
  text = Text.from_ansi(task_line)
361
375
  task_line = text.plain
362
-
376
+
363
377
  # Remove task number prefix if present (e.g., "1 " or "1. ")
364
378
  # First try the format without dot (standard todo.sh format)
365
379
  if " " in task_line and task_line.split(" ")[0].isdigit():
@@ -368,13 +382,12 @@ class TodoShell:
368
382
  elif ". " in task_line:
369
383
  task_line = task_line.split(". ", 1)[1]
370
384
 
371
- components = {
385
+ components: TaskComponents = {
372
386
  "priority": None,
373
387
  "description": "",
374
388
  "projects": [],
375
389
  "contexts": [],
376
390
  "due": None,
377
- "recurring": None,
378
391
  "other_tags": [],
379
392
  }
380
393
 
@@ -409,17 +422,8 @@ class TodoShell:
409
422
  components["due"] = word[4:] # Remove 'due:' prefix
410
423
  continue
411
424
 
412
- # Recurring: rec:frequency[:interval]
413
- if word.startswith("rec:"):
414
- components["recurring"] = word
415
- continue
416
-
417
425
  # Other tags (like custom tags)
418
- if (
419
- ":" in word
420
- and not word.startswith("due:")
421
- and not word.startswith("rec:")
422
- ):
426
+ if ":" in word and not word.startswith("due:"):
423
427
  other_tags_set.add(word)
424
428
  continue
425
429
 
@@ -430,13 +434,13 @@ class TodoShell:
430
434
  components["description"] = word
431
435
 
432
436
  # Convert sets back to sorted lists for consistent ordering
433
- components["projects"] = sorted(list(projects_set))
434
- components["contexts"] = sorted(list(contexts_set))
435
- components["other_tags"] = sorted(list(other_tags_set))
437
+ components["projects"] = sorted(projects_set)
438
+ components["contexts"] = sorted(contexts_set)
439
+ components["other_tags"] = sorted(other_tags_set)
436
440
 
437
441
  return components
438
442
 
439
- def _reconstruct_task(self, components: dict) -> str:
443
+ def _reconstruct_task(self, components: TaskComponents) -> str:
440
444
  """
441
445
  Reconstruct a task description from parsed components.
442
446
 
@@ -466,10 +470,6 @@ class TodoShell:
466
470
  if components["due"]:
467
471
  parts.append(f"due:{components['due']}")
468
472
 
469
- # Add recurring pattern
470
- if components["recurring"]:
471
- parts.append(components["recurring"])
472
-
473
473
  # Add other tags
474
474
  parts.extend(components["other_tags"])
475
475
 
@@ -3,16 +3,14 @@ Command-line interface for todo.sh LLM agent.
3
3
  """
4
4
 
5
5
  from typing import Optional
6
+ import readline
7
+ from rich.align import Align
8
+ from rich.console import Console, Group
9
+ from rich.live import Live
10
+ from rich.spinner import Spinner
11
+ from rich.text import Text
6
12
 
7
13
  try:
8
- import readline
9
-
10
- from rich.align import Align
11
- from rich.console import Console
12
- from rich.live import Live
13
- from rich.spinner import Spinner
14
- from rich.text import Text
15
-
16
14
  from todo_agent.core.todo_manager import TodoManager
17
15
  from todo_agent.infrastructure.config import Config
18
16
  from todo_agent.infrastructure.inference import Inference
@@ -26,6 +24,7 @@ try:
26
24
  TaskFormatter,
27
25
  )
28
26
  from todo_agent.interface.tools import ToolCallHandler
27
+ from todo_agent.interface.progress import ToolCallProgress
29
28
  except ImportError:
30
29
  from core.todo_manager import TodoManager # type: ignore[no-redef]
31
30
  from infrastructure.config import Config # type: ignore[no-redef]
@@ -40,11 +39,7 @@ except ImportError:
40
39
  TaskFormatter,
41
40
  )
42
41
  from interface.tools import ToolCallHandler # type: ignore[no-redef]
43
- from rich.align import Align
44
- from rich.console import Console
45
- from rich.live import Live
46
- from rich.spinner import Spinner
47
- from rich.text import Text
42
+ from interface.progress import ToolCallProgress # type: ignore[no-redef]
48
43
 
49
44
 
50
45
  class CLI:
@@ -105,6 +100,100 @@ class CLI:
105
100
  initial_spinner = self._create_thinking_spinner("Thinking...")
106
101
  return Live(initial_spinner, console=self.console, refresh_per_second=10)
107
102
 
103
+ def _create_tool_call_spinner(self, progress_description: str,
104
+ sequence: int = 0, total_sequences: int = 0) -> Group:
105
+ """
106
+ Create a multi-line spinner showing tool call progress.
107
+
108
+ Args:
109
+ progress_description: User-friendly description of what the tool is doing
110
+ sequence: Current sequence number
111
+ total_sequences: Total number of sequences
112
+
113
+ Returns:
114
+ Group object with spinner and optional sequence info
115
+ """
116
+ # Line 1: Main progress with spinner
117
+ main_line = Spinner("dots", text=Text(progress_description, style="cyan"))
118
+
119
+ # Line 2: Sequence progress (show current sequence even if we don't know total)
120
+ # if sequence > 0:
121
+ # if total_sequences > 0:
122
+ # sequence_text = Text(f"Sequence {sequence}/{total_sequences}", style="dim")
123
+ # else:
124
+ # sequence_text = Text(f"Sequence {sequence}", style="dim")
125
+ # return Group(main_line, sequence_text)
126
+
127
+ return main_line
128
+
129
+ def _create_completion_spinner(self, thinking_time: float) -> Spinner:
130
+ """
131
+ Create completion spinner with timing.
132
+
133
+ Args:
134
+ thinking_time: Total thinking time in seconds
135
+
136
+ Returns:
137
+ Spinner object showing completion
138
+ """
139
+ return Spinner("dots", text=Text(f"✅ Complete ({thinking_time:.1f}s)", style="green"))
140
+
141
+ def _create_cli_progress_callback(self, live_display: Live) -> ToolCallProgress:
142
+ """
143
+ Create a CLI-specific progress callback for tool call tracking.
144
+
145
+ Args:
146
+ live_display: The live display to update
147
+
148
+ Returns:
149
+ ToolCallProgress implementation for CLI
150
+ """
151
+ class CLIProgressCallback(ToolCallProgress):
152
+ def __init__(self, cli: CLI, live: Live):
153
+ self.cli = cli
154
+ self.live = live
155
+ self.current_sequence = 0
156
+ self.total_sequences = 0
157
+
158
+ def on_thinking_start(self) -> None:
159
+ """Show initial thinking spinner."""
160
+ spinner = self.cli._create_thinking_spinner("🤔 Analyzing your request...")
161
+ self.live.update(spinner)
162
+
163
+ def on_tool_call_start(self, tool_name: str, progress_description: str,
164
+ sequence: int, total_sequences: int) -> None:
165
+ """Show tool execution progress."""
166
+ self.current_sequence = sequence
167
+ self.total_sequences = total_sequences
168
+
169
+ # Create multi-line spinner
170
+ spinner = self.cli._create_tool_call_spinner(
171
+ progress_description=progress_description,
172
+ sequence=sequence,
173
+ total_sequences=total_sequences
174
+ )
175
+ self.live.update(spinner)
176
+
177
+ def on_tool_call_complete(self, tool_name: str, success: bool, duration: float) -> None:
178
+ """Tool completion - no action needed."""
179
+ pass
180
+
181
+ def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
182
+ """Show sequence completion."""
183
+ spinner = self.cli._create_tool_call_spinner(
184
+ progress_description=f"🔄 Sequence {sequence} complete",
185
+ sequence=sequence,
186
+ total_sequences=total_sequences
187
+ )
188
+ self.live.update(spinner)
189
+
190
+ def on_thinking_complete(self, total_time: float) -> None:
191
+ """Show completion spinner."""
192
+ spinner = self.cli._create_completion_spinner(total_time)
193
+ self.live.update(spinner)
194
+
195
+ return CLIProgressCallback(self, live_display)
196
+
108
197
  def _print_header(self) -> None:
109
198
  """Print the application header with unicode borders."""
110
199
  header_panel = PanelFormatter.create_header_panel()
@@ -244,10 +333,10 @@ class CLI:
244
333
  # Format the response and create a panel
245
334
  formatted_response = ResponseFormatter.format_response(response)
246
335
 
247
- # Get memory usage
336
+ # Get memory usage
248
337
  # DISABLED FOR NOW
249
- # memory_usage = self._get_memory_usage()
250
- memory_usage = None
338
+ memory_usage = self._get_memory_usage()
339
+ #memory_usage = None
251
340
 
252
341
  # Create response panel with memory usage
253
342
  response_panel = PanelFormatter.create_response_panel(
@@ -277,8 +366,11 @@ class CLI:
277
366
  # Show thinking spinner during LLM processing
278
367
  with self._get_thinking_live() as live:
279
368
  try:
280
- # Process request through inference engine
281
- response, thinking_time = self.inference.process_request(user_input)
369
+ # Create progress callback for tool call tracking
370
+ progress_callback = self._create_cli_progress_callback(live)
371
+
372
+ # Process request through inference engine with progress tracking
373
+ response, thinking_time = self.inference.process_request(user_input, progress_callback)
282
374
 
283
375
  # Update spinner with completion message and thinking time
284
376
  live.update(
@@ -14,6 +14,28 @@ from rich.text import Text
14
14
  CLI_WIDTH = 100
15
15
  PANEL_WIDTH = CLI_WIDTH - 2 # Leave 2 characters for borders
16
16
 
17
+ # Provider error message mapping
18
+ PROVIDER_ERROR_MESSAGES = {
19
+ "malformed_response": "I got a confusing response from my AI service. Please try again, or type 'clear' to reset our conversation.",
20
+ "malformed_tool_call": "I received a malformed request. Please try again, or type 'clear' to reset our conversation.",
21
+ "rate_limit": "I'm a bit overwhelmed right now. Please wait a moment and try again, or type 'clear' to start fresh.",
22
+ "auth_error": "I can't connect to my AI service. Please check your configuration, or type 'clear' to reset.",
23
+ "timeout": "The request took too long. Please try again, or type 'clear' to reset our conversation.",
24
+ "general_error": "Something went wrong with my AI service. Please try again, or type 'clear' to reset our conversation."
25
+ }
26
+
27
+ def get_provider_error_message(error_type: str) -> str:
28
+ """
29
+ Get user-friendly error message for provider errors.
30
+
31
+ Args:
32
+ error_type: The type of provider error
33
+
34
+ Returns:
35
+ User-friendly error message with recovery suggestion
36
+ """
37
+ return PROVIDER_ERROR_MESSAGES.get(error_type, PROVIDER_ERROR_MESSAGES["general_error"])
38
+
17
39
 
18
40
  class TaskFormatter:
19
41
  """Formats task-related output with unicode characters and consistent styling."""
@@ -0,0 +1,58 @@
1
+ """
2
+ Progress tracking interface for tool call execution.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Optional
7
+
8
+
9
+ class ToolCallProgress(ABC):
10
+ """Abstract interface for tool call progress tracking."""
11
+
12
+ @abstractmethod
13
+ def on_thinking_start(self) -> None:
14
+ """Called when LLM starts thinking."""
15
+ pass
16
+
17
+ @abstractmethod
18
+ def on_tool_call_start(self, tool_name: str, progress_description: str,
19
+ sequence: int, total_sequences: int) -> None:
20
+ """Called when a tool call starts."""
21
+ pass
22
+
23
+ @abstractmethod
24
+ def on_tool_call_complete(self, tool_name: str, success: bool,
25
+ duration: float) -> None:
26
+ """Called when a tool call completes (optional - no action needed)."""
27
+ pass
28
+
29
+ @abstractmethod
30
+ def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
31
+ """Called when a tool call sequence completes."""
32
+ pass
33
+
34
+ @abstractmethod
35
+ def on_thinking_complete(self, total_time: float) -> None:
36
+ """Called when thinking is complete."""
37
+ pass
38
+
39
+
40
+ class NoOpProgress(ToolCallProgress):
41
+ """No-operation implementation for when progress tracking is not needed."""
42
+
43
+ def on_thinking_start(self) -> None:
44
+ pass
45
+
46
+ def on_tool_call_start(self, tool_name: str, progress_description: str,
47
+ sequence: int, total_sequences: int) -> None:
48
+ pass
49
+
50
+ def on_tool_call_complete(self, tool_name: str, success: bool,
51
+ duration: float) -> None:
52
+ pass
53
+
54
+ def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
55
+ pass
56
+
57
+ def on_thinking_complete(self, total_time: float) -> None:
58
+ pass