todo-agent 0.3.2__py3-none-any.whl → 0.3.5__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.
@@ -32,13 +32,19 @@ class TodoShell:
32
32
  self.todo_dir = os.path.dirname(todo_file_path) or os.getcwd()
33
33
  self.logger = logger
34
34
 
35
- def execute(self, command: List[str], cwd: Optional[str] = None) -> str:
35
+ def execute(
36
+ self,
37
+ command: List[str],
38
+ cwd: Optional[str] = None,
39
+ suppress_color: bool = False,
40
+ ) -> str:
36
41
  """
37
- Execute todo.sh command.
42
+ Execute a todo.sh command and return the output.
38
43
 
39
44
  Args:
40
45
  command: List of command arguments
41
46
  cwd: Working directory (defaults to todo.sh directory)
47
+ suppress_color: If True, strip ANSI color codes from output (for LLM consumption)
42
48
 
43
49
  Returns:
44
50
  Command output as string
@@ -52,6 +58,7 @@ class TodoShell:
52
58
  self.logger.debug(f"=== RAW COMMAND EXECUTION ===")
53
59
  self.logger.debug(f"Raw command: {raw_command}")
54
60
  self.logger.debug(f"Working directory: {cwd or self.todo_dir}")
61
+ self.logger.debug(f"Suppress color: {suppress_color}")
55
62
 
56
63
  try:
57
64
  working_dir = cwd or self.todo_dir
@@ -67,7 +74,20 @@ class TodoShell:
67
74
  self.logger.debug(f"Raw stderr: {result.stderr}")
68
75
  self.logger.debug(f"Return code: {result.returncode}")
69
76
 
70
- return result.stdout.strip()
77
+ output = result.stdout.strip()
78
+
79
+ # Strip ANSI color codes if requested (for LLM consumption)
80
+ if suppress_color:
81
+ from rich.text import Text
82
+
83
+ # Use Rich's Text.from_ansi to parse and then get plain text
84
+ output = Text.from_ansi(output).plain
85
+ if self.logger:
86
+ self.logger.debug(
87
+ f"Stripped ANSI codes from output for LLM consumption"
88
+ )
89
+
90
+ return output
71
91
  except subprocess.CalledProcessError as e:
72
92
  # Log error details
73
93
  if self.logger:
@@ -88,12 +108,18 @@ class TodoShell:
88
108
  """Add new task."""
89
109
  return self.execute(["todo.sh", "add", description])
90
110
 
91
- def list_tasks(self, filter_str: Optional[str] = None) -> str:
111
+ def addto(self, destination: str, text: str) -> str:
112
+ """Add text to a specific file in the todo.txt directory."""
113
+ return self.execute(["todo.sh", "addto", destination, text])
114
+
115
+ def list_tasks(
116
+ self, filter_str: Optional[str] = None, suppress_color: bool = True
117
+ ) -> str:
92
118
  """List tasks with optional filtering."""
93
119
  command = ["todo.sh", "ls"]
94
120
  if filter_str:
95
121
  command.append(filter_str)
96
- return self.execute(command)
122
+ return self.execute(command, suppress_color=suppress_color)
97
123
 
98
124
  def complete(self, task_number: int) -> str:
99
125
  """Mark task complete."""
@@ -135,20 +161,22 @@ class TodoShell:
135
161
  """Remove task priority."""
136
162
  return self.execute(["todo.sh", "depri", str(task_number)])
137
163
 
138
- def list_projects(self) -> str:
164
+ def list_projects(self, suppress_color: bool = True) -> str:
139
165
  """List projects."""
140
- return self.execute(["todo.sh", "lsp"])
166
+ return self.execute(["todo.sh", "lsp"], suppress_color=suppress_color)
141
167
 
142
- def list_contexts(self) -> str:
168
+ def list_contexts(self, suppress_color: bool = True) -> str:
143
169
  """List contexts."""
144
- return self.execute(["todo.sh", "lsc"])
170
+ return self.execute(["todo.sh", "lsc"], suppress_color=suppress_color)
145
171
 
146
- def list_completed(self, filter_str: Optional[str] = None) -> str:
172
+ def list_completed(
173
+ self, filter_str: Optional[str] = None, suppress_color: bool = True
174
+ ) -> str:
147
175
  """List completed tasks with optional filtering."""
148
176
  command = ["todo.sh", "listfile", "done.txt"]
149
177
  if filter_str:
150
178
  command.append(filter_str)
151
- return self.execute(command)
179
+ return self.execute(command, suppress_color=suppress_color)
152
180
 
153
181
  def archive(self) -> str:
154
182
  """Archive completed tasks."""
@@ -252,6 +280,61 @@ class TodoShell:
252
280
  # Replace the task with the new description
253
281
  return self.replace(task_number, new_description)
254
282
 
283
+ def set_parent(self, task_number: int, parent_number: Optional[int]) -> str:
284
+ """
285
+ Set or update parent task number for a task by intelligently rewriting it.
286
+
287
+ Args:
288
+ task_number: The task number to modify
289
+ parent_number: Parent task number, or None to remove parent
290
+
291
+ Returns:
292
+ The updated task description
293
+ """
294
+ # First, get the current task to parse its components
295
+ tasks_output = self.list_tasks()
296
+ task_lines = tasks_output.strip().split("\n")
297
+
298
+ # Find the task by its actual number (not array index)
299
+ current_task = None
300
+ for line in task_lines:
301
+ if line.strip():
302
+ # Extract task number from the beginning of the line (handling ANSI codes)
303
+ extracted_number = self._extract_task_number(line)
304
+ if extracted_number == task_number:
305
+ current_task = line
306
+ break
307
+
308
+ if not current_task:
309
+ raise TodoShellError(f"Task number {task_number} not found")
310
+
311
+ # Parse the current task components
312
+ components = self._parse_task_components(current_task)
313
+
314
+ # Update the parent (None removes it)
315
+ if parent_number is not None:
316
+ if not isinstance(parent_number, int) or parent_number <= 0:
317
+ raise TodoShellError(
318
+ f"Invalid parent_number '{parent_number}'. Must be a positive integer."
319
+ )
320
+ parent_tag = f"parent:{parent_number}"
321
+ # Remove any existing parent tag and add the new one
322
+ components["other_tags"] = [
323
+ tag for tag in components["other_tags"] if not tag.startswith("parent:")
324
+ ]
325
+ components["other_tags"].append(parent_tag)
326
+ else:
327
+ # Remove parent tag
328
+ components["other_tags"] = [
329
+ tag for tag in components["other_tags"] if not tag.startswith("parent:")
330
+ ]
331
+
332
+ # Reconstruct the task
333
+ new_description = self._reconstruct_task(components)
334
+
335
+ # Replace the task with the new description
336
+ return self.replace(task_number, new_description)
337
+
255
338
  def _extract_task_number(self, line: str) -> Optional[int]:
256
339
  """
257
340
  Extract task number from a line that may contain ANSI color codes.
@@ -2,8 +2,9 @@
2
2
  Command-line interface for todo.sh LLM agent.
3
3
  """
4
4
 
5
- from typing import Optional
6
5
  import readline
6
+ from typing import Optional
7
+
7
8
  from rich.align import Align
8
9
  from rich.console import Console, Group
9
10
  from rich.live import Live
@@ -23,8 +24,8 @@ try:
23
24
  TableFormatter,
24
25
  TaskFormatter,
25
26
  )
26
- from todo_agent.interface.tools import ToolCallHandler
27
27
  from todo_agent.interface.progress import ToolCallProgress
28
+ from todo_agent.interface.tools import ToolCallHandler
28
29
  except ImportError:
29
30
  from core.todo_manager import TodoManager # type: ignore[no-redef]
30
31
  from infrastructure.config import Config # type: ignore[no-redef]
@@ -38,8 +39,8 @@ except ImportError:
38
39
  TableFormatter,
39
40
  TaskFormatter,
40
41
  )
41
- from interface.tools import ToolCallHandler # type: ignore[no-redef]
42
42
  from interface.progress import ToolCallProgress # type: ignore[no-redef]
43
+ from interface.tools import ToolCallHandler # type: ignore[no-redef]
43
44
 
44
45
 
45
46
  class CLI:
@@ -100,22 +101,23 @@ class CLI:
100
101
  initial_spinner = self._create_thinking_spinner("Thinking...")
101
102
  return Live(initial_spinner, console=self.console, refresh_per_second=10)
102
103
 
103
- def _create_tool_call_spinner(self, progress_description: str,
104
- sequence: int = 0, total_sequences: int = 0) -> Group:
104
+ def _create_tool_call_spinner(
105
+ self, progress_description: str, sequence: int = 0, total_sequences: int = 0
106
+ ) -> Group:
105
107
  """
106
108
  Create a multi-line spinner showing tool call progress.
107
-
109
+
108
110
  Args:
109
111
  progress_description: User-friendly description of what the tool is doing
110
112
  sequence: Current sequence number
111
113
  total_sequences: Total number of sequences
112
-
114
+
113
115
  Returns:
114
116
  Group object with spinner and optional sequence info
115
117
  """
116
118
  # Line 1: Main progress with spinner
117
119
  main_line = Spinner("dots", text=Text(progress_description, style="cyan"))
118
-
120
+
119
121
  # Line 2: Sequence progress (show current sequence even if we don't know total)
120
122
  # if sequence > 0:
121
123
  # if total_sequences > 0:
@@ -123,75 +125,87 @@ class CLI:
123
125
  # else:
124
126
  # sequence_text = Text(f"Sequence {sequence}", style="dim")
125
127
  # return Group(main_line, sequence_text)
126
-
127
- return main_line
128
+
129
+ return Group(main_line)
128
130
 
129
131
  def _create_completion_spinner(self, thinking_time: float) -> Spinner:
130
132
  """
131
133
  Create completion spinner with timing.
132
-
134
+
133
135
  Args:
134
136
  thinking_time: Total thinking time in seconds
135
-
137
+
136
138
  Returns:
137
139
  Spinner object showing completion
138
140
  """
139
- return Spinner("dots", text=Text(f"✅ Complete ({thinking_time:.1f}s)", style="green"))
141
+ return Spinner(
142
+ "dots", text=Text(f"✅ Complete ({thinking_time:.1f}s)", style="green")
143
+ )
140
144
 
141
145
  def _create_cli_progress_callback(self, live_display: Live) -> ToolCallProgress:
142
146
  """
143
147
  Create a CLI-specific progress callback for tool call tracking.
144
-
148
+
145
149
  Args:
146
150
  live_display: The live display to update
147
-
151
+
148
152
  Returns:
149
153
  ToolCallProgress implementation for CLI
150
154
  """
155
+
151
156
  class CLIProgressCallback(ToolCallProgress):
152
157
  def __init__(self, cli: CLI, live: Live):
153
158
  self.cli = cli
154
159
  self.live = live
155
160
  self.current_sequence = 0
156
161
  self.total_sequences = 0
157
-
162
+
158
163
  def on_thinking_start(self) -> None:
159
164
  """Show initial thinking spinner."""
160
- spinner = self.cli._create_thinking_spinner("🤔 Analyzing your request...")
165
+ spinner = self.cli._create_thinking_spinner(
166
+ "🤔 Analyzing your request..."
167
+ )
161
168
  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:
169
+
170
+ def on_tool_call_start(
171
+ self,
172
+ tool_name: str,
173
+ progress_description: str,
174
+ sequence: int,
175
+ total_sequences: int,
176
+ ) -> None:
165
177
  """Show tool execution progress."""
166
178
  self.current_sequence = sequence
167
179
  self.total_sequences = total_sequences
168
-
180
+
169
181
  # Create multi-line spinner
170
182
  spinner = self.cli._create_tool_call_spinner(
171
183
  progress_description=progress_description,
172
184
  sequence=sequence,
173
- total_sequences=total_sequences
185
+ total_sequences=total_sequences,
174
186
  )
175
187
  self.live.update(spinner)
176
-
177
- def on_tool_call_complete(self, tool_name: str, success: bool, duration: float) -> None:
188
+
189
+ def on_tool_call_complete(
190
+ self, tool_name: str, success: bool, duration: float
191
+ ) -> None:
178
192
  """Tool completion - no action needed."""
179
193
  pass
180
-
194
+
181
195
  def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
182
196
  """Show sequence completion."""
183
197
  spinner = self.cli._create_tool_call_spinner(
184
198
  progress_description=f"🔄 Sequence {sequence} complete",
185
199
  sequence=sequence,
186
- total_sequences=total_sequences
200
+ total_sequences=total_sequences,
187
201
  )
188
202
  self.live.update(spinner)
189
-
203
+
190
204
  def on_thinking_complete(self, total_time: float) -> None:
191
205
  """Show completion spinner."""
192
206
  spinner = self.cli._create_completion_spinner(total_time)
193
207
  self.live.update(spinner)
194
-
208
+
195
209
  return CLIProgressCallback(self, live_display)
196
210
 
197
211
  def _print_header(self) -> None:
@@ -296,7 +310,8 @@ class CLI:
296
310
  if user_input.lower() == "list":
297
311
  self.logger.debug("User requested task list")
298
312
  try:
299
- output = self.todo_shell.list_tasks()
313
+ # Use suppress_color=False for interactive display to preserve colors
314
+ output = self.todo_shell.list_tasks(suppress_color=False)
300
315
  formatted_output = TaskFormatter.format_task_list(output)
301
316
  task_panel = PanelFormatter.create_task_panel(formatted_output)
302
317
  self.console.print(task_panel)
@@ -311,7 +326,8 @@ class CLI:
311
326
  if user_input.lower() == "done":
312
327
  self.logger.debug("User requested completed task list")
313
328
  try:
314
- output = self.todo_shell.list_completed()
329
+ # Use suppress_color=False for interactive display to preserve colors
330
+ output = self.todo_shell.list_completed(suppress_color=False)
315
331
  formatted_output = TaskFormatter.format_completed_tasks(output)
316
332
  task_panel = PanelFormatter.create_task_panel(
317
333
  formatted_output, title="✅ Completed Tasks"
@@ -336,7 +352,7 @@ class CLI:
336
352
  # Get memory usage
337
353
  # DISABLED FOR NOW
338
354
  memory_usage = self._get_memory_usage()
339
- #memory_usage = None
355
+ # memory_usage = None
340
356
 
341
357
  # Create response panel with memory usage
342
358
  response_panel = PanelFormatter.create_response_panel(
@@ -368,9 +384,11 @@ class CLI:
368
384
  try:
369
385
  # Create progress callback for tool call tracking
370
386
  progress_callback = self._create_cli_progress_callback(live)
371
-
387
+
372
388
  # Process request through inference engine with progress tracking
373
- response, thinking_time = self.inference.process_request(user_input, progress_callback)
389
+ response, thinking_time = self.inference.process_request(
390
+ user_input, progress_callback
391
+ )
374
392
 
375
393
  # Update spinner with completion message and thinking time
376
394
  live.update(
@@ -21,20 +21,23 @@ PROVIDER_ERROR_MESSAGES = {
21
21
  "rate_limit": "I'm a bit overwhelmed right now. Please wait a moment and try again, or type 'clear' to start fresh.",
22
22
  "auth_error": "I can't connect to my AI service. Please check your configuration, or type 'clear' to reset.",
23
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."
24
+ "general_error": "Something went wrong with my AI service. Please try again, or type 'clear' to reset our conversation.",
25
25
  }
26
26
 
27
+
27
28
  def get_provider_error_message(error_type: str) -> str:
28
29
  """
29
30
  Get user-friendly error message for provider errors.
30
-
31
+
31
32
  Args:
32
33
  error_type: The type of provider error
33
-
34
+
34
35
  Returns:
35
36
  User-friendly error message with recovery suggestion
36
37
  """
37
- return PROVIDER_ERROR_MESSAGES.get(error_type, PROVIDER_ERROR_MESSAGES["general_error"])
38
+ return PROVIDER_ERROR_MESSAGES.get(
39
+ error_type, PROVIDER_ERROR_MESSAGES["general_error"]
40
+ )
38
41
 
39
42
 
40
43
  class TaskFormatter:
@@ -3,34 +3,39 @@ Progress tracking interface for tool call execution.
3
3
  """
4
4
 
5
5
  from abc import ABC, abstractmethod
6
- from typing import Optional
7
6
 
8
7
 
9
8
  class ToolCallProgress(ABC):
10
9
  """Abstract interface for tool call progress tracking."""
11
-
10
+
12
11
  @abstractmethod
13
12
  def on_thinking_start(self) -> None:
14
13
  """Called when LLM starts thinking."""
15
14
  pass
16
-
15
+
17
16
  @abstractmethod
18
- def on_tool_call_start(self, tool_name: str, progress_description: str,
19
- sequence: int, total_sequences: int) -> None:
17
+ def on_tool_call_start(
18
+ self,
19
+ tool_name: str,
20
+ progress_description: str,
21
+ sequence: int,
22
+ total_sequences: int,
23
+ ) -> None:
20
24
  """Called when a tool call starts."""
21
25
  pass
22
-
26
+
23
27
  @abstractmethod
24
- def on_tool_call_complete(self, tool_name: str, success: bool,
25
- duration: float) -> None:
28
+ def on_tool_call_complete(
29
+ self, tool_name: str, success: bool, duration: float
30
+ ) -> None:
26
31
  """Called when a tool call completes (optional - no action needed)."""
27
32
  pass
28
-
33
+
29
34
  @abstractmethod
30
35
  def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
31
36
  """Called when a tool call sequence completes."""
32
37
  pass
33
-
38
+
34
39
  @abstractmethod
35
40
  def on_thinking_complete(self, total_time: float) -> None:
36
41
  """Called when thinking is complete."""
@@ -39,20 +44,26 @@ class ToolCallProgress(ABC):
39
44
 
40
45
  class NoOpProgress(ToolCallProgress):
41
46
  """No-operation implementation for when progress tracking is not needed."""
42
-
47
+
43
48
  def on_thinking_start(self) -> None:
44
49
  pass
45
-
46
- def on_tool_call_start(self, tool_name: str, progress_description: str,
47
- sequence: int, total_sequences: int) -> None:
50
+
51
+ def on_tool_call_start(
52
+ self,
53
+ tool_name: str,
54
+ progress_description: str,
55
+ sequence: int,
56
+ total_sequences: int,
57
+ ) -> None:
48
58
  pass
49
-
50
- def on_tool_call_complete(self, tool_name: str, success: bool,
51
- duration: float) -> None:
59
+
60
+ def on_tool_call_complete(
61
+ self, tool_name: str, success: bool, duration: float
62
+ ) -> None:
52
63
  pass
53
-
64
+
54
65
  def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
55
66
  pass
56
-
67
+
57
68
  def on_thinking_complete(self, total_time: float) -> None:
58
69
  pass