todo-agent 0.2.8__py3-none-any.whl → 0.3.1__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
+ recurring: str | None
24
+ other_tags: list[str]
25
+
26
+
15
27
  class TodoShell:
16
28
  """Subprocess execution wrapper with error management."""
17
29
 
@@ -142,12 +154,337 @@ class TodoShell:
142
154
  """Archive completed tasks."""
143
155
  return self.execute(["todo.sh", "-f", "archive"])
144
156
 
145
- def deduplicate(self) -> str:
146
- """Remove duplicate tasks."""
147
- try:
148
- return self.execute(["todo.sh", "-f", "deduplicate"])
149
- except TodoShellError as e:
150
- # Handle the case where no duplicates are found (not really an error)
151
- if "No duplicate tasks found" in str(e):
152
- return "No duplicate tasks found"
153
- raise
157
+ def set_due_date(self, task_number: int, due_date: str) -> str:
158
+ """
159
+ Set or update due date for a task by intelligently rewriting it.
160
+
161
+ Args:
162
+ task_number: The task number to modify
163
+ due_date: Due date in YYYY-MM-DD format, or empty string to remove due date
164
+
165
+ Returns:
166
+ The updated task description
167
+ """
168
+ # First, get the current task to parse its components
169
+ tasks_output = self.list_tasks()
170
+ task_lines = tasks_output.strip().split("\n")
171
+
172
+ # Find the task by its actual number (not array index)
173
+ current_task = None
174
+ for line in task_lines:
175
+ if line.strip():
176
+ # Extract task number from the beginning of the line (handling ANSI codes)
177
+ extracted_number = self._extract_task_number(line)
178
+ if extracted_number == task_number:
179
+ current_task = line
180
+ break
181
+
182
+ if not current_task:
183
+ raise TodoShellError(f"Task number {task_number} not found")
184
+
185
+ # Parse the current task components
186
+ components = self._parse_task_components(current_task)
187
+
188
+ # Update the due date (empty string removes it)
189
+ if due_date.strip():
190
+ components["due"] = due_date
191
+ else:
192
+ components["due"] = None
193
+
194
+ # Reconstruct the task
195
+ new_description = self._reconstruct_task(components)
196
+
197
+ # Replace the task with the new description
198
+ return self.replace(task_number, new_description)
199
+
200
+ def set_context(self, task_number: int, context: str) -> str:
201
+ """
202
+ Set or update context for a task by intelligently rewriting it.
203
+
204
+ Args:
205
+ task_number: The task number to modify
206
+ context: Context name (without @ symbol), or empty string to remove context
207
+
208
+ Returns:
209
+ The updated task description
210
+ """
211
+ # First, get the current task to parse its components
212
+ tasks_output = self.list_tasks()
213
+ task_lines = tasks_output.strip().split("\n")
214
+
215
+ # Find the task by its actual number (not array index)
216
+ current_task = None
217
+ for line in task_lines:
218
+ if line.strip():
219
+ # Extract task number from the beginning of the line (handling ANSI codes)
220
+ extracted_number = self._extract_task_number(line)
221
+ if extracted_number == task_number:
222
+ current_task = line
223
+ break
224
+
225
+ if not current_task:
226
+ raise TodoShellError(f"Task number {task_number} not found")
227
+
228
+ # Parse the current task components
229
+ components = self._parse_task_components(current_task)
230
+
231
+ # Update the context (empty string removes it)
232
+ if context.strip():
233
+ # Remove any existing @ symbols to prevent duplication
234
+ clean_context = context.strip().lstrip("@")
235
+ if not clean_context:
236
+ raise TodoShellError(
237
+ "Context name cannot be empty after removing @ symbol."
238
+ )
239
+ context_tag = f"@{clean_context}"
240
+ # Only add if not already present (deduplication)
241
+ if context_tag not in components["contexts"]:
242
+ components["contexts"] = [context_tag]
243
+ else:
244
+ # Context already exists, no change needed
245
+ return self._reconstruct_task(components)
246
+ else:
247
+ components["contexts"] = []
248
+
249
+ # Reconstruct the task
250
+ new_description = self._reconstruct_task(components)
251
+
252
+ # Replace the task with the new description
253
+ return self.replace(task_number, new_description)
254
+
255
+ def _extract_task_number(self, line: str) -> Optional[int]:
256
+ """
257
+ Extract task number from a line that may contain ANSI color codes.
258
+
259
+ Args:
260
+ line: Task line that may contain ANSI color codes
261
+
262
+ Returns:
263
+ Task number if found, None otherwise
264
+ """
265
+ from rich.text import Text
266
+
267
+ # Use rich to properly handle ANSI color codes
268
+ text = Text.from_ansi(line)
269
+ clean_line = text.plain
270
+
271
+ # Split on first space and check if first part is a number
272
+ parts = clean_line.split(" ", 1)
273
+ if parts and parts[0].isdigit():
274
+ return int(parts[0])
275
+ return None
276
+
277
+ def set_project(self, task_number: int, projects: list) -> str:
278
+ """
279
+ Set or update projects for a task by intelligently rewriting it.
280
+
281
+ Args:
282
+ task_number: The task number to modify
283
+ projects: List of project operations. Each item can be:
284
+ - "project" (add project)
285
+ - "-project" (remove project)
286
+ - Empty string is a NOOP
287
+
288
+ Returns:
289
+ The updated task description
290
+ """
291
+ # First, get the current task to parse its components
292
+ tasks_output = self.list_tasks()
293
+ task_lines = tasks_output.strip().split("\n")
294
+
295
+ # Find the task by its actual number (not array index)
296
+ current_task = None
297
+ for line in task_lines:
298
+ if line.strip():
299
+ # Extract task number from the beginning of the line (handling ANSI codes)
300
+ extracted_number = self._extract_task_number(line)
301
+ if extracted_number == task_number:
302
+ current_task = line
303
+ break
304
+
305
+ if not current_task:
306
+ raise TodoShellError(f"Task number {task_number} not found")
307
+
308
+ # Parse the current task components
309
+ components = self._parse_task_components(current_task)
310
+
311
+ # Store original projects to check if any changes were made
312
+ original_projects = components["projects"].copy()
313
+
314
+ # Handle project operations
315
+ if not projects:
316
+ # Empty list is a NOOP - return original task unchanged
317
+ return self._reconstruct_task(components)
318
+ else:
319
+ # Process each project operation
320
+ for project in projects:
321
+ if not project.strip():
322
+ # Empty string is a NOOP - skip this operation
323
+ continue
324
+ elif project.startswith("-"):
325
+ # Remove project
326
+ clean_project = project[1:].strip().lstrip("+")
327
+ if not clean_project:
328
+ raise TodoShellError(
329
+ "Project name cannot be empty after removing - and + symbols."
330
+ )
331
+ # Remove the project if it exists (with or without + prefix)
332
+ project_to_remove = f"+{clean_project}"
333
+ components["projects"] = [
334
+ p
335
+ for p in components["projects"]
336
+ if p != project_to_remove and p != clean_project
337
+ ]
338
+ else:
339
+ # Add project
340
+ clean_project = project.strip().lstrip("+")
341
+ if not clean_project:
342
+ raise TodoShellError(
343
+ "Project name cannot be empty after removing + symbol."
344
+ )
345
+ project_tag = f"+{clean_project}"
346
+ # Only add if not already present (deduplication)
347
+ if project_tag not in components["projects"]:
348
+ components["projects"].append(project_tag)
349
+
350
+ # Check if any changes were actually made
351
+ if components["projects"] == original_projects:
352
+ # No changes made - return original task unchanged
353
+ return self._reconstruct_task(components)
354
+
355
+ # Reconstruct the task
356
+ new_description = self._reconstruct_task(components)
357
+
358
+ # Replace the task with the new description
359
+ return self.replace(task_number, new_description)
360
+
361
+ def _parse_task_components(self, task_line: str) -> TaskComponents:
362
+ """
363
+ Parse a todo.txt task line into its components.
364
+
365
+ Args:
366
+ task_line: Raw task line from todo.txt
367
+
368
+ Returns:
369
+ Dictionary with parsed components
370
+ """
371
+ # Remove ANSI color codes first using rich
372
+ from rich.text import Text
373
+
374
+ text = Text.from_ansi(task_line)
375
+ task_line = text.plain
376
+
377
+ # Remove task number prefix if present (e.g., "1 " or "1. ")
378
+ # First try the format without dot (standard todo.sh format)
379
+ if " " in task_line and task_line.split(" ")[0].isdigit():
380
+ task_line = task_line.split(" ", 1)[1]
381
+ # Fallback to dot format if present
382
+ elif ". " in task_line:
383
+ task_line = task_line.split(". ", 1)[1]
384
+
385
+ components: TaskComponents = {
386
+ "priority": None,
387
+ "description": "",
388
+ "projects": [],
389
+ "contexts": [],
390
+ "due": None,
391
+ "recurring": None,
392
+ "other_tags": [],
393
+ }
394
+
395
+ # Use sets to automatically deduplicate projects and contexts
396
+ projects_set = set()
397
+ contexts_set = set()
398
+ other_tags_set = set()
399
+
400
+ # Split by spaces to process each word
401
+ words = task_line.split()
402
+
403
+ for word in words:
404
+ # Priority: (A), (B), etc.
405
+ if word.startswith("(") and word.endswith(")") and len(word) == 3:
406
+ priority = word[1]
407
+ if priority.isalpha() and priority.isupper():
408
+ components["priority"] = priority
409
+ continue
410
+
411
+ # Projects: +project
412
+ if word.startswith("+"):
413
+ projects_set.add(word)
414
+ continue
415
+
416
+ # Contexts: @context
417
+ if word.startswith("@"):
418
+ contexts_set.add(word)
419
+ continue
420
+
421
+ # Due date: due:YYYY-MM-DD
422
+ if word.startswith("due:"):
423
+ components["due"] = word[4:] # Remove 'due:' prefix
424
+ continue
425
+
426
+ # Recurring: rec:frequency[:interval]
427
+ if word.startswith("rec:"):
428
+ components["recurring"] = word
429
+ continue
430
+
431
+ # Other tags (like custom tags)
432
+ if (
433
+ ":" in word
434
+ and not word.startswith("due:")
435
+ and not word.startswith("rec:")
436
+ ):
437
+ other_tags_set.add(word)
438
+ continue
439
+
440
+ # Regular description text
441
+ if components["description"]:
442
+ components["description"] += " " + word
443
+ else:
444
+ components["description"] = word
445
+
446
+ # Convert sets back to sorted lists for consistent ordering
447
+ components["projects"] = sorted(projects_set)
448
+ components["contexts"] = sorted(contexts_set)
449
+ components["other_tags"] = sorted(other_tags_set)
450
+
451
+ return components
452
+
453
+ def _reconstruct_task(self, components: TaskComponents) -> str:
454
+ """
455
+ Reconstruct a task description from parsed components.
456
+
457
+ Args:
458
+ components: Dictionary with task components
459
+
460
+ Returns:
461
+ Reconstructed task description
462
+ """
463
+ parts = []
464
+
465
+ # Add priority if present
466
+ if components["priority"]:
467
+ parts.append(f"({components['priority']})")
468
+
469
+ # Add description
470
+ if components["description"]:
471
+ parts.append(components["description"])
472
+
473
+ # Add projects
474
+ parts.extend(components["projects"])
475
+
476
+ # Add contexts
477
+ parts.extend(components["contexts"])
478
+
479
+ # Add due date
480
+ if components["due"]:
481
+ parts.append(f"due:{components['due']}")
482
+
483
+ # Add recurring pattern
484
+ if components["recurring"]:
485
+ parts.append(components["recurring"])
486
+
487
+ # Add other tags
488
+ parts.extend(components["other_tags"])
489
+
490
+ return " ".join(parts)
@@ -166,7 +166,7 @@ class CLI:
166
166
  while True:
167
167
  try:
168
168
  # Print prompt character on separate line to prevent deletion
169
- self.console.print("\n[bold cyan]▶[/bold cyan]", end="\n")
169
+ self.console.print("\n[bold cyan]▶[/bold cyan]", end=" ")
170
170
  user_input = self.console.input().strip()
171
171
 
172
172
  if user_input.lower() in ["quit", "exit", "q"]:
@@ -245,7 +245,9 @@ class CLI:
245
245
  formatted_response = ResponseFormatter.format_response(response)
246
246
 
247
247
  # Get memory usage
248
- memory_usage = self._get_memory_usage()
248
+ # DISABLED FOR NOW
249
+ # memory_usage = self._get_memory_usage()
250
+ memory_usage = None
249
251
 
250
252
  # Create response panel with memory usage
251
253
  response_panel = PanelFormatter.create_response_panel(