todo-agent 0.3.2__tar.gz → 0.3.3__tar.gz

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.
Files changed (62) hide show
  1. {todo_agent-0.3.2 → todo_agent-0.3.3}/PKG-INFO +1 -1
  2. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_core/test_conversation_manager.py +1 -1
  3. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_core/test_todo_manager.py +31 -11
  4. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_inference.py +3 -1
  5. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_ollama_client.py +11 -24
  6. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_openrouter_client.py +18 -25
  7. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_todo_shell.py +49 -6
  8. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_interface/test_cli.py +12 -4
  9. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/_version.py +3 -3
  10. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/core/exceptions.py +6 -6
  11. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/core/todo_manager.py +13 -8
  12. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/inference.py +113 -52
  13. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/llm_client.py +56 -22
  14. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/ollama_client.py +23 -13
  15. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/openrouter_client.py +20 -12
  16. todo_agent-0.3.3/todo_agent/infrastructure/prompts/system_prompt.txt +91 -0
  17. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/todo_shell.py +35 -11
  18. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/interface/cli.py +51 -33
  19. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/interface/formatters.py +7 -4
  20. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/interface/progress.py +30 -19
  21. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/interface/tools.py +25 -25
  22. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent.egg-info/PKG-INFO +1 -1
  23. todo_agent-0.3.2/todo_agent/infrastructure/prompts/system_prompt.txt +0 -441
  24. {todo_agent-0.3.2 → todo_agent-0.3.3}/.gitignore +0 -0
  25. {todo_agent-0.3.2 → todo_agent-0.3.3}/LICENSE +0 -0
  26. {todo_agent-0.3.2 → todo_agent-0.3.3}/MANIFEST.in +0 -0
  27. {todo_agent-0.3.2 → todo_agent-0.3.3}/Makefile +0 -0
  28. {todo_agent-0.3.2 → todo_agent-0.3.3}/README.md +0 -0
  29. {todo_agent-0.3.2 → todo_agent-0.3.3}/docs/publishing.md +0 -0
  30. {todo_agent-0.3.2 → todo_agent-0.3.3}/pyproject.toml +0 -0
  31. {todo_agent-0.3.2 → todo_agent-0.3.3}/requirements-dev.txt +0 -0
  32. {todo_agent-0.3.2 → todo_agent-0.3.3}/requirements.txt +0 -0
  33. {todo_agent-0.3.2 → todo_agent-0.3.3}/setup.cfg +0 -0
  34. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/__init__.py +0 -0
  35. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_core/__init__.py +0 -0
  36. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/__init__.py +0 -0
  37. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_calendar_utils.py +0 -0
  38. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_config.py +0 -0
  39. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_llm_client_factory.py +0 -0
  40. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_infrastructure/test_token_counter.py +0 -0
  41. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_interface/__init__.py +0 -0
  42. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_interface/test_formatters.py +0 -0
  43. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_interface/test_tools.py +0 -0
  44. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_linting.py +0 -0
  45. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_logger.py +0 -0
  46. {todo_agent-0.3.2 → todo_agent-0.3.3}/tests/test_main.py +0 -0
  47. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/__init__.py +0 -0
  48. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/core/__init__.py +0 -0
  49. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/core/conversation_manager.py +0 -0
  50. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/__init__.py +0 -0
  51. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/calendar_utils.py +0 -0
  52. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/config.py +0 -0
  53. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/llm_client_factory.py +0 -0
  54. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/logger.py +0 -0
  55. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/infrastructure/token_counter.py +0 -0
  56. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/interface/__init__.py +0 -0
  57. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent/main.py +0 -0
  58. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent.egg-info/SOURCES.txt +0 -0
  59. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent.egg-info/dependency_links.txt +0 -0
  60. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent.egg-info/entry_points.txt +0 -0
  61. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent.egg-info/requires.txt +0 -0
  62. {todo_agent-0.3.2 → todo_agent-0.3.3}/todo_agent.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: todo-agent
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: A natural language interface for todo.sh task management
5
5
  Author: codeprimate
6
6
  Maintainer: codeprimate
@@ -15,7 +15,7 @@ class TestConversationManager:
15
15
  manager = ConversationManager()
16
16
  assert len(manager.history) == 0
17
17
  assert manager.max_tokens == 64000
18
- assert manager.max_messages == 50
18
+ assert manager.max_messages == 100
19
19
  assert manager.system_prompt is None
20
20
 
21
21
  def test_add_message(self):
@@ -212,7 +212,7 @@ class TestTodoManager(unittest.TestCase):
212
212
  self.todo_shell.list_tasks.return_value = "1. Task 1\n2. Task 2"
213
213
  result = self.todo_manager.list_tasks()
214
214
  self.assertEqual(result, "1. Task 1\n2. Task 2")
215
- self.todo_shell.list_tasks.assert_called_once_with(None)
215
+ self.todo_shell.list_tasks.assert_called_once_with(None, suppress_color=True)
216
216
 
217
217
  def test_complete_task(self):
218
218
  """Test completing a task."""
@@ -228,28 +228,36 @@ class TestTodoManager(unittest.TestCase):
228
228
  )
229
229
  result = self.todo_manager.list_completed_tasks()
230
230
  self.assertEqual(result, "1. Completed task 1\n2. Completed task 2")
231
- self.todo_shell.list_completed.assert_called_once_with(None)
231
+ self.todo_shell.list_completed.assert_called_once_with(
232
+ None, suppress_color=True
233
+ )
232
234
 
233
235
  def test_list_completed_tasks_with_project_filter(self):
234
236
  """Test listing completed tasks with project filter."""
235
237
  self.todo_shell.list_completed.return_value = "1. Work task completed"
236
238
  result = self.todo_manager.list_completed_tasks(project="work")
237
239
  self.assertEqual(result, "1. Work task completed")
238
- self.todo_shell.list_completed.assert_called_once_with("+work")
240
+ self.todo_shell.list_completed.assert_called_once_with(
241
+ "+work", suppress_color=True
242
+ )
239
243
 
240
244
  def test_list_completed_tasks_with_context_filter(self):
241
245
  """Test listing completed tasks with context filter."""
242
246
  self.todo_shell.list_completed.return_value = "1. Office task completed"
243
247
  result = self.todo_manager.list_completed_tasks(context="office")
244
248
  self.assertEqual(result, "1. Office task completed")
245
- self.todo_shell.list_completed.assert_called_once_with("@office")
249
+ self.todo_shell.list_completed.assert_called_once_with(
250
+ "@office", suppress_color=True
251
+ )
246
252
 
247
253
  def test_list_completed_tasks_with_text_search(self):
248
254
  """Test listing completed tasks with text search."""
249
255
  self.todo_shell.list_completed.return_value = "1. Review task completed"
250
256
  result = self.todo_manager.list_completed_tasks(text_search="review")
251
257
  self.assertEqual(result, "1. Review task completed")
252
- self.todo_shell.list_completed.assert_called_once_with("review")
258
+ self.todo_shell.list_completed.assert_called_once_with(
259
+ "review", suppress_color=True
260
+ )
253
261
 
254
262
  def test_list_completed_tasks_with_date_filters(self):
255
263
  """Test listing completed tasks with date filters."""
@@ -259,14 +267,18 @@ class TestTodoManager(unittest.TestCase):
259
267
  )
260
268
  self.assertEqual(result, "1. Task completed in date range")
261
269
  # With both date_from and date_to, we use the year-month pattern from date_from
262
- self.todo_shell.list_completed.assert_called_once_with("2025-08")
270
+ self.todo_shell.list_completed.assert_called_once_with(
271
+ "2025-08", suppress_color=True
272
+ )
263
273
 
264
274
  def test_list_completed_tasks_with_date_from_only(self):
265
275
  """Test listing completed tasks with only date_from filter."""
266
276
  self.todo_shell.list_completed.return_value = "1. Task completed from date"
267
277
  result = self.todo_manager.list_completed_tasks(date_from="2025-08-01")
268
278
  self.assertEqual(result, "1. Task completed from date")
269
- self.todo_shell.list_completed.assert_called_once_with("2025-08-01")
279
+ self.todo_shell.list_completed.assert_called_once_with(
280
+ "2025-08-01", suppress_color=True
281
+ )
270
282
 
271
283
  def test_list_completed_tasks_with_date_to_only(self):
272
284
  """Test listing completed tasks with only date_to filter."""
@@ -274,7 +286,9 @@ class TestTodoManager(unittest.TestCase):
274
286
  result = self.todo_manager.list_completed_tasks(date_to="2025-08-31")
275
287
  self.assertEqual(result, "1. Task completed in month")
276
288
  # With only date_to, we use the year-month pattern
277
- self.todo_shell.list_completed.assert_called_once_with("2025-08")
289
+ self.todo_shell.list_completed.assert_called_once_with(
290
+ "2025-08", suppress_color=True
291
+ )
278
292
 
279
293
  def test_list_completed_tasks_with_multiple_filters(self):
280
294
  """Test listing completed tasks with multiple filters."""
@@ -285,21 +299,27 @@ class TestTodoManager(unittest.TestCase):
285
299
  project="work", context="office", text_search="review"
286
300
  )
287
301
  self.assertEqual(result, "1. Work task from office completed")
288
- self.todo_shell.list_completed.assert_called_once_with("+work @office review")
302
+ self.todo_shell.list_completed.assert_called_once_with(
303
+ "+work @office review", suppress_color=True
304
+ )
289
305
 
290
306
  def test_list_completed_tasks_with_raw_filter(self):
291
307
  """Test listing completed tasks with raw filter string."""
292
308
  self.todo_shell.list_completed.return_value = "1. Custom filtered task"
293
309
  result = self.todo_manager.list_completed_tasks(filter="+urgent @home")
294
310
  self.assertEqual(result, "1. Custom filtered task")
295
- self.todo_shell.list_completed.assert_called_once_with("+urgent @home")
311
+ self.todo_shell.list_completed.assert_called_once_with(
312
+ "+urgent @home", suppress_color=True
313
+ )
296
314
 
297
315
  def test_list_completed_tasks_no_results(self):
298
316
  """Test listing completed tasks when no results found."""
299
317
  self.todo_shell.list_completed.return_value = ""
300
318
  result = self.todo_manager.list_completed_tasks(project="nonexistent")
301
319
  self.assertEqual(result, "No completed tasks found matching the criteria.")
302
- self.todo_shell.list_completed.assert_called_once_with("+nonexistent")
320
+ self.todo_shell.list_completed.assert_called_once_with(
321
+ "+nonexistent", suppress_color=True
322
+ )
303
323
 
304
324
  def test_add_task_sanitizes_inputs(self):
305
325
  """Test that add_task sanitizes inputs to prevent duplicates."""
@@ -104,7 +104,9 @@ class TestInference:
104
104
  def test_system_prompt_datetime_interpolation(self):
105
105
  """Test that current datetime is properly interpolated into system prompt."""
106
106
  # Mock the file reading
107
- mock_prompt_content = "CURRENT DATE/TIME: {current_datetime}\nCALENDAR: {calendar_output}"
107
+ mock_prompt_content = (
108
+ "CURRENT DATE/TIME: {current_datetime}\nCALENDAR: {calendar_output}"
109
+ )
108
110
 
109
111
  with patch("builtins.open", mock_open(read_data=mock_prompt_content)):
110
112
  # Call the method that loads the system prompt
@@ -22,9 +22,7 @@ class TestOllamaClient:
22
22
  self.config.ollama_model = "llama3.2"
23
23
 
24
24
  # Patch the dependencies that are now initialized in the parent class
25
- with patch(
26
- "todo_agent.infrastructure.llm_client.Logger"
27
- ) as mock_logger, patch(
25
+ with patch("todo_agent.infrastructure.llm_client.Logger") as mock_logger, patch(
28
26
  "todo_agent.infrastructure.llm_client.get_token_counter"
29
27
  ) as mock_token_counter:
30
28
  mock_logger.return_value = Mock()
@@ -61,7 +59,7 @@ class TestOllamaClient:
61
59
  """Test request payload generation."""
62
60
  messages = [{"role": "user", "content": "Hello"}]
63
61
  tools = [{"name": "test_tool", "description": "A test tool"}]
64
-
62
+
65
63
  payload = self.client._get_request_payload(messages, tools)
66
64
  expected = {
67
65
  "model": "llama3.2",
@@ -102,7 +100,7 @@ class TestOllamaClient:
102
100
  "error_type": "general_error",
103
101
  "provider": "ollama",
104
102
  "status_code": 500,
105
- "raw_error": "Internal Server Error"
103
+ "raw_error": "Internal Server Error",
106
104
  }
107
105
  mock_make_request.return_value = error_response
108
106
 
@@ -144,11 +142,7 @@ class TestOllamaClient:
144
142
 
145
143
  def test_extract_tool_calls_with_error_response(self):
146
144
  """Test extracting tool calls from error response."""
147
- error_response = {
148
- "error": True,
149
- "error_type": "timeout",
150
- "provider": "ollama"
151
- }
145
+ error_response = {"error": True, "error_type": "timeout", "provider": "ollama"}
152
146
 
153
147
  tool_calls = self.client.extract_tool_calls(error_response)
154
148
  assert len(tool_calls) == 0
@@ -166,9 +160,7 @@ class TestOllamaClient:
166
160
 
167
161
  def test_extract_content_without_content(self):
168
162
  """Test extracting content from response without content."""
169
- response = {
170
- "message": {}
171
- }
163
+ response = {"message": {}}
172
164
 
173
165
  content = self.client.extract_content(response)
174
166
  assert content == ""
@@ -182,11 +174,7 @@ class TestOllamaClient:
182
174
 
183
175
  def test_extract_content_with_error_response(self):
184
176
  """Test extracting content from error response."""
185
- error_response = {
186
- "error": True,
187
- "error_type": "timeout",
188
- "provider": "ollama"
189
- }
177
+ error_response = {"error": True, "error_type": "timeout", "provider": "ollama"}
190
178
 
191
179
  content = self.client.extract_content(error_response)
192
180
  assert content == ""
@@ -196,16 +184,15 @@ class TestOllamaClient:
196
184
  response_data = {
197
185
  "message": {
198
186
  "content": "Hello",
199
- "tool_calls": [
200
- {"id": "call_1", "function": {"name": "test_tool"}}
201
- ]
187
+ "tool_calls": [{"id": "call_1", "function": {"name": "test_tool"}}],
202
188
  }
203
189
  }
204
190
 
205
- with patch.object(self.client.logger, "info") as mock_info, \
206
- patch.object(self.client.logger, "debug") as mock_debug:
191
+ with patch.object(self.client.logger, "info") as mock_info, patch.object(
192
+ self.client.logger, "debug"
193
+ ) as mock_debug:
207
194
  self.client._process_response(response_data, 0.0)
208
-
195
+
209
196
  # Should log response details
210
197
  assert mock_info.call_count >= 1
211
198
  assert mock_debug.call_count >= 1
@@ -21,9 +21,7 @@ class TestOpenRouterClient:
21
21
  def setup_method(self):
22
22
  """Set up test fixtures."""
23
23
  # Patch the dependencies that are now initialized in the parent class
24
- with patch(
25
- "todo_agent.infrastructure.llm_client.Logger"
26
- ) as mock_logger, patch(
24
+ with patch("todo_agent.infrastructure.llm_client.Logger") as mock_logger, patch(
27
25
  "todo_agent.infrastructure.llm_client.get_token_counter"
28
26
  ) as mock_token_counter:
29
27
  mock_logger.return_value = Mock()
@@ -62,7 +60,7 @@ class TestOpenRouterClient:
62
60
  """Test request payload generation."""
63
61
  messages = [{"role": "user", "content": "Hello"}]
64
62
  tools = [{"name": "test_tool", "description": "A test tool"}]
65
-
63
+
66
64
  payload = self.client._get_request_payload(messages, tools)
67
65
  expected = {
68
66
  "model": "test-model",
@@ -77,7 +75,9 @@ class TestOpenRouterClient:
77
75
  endpoint = self.client._get_api_endpoint()
78
76
  assert endpoint == "https://openrouter.ai/api/v1/chat/completions"
79
77
 
80
- @patch("todo_agent.infrastructure.openrouter_client.OpenRouterClient._make_http_request")
78
+ @patch(
79
+ "todo_agent.infrastructure.openrouter_client.OpenRouterClient._make_http_request"
80
+ )
81
81
  def test_chat_with_tools_success(self, mock_make_request):
82
82
  """Test successful chat with tools."""
83
83
  messages = [{"role": "user", "content": "Hello"}]
@@ -104,7 +104,9 @@ class TestOpenRouterClient:
104
104
  assert result == expected_response
105
105
  mock_make_request.assert_called_once_with(messages, tools)
106
106
 
107
- @patch("todo_agent.infrastructure.openrouter_client.OpenRouterClient._make_http_request")
107
+ @patch(
108
+ "todo_agent.infrastructure.openrouter_client.OpenRouterClient._make_http_request"
109
+ )
108
110
  def test_chat_with_tools_api_error(self, mock_make_request):
109
111
  """Test API error handling."""
110
112
  messages = [{"role": "user", "content": "Hello"}]
@@ -115,7 +117,7 @@ class TestOpenRouterClient:
115
117
  "error_type": "auth_error",
116
118
  "provider": "openrouter",
117
119
  "status_code": 401,
118
- "raw_error": "Unauthorized"
120
+ "raw_error": "Unauthorized",
119
121
  }
120
122
  mock_make_request.return_value = error_response
121
123
 
@@ -179,7 +181,7 @@ class TestOpenRouterClient:
179
181
  error_response = {
180
182
  "error": True,
181
183
  "error_type": "timeout",
182
- "provider": "openrouter"
184
+ "provider": "openrouter",
183
185
  }
184
186
 
185
187
  tool_calls = self.client.extract_tool_calls(error_response)
@@ -202,13 +204,7 @@ class TestOpenRouterClient:
202
204
 
203
205
  def test_extract_content_no_content(self):
204
206
  """Test extracting content from response without content."""
205
- response = {
206
- "choices": [
207
- {
208
- "message": {}
209
- }
210
- ]
211
- }
207
+ response = {"choices": [{"message": {}}]}
212
208
 
213
209
  content = self.client.extract_content(response)
214
210
  assert content == ""
@@ -232,7 +228,7 @@ class TestOpenRouterClient:
232
228
  error_response = {
233
229
  "error": True,
234
230
  "error_type": "timeout",
235
- "provider": "openrouter"
231
+ "provider": "openrouter",
236
232
  }
237
233
 
238
234
  content = self.client.extract_content(error_response)
@@ -253,21 +249,18 @@ class TestOpenRouterClient:
253
249
  "content": "Hello",
254
250
  "tool_calls": [
255
251
  {"id": "call_1", "function": {"name": "test_tool"}}
256
- ]
252
+ ],
257
253
  }
258
254
  }
259
255
  ],
260
- "usage": {
261
- "prompt_tokens": 10,
262
- "completion_tokens": 5,
263
- "total_tokens": 15
264
- }
256
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
265
257
  }
266
258
 
267
- with patch.object(self.client.logger, "info") as mock_info, \
268
- patch.object(self.client.logger, "debug") as mock_debug:
259
+ with patch.object(self.client.logger, "info") as mock_info, patch.object(
260
+ self.client.logger, "debug"
261
+ ) as mock_debug:
269
262
  self.client._process_response(response_data, 0.0)
270
-
263
+
271
264
  # Should log response details
272
265
  assert mock_info.call_count >= 2 # Latency and token usage
273
266
  assert mock_debug.call_count >= 1 # Raw response
@@ -100,7 +100,7 @@ class TestTodoShell:
100
100
  result = self.todo_shell.list_tasks()
101
101
 
102
102
  # Verify the correct command was used
103
- mock_execute.assert_called_once_with(["todo.sh", "ls"])
103
+ mock_execute.assert_called_once_with(["todo.sh", "ls"], suppress_color=True)
104
104
  assert result == "1. Task 1\n2. Task 2"
105
105
 
106
106
  def test_list_tasks_with_filter_appends_filter_to_command(self):
@@ -111,7 +111,9 @@ class TestTodoShell:
111
111
  result = self.todo_shell.list_tasks("+work")
112
112
 
113
113
  # Verify filter was appended to command
114
- mock_execute.assert_called_once_with(["todo.sh", "ls", "+work"])
114
+ mock_execute.assert_called_once_with(
115
+ ["todo.sh", "ls", "+work"], suppress_color=True
116
+ )
115
117
  assert result == "1. Work task"
116
118
 
117
119
  def test_complete_task_uses_do_command_with_task_number(self):
@@ -500,7 +502,9 @@ class TestTodoShell:
500
502
  result = self.todo_shell.list_projects()
501
503
 
502
504
  # Verify lsp command
503
- mock_execute.assert_called_once_with(["todo.sh", "lsp"])
505
+ mock_execute.assert_called_once_with(
506
+ ["todo.sh", "lsp"], suppress_color=True
507
+ )
504
508
  assert result == "+work\n+home\n+shopping"
505
509
 
506
510
  def test_list_contexts_uses_lsc_command(self):
@@ -511,7 +515,9 @@ class TestTodoShell:
511
515
  result = self.todo_shell.list_contexts()
512
516
 
513
517
  # Verify lsc command
514
- mock_execute.assert_called_once_with(["todo.sh", "lsc"])
518
+ mock_execute.assert_called_once_with(
519
+ ["todo.sh", "lsc"], suppress_color=True
520
+ )
515
521
  assert result == "@work\n@home\n@shopping"
516
522
 
517
523
  def test_list_completed_uses_listfile_command(self):
@@ -522,7 +528,9 @@ class TestTodoShell:
522
528
  result = self.todo_shell.list_completed()
523
529
 
524
530
  # Verify listfile command with done.txt
525
- mock_execute.assert_called_once_with(["todo.sh", "listfile", "done.txt"])
531
+ mock_execute.assert_called_once_with(
532
+ ["todo.sh", "listfile", "done.txt"], suppress_color=True
533
+ )
526
534
  assert result == "1. Completed task"
527
535
 
528
536
  def test_list_completed_with_filter_appends_filter(self):
@@ -534,7 +542,7 @@ class TestTodoShell:
534
542
 
535
543
  # Verify filter was appended to command
536
544
  mock_execute.assert_called_once_with(
537
- ["todo.sh", "listfile", "done.txt", "+work"]
545
+ ["todo.sh", "listfile", "done.txt", "+work"], suppress_color=True
538
546
  )
539
547
  assert result == "1. Work task completed"
540
548
 
@@ -584,6 +592,41 @@ class TestTodoShell:
584
592
  ):
585
593
  self.todo_shell.execute(["todo.sh", "add", "test"])
586
594
 
595
+ def test_execute_suppresses_color_codes_when_requested(self):
596
+ """Test that execute method strips ANSI color codes when suppress_color=True."""
597
+ # Mock subprocess to return output with ANSI color codes
598
+ mock_result = type(
599
+ "MockResult",
600
+ (),
601
+ {
602
+ "stdout": "\033[1;33m1\033[0m (A) \033[1;32m2025-08-29\033[0m Clean cat box \033[1;34m@home\033[0m \033[1;35m+chores\033[0m \033[1;31mdue:2025-08-29\033[0m",
603
+ "stderr": "",
604
+ "returncode": 0,
605
+ },
606
+ )()
607
+
608
+ with patch("subprocess.run", return_value=mock_result):
609
+ # Test with suppress_color=True (default for LLM consumption)
610
+ result = self.todo_shell.execute(["todo.sh", "ls"], suppress_color=True)
611
+
612
+ # Should return clean text without ANSI codes
613
+ assert (
614
+ result == "1 (A) 2025-08-29 Clean cat box @home +chores due:2025-08-29"
615
+ )
616
+ assert "\033[" not in result # No ANSI escape sequences
617
+
618
+ # Test with suppress_color=False (for interactive display)
619
+ result_with_color = self.todo_shell.execute(
620
+ ["todo.sh", "ls"], suppress_color=False
621
+ )
622
+
623
+ # Should preserve ANSI codes
624
+ assert "\033[" in result_with_color # ANSI escape sequences preserved
625
+ assert (
626
+ result_with_color
627
+ == "\033[1;33m1\033[0m (A) \033[1;32m2025-08-29\033[0m Clean cat box \033[1;34m@home\033[0m \033[1;35m+chores\033[0m \033[1;31mdue:2025-08-29\033[0m"
628
+ )
629
+
587
630
  def test_set_project_adds_projects_to_task_without_projects(self):
588
631
  """Test that set_project adds projects to task that doesn't have any."""
589
632
  # Mock the list_tasks to return a task without projects
@@ -141,7 +141,9 @@ class TestCLI:
141
141
  # Verify inference engine was called with correct input and progress callback
142
142
  call_args = self.cli.inference.process_request.call_args
143
143
  assert call_args[0][0] == user_input # First argument should be user_input
144
- assert call_args[0][1] is not None # Second argument should be progress callback
144
+ assert (
145
+ call_args[0][1] is not None
146
+ ) # Second argument should be progress callback
145
147
 
146
148
  def test_handle_request_exception_handling(self):
147
149
  """Test that exceptions in handle_request are properly caught and formatted."""
@@ -159,7 +161,9 @@ class TestCLI:
159
161
  # Verify inference engine was called with progress callback
160
162
  call_args = self.cli.inference.process_request.call_args
161
163
  assert call_args[0][0] == user_input # First argument should be user_input
162
- assert call_args[0][1] is not None # Second argument should be progress callback
164
+ assert (
165
+ call_args[0][1] is not None
166
+ ) # Second argument should be progress callback
163
167
 
164
168
  def test_run_single_request_delegates_to_handle_request(self):
165
169
  """Test that run_single_request properly delegates to handle_request."""
@@ -382,7 +386,9 @@ class TestCLI:
382
386
  # Should still call inference engine (let it handle empty input)
383
387
  call_args = self.cli.inference.process_request.call_args
384
388
  assert call_args[0][0] == "" # First argument should be empty string
385
- assert call_args[0][1] is not None # Second argument should be progress callback
389
+ assert (
390
+ call_args[0][1] is not None
391
+ ) # Second argument should be progress callback
386
392
 
387
393
  def test_long_input_truncation_in_logging(self):
388
394
  """Test that long inputs are properly truncated in logging."""
@@ -396,7 +402,9 @@ class TestCLI:
396
402
  # Verify inference engine was called with full input and progress callback
397
403
  call_args = self.cli.inference.process_request.call_args
398
404
  assert call_args[0][0] == long_input # First argument should be long_input
399
- assert call_args[0][1] is not None # Second argument should be progress callback
405
+ assert (
406
+ call_args[0][1] is not None
407
+ ) # Second argument should be progress callback
400
408
 
401
409
  # Verify response is correct
402
410
  assert result == "Response"
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.2'
32
- __version_tuple__ = version_tuple = (0, 3, 2)
31
+ __version__ = version = '0.3.3'
32
+ __version_tuple__ = version_tuple = (0, 3, 3)
33
33
 
34
- __commit_id__ = commit_id = 'gaaa8652a7'
34
+ __commit_id__ = commit_id = 'g4d82778ea'
@@ -35,7 +35,7 @@ class TodoShellError(TodoError):
35
35
 
36
36
  class ProviderError(Exception):
37
37
  """Base exception for LLM provider errors."""
38
-
38
+
39
39
  def __init__(self, message: str, error_type: str, provider: str):
40
40
  super().__init__(message)
41
41
  self.message = message
@@ -45,34 +45,34 @@ class ProviderError(Exception):
45
45
 
46
46
  class MalformedResponseError(ProviderError):
47
47
  """Provider returned malformed or invalid response."""
48
-
48
+
49
49
  def __init__(self, message: str, provider: str):
50
50
  super().__init__(message, "malformed_response", provider)
51
51
 
52
52
 
53
53
  class RateLimitError(ProviderError):
54
54
  """Provider rate limit exceeded."""
55
-
55
+
56
56
  def __init__(self, message: str, provider: str):
57
57
  super().__init__(message, "rate_limit", provider)
58
58
 
59
59
 
60
60
  class AuthenticationError(ProviderError):
61
61
  """Provider authentication failed."""
62
-
62
+
63
63
  def __init__(self, message: str, provider: str):
64
64
  super().__init__(message, "auth_error", provider)
65
65
 
66
66
 
67
67
  class TimeoutError(ProviderError):
68
68
  """Provider request timed out."""
69
-
69
+
70
70
  def __init__(self, message: str, provider: str):
71
71
  super().__init__(message, "timeout", provider)
72
72
 
73
73
 
74
74
  class GeneralProviderError(ProviderError):
75
75
  """General provider error."""
76
-
76
+
77
77
  def __init__(self, message: str, provider: str):
78
78
  super().__init__(message, "general_error", provider)
@@ -102,9 +102,11 @@ class TodoManager:
102
102
  self.todo_shell.add(full_description)
103
103
  return f"Added task: {full_description}"
104
104
 
105
- def list_tasks(self, filter: Optional[str] = None) -> str:
105
+ def list_tasks(
106
+ self, filter: Optional[str] = None, suppress_color: bool = True
107
+ ) -> str:
106
108
  """List tasks with optional filtering."""
107
- result = self.todo_shell.list_tasks(filter)
109
+ result = self.todo_shell.list_tasks(filter, suppress_color=suppress_color)
108
110
  if not result.strip():
109
111
  return "No tasks found."
110
112
 
@@ -258,16 +260,16 @@ class TodoManager:
258
260
  operation_desc = ", ".join(operations)
259
261
  return f"Updated projects for task {task_number} ({operation_desc}): {result}"
260
262
 
261
- def list_projects(self, **kwargs: Any) -> str:
263
+ def list_projects(self, suppress_color: bool = True, **kwargs: Any) -> str:
262
264
  """List all available projects in todo.txt."""
263
- result = self.todo_shell.list_projects()
265
+ result = self.todo_shell.list_projects(suppress_color=suppress_color)
264
266
  if not result.strip():
265
267
  return "No projects found."
266
268
  return result
267
269
 
268
- def list_contexts(self, **kwargs: Any) -> str:
270
+ def list_contexts(self, suppress_color: bool = True, **kwargs: Any) -> str:
269
271
  """List all available contexts in todo.txt."""
270
- result = self.todo_shell.list_contexts()
272
+ result = self.todo_shell.list_contexts(suppress_color=suppress_color)
271
273
  if not result.strip():
272
274
  return "No contexts found."
273
275
  return result
@@ -280,6 +282,7 @@ class TodoManager:
280
282
  text_search: Optional[str] = None,
281
283
  date_from: Optional[str] = None,
282
284
  date_to: Optional[str] = None,
285
+ suppress_color: bool = True,
283
286
  **kwargs: Any,
284
287
  ) -> str:
285
288
  """List completed tasks with optional filtering.
@@ -329,7 +332,9 @@ class TodoManager:
329
332
  # Combine all filters
330
333
  combined_filter = " ".join(filter_parts) if filter_parts else None
331
334
 
332
- result = self.todo_shell.list_completed(combined_filter)
335
+ result = self.todo_shell.list_completed(
336
+ combined_filter, suppress_color=suppress_color
337
+ )
333
338
  if not result.strip():
334
339
  return "No completed tasks found matching the criteria."
335
340
  return result
@@ -464,7 +469,7 @@ class TodoManager:
464
469
 
465
470
  # Use the move command to restore the task from done.txt to todo.txt
466
471
  result = self.todo_shell.move(task_number, "todo.txt", "done.txt")
467
-
472
+
468
473
  # Extract the task description from the result for confirmation
469
474
  # The result format is typically: "TODO: X moved from '.../done.txt' to '.../todo.txt'."
470
475
  if "moved from" in result and "to" in result: