jott-cli 0.5.3__tar.gz → 0.5.5__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 (98) hide show
  1. {jott_cli-0.5.3/jott_cli.egg-info → jott_cli-0.5.5}/PKG-INFO +1 -1
  2. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/__init__.py +1 -1
  3. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/_app_navigation_mixin.py +1 -3
  4. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/_dispatch_mixin.py +27 -4
  5. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/app.py +1 -4
  6. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_transfer_mixin.py +120 -46
  7. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/handler.py +2 -2
  8. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_subtask_mixin.py +15 -0
  9. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/projects/registry.py +8 -0
  10. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_footer.py +1 -1
  11. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_help.py +15 -15
  12. {jott_cli-0.5.3 → jott_cli-0.5.5/jott_cli.egg-info}/PKG-INFO +1 -1
  13. {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/SOURCES.txt +2 -1
  14. {jott_cli-0.5.3 → jott_cli-0.5.5}/pyproject.toml +1 -1
  15. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_dispatch.py +1 -2
  16. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_jot.py +233 -108
  17. jott_cli-0.5.5/tests/test_transfer_subtasks.py +381 -0
  18. {jott_cli-0.5.3 → jott_cli-0.5.5}/LICENSE +0 -0
  19. {jott_cli-0.5.3 → jott_cli-0.5.5}/README.md +0 -0
  20. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/__init__.py +0 -0
  21. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/config.py +0 -0
  22. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/manager.py +0 -0
  23. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/categories/templates.py +0 -0
  24. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/__init__.py +0 -0
  25. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/archive.py +0 -0
  26. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/config.py +0 -0
  27. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/cli/views.py +0 -0
  28. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/__init__.py +0 -0
  29. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_ai_analysis_mixin.py +0 -0
  30. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_ai_suggest_mixin.py +0 -0
  31. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_audio_timer_mixin.py +0 -0
  32. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_bulk_mixin.py +0 -0
  33. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_context_mixin.py +0 -0
  34. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_core_mixin.py +0 -0
  35. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_gcal_mixin.py +0 -0
  36. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_metadata_mixin.py +0 -0
  37. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_notes_mixin.py +0 -0
  38. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/commands/_web_clipboard_mixin.py +0 -0
  39. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/__init__.py +0 -0
  40. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_age_backlog_mixin.py +0 -0
  41. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_compress_mixin.py +0 -0
  42. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_crud_mixin.py +0 -0
  43. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_delete_mixin.py +0 -0
  44. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_export_mixin.py +0 -0
  45. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_id_migration_mixin.py +0 -0
  46. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_metadata_mixin.py +0 -0
  47. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_navigation_mixin.py +0 -0
  48. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/_persistence_mixin.py +0 -0
  49. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/archive_manager.py +0 -0
  50. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/constants.py +0 -0
  51. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/id_manager.py +0 -0
  52. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/core/task_manager.py +0 -0
  53. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/__init__.py +0 -0
  54. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/__init__.py +0 -0
  55. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/account_manager.py +0 -0
  56. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/auth.py +0 -0
  57. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/gcal/events.py +0 -0
  58. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/__init__.py +0 -0
  59. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/_config_mixin.py +0 -0
  60. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  61. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/integrations/keywords/handler.py +0 -0
  62. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/__init__.py +0 -0
  63. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/handlers.py +0 -0
  64. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/schemas.py +0 -0
  65. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/mcp/server.py +0 -0
  66. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/projects/__init__.py +0 -0
  67. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/projects/backup.py +0 -0
  68. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/__init__.py +0 -0
  69. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display.py +0 -0
  70. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_archive.py +0 -0
  71. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_projects.py +0 -0
  72. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/display_tasks.py +0 -0
  73. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/formatting.py +0 -0
  74. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/input.py +0 -0
  75. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/picker.py +0 -0
  76. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/rendering.py +0 -0
  77. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/ui/styles.py +0 -0
  78. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/__init__.py +0 -0
  79. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/date_utils.py +0 -0
  80. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/text_utils.py +0 -0
  81. {jott_cli-0.5.3 → jott_cli-0.5.5}/jot/utils/validation.py +0 -0
  82. {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/dependency_links.txt +0 -0
  83. {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/entry_points.txt +0 -0
  84. {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/requires.txt +0 -0
  85. {jott_cli-0.5.3 → jott_cli-0.5.5}/jott_cli.egg-info/top_level.txt +0 -0
  86. {jott_cli-0.5.3 → jott_cli-0.5.5}/setup.cfg +0 -0
  87. {jott_cli-0.5.3 → jott_cli-0.5.5}/setup.py +0 -0
  88. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_command_handler.py +0 -0
  89. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_edit_edge_cases.py +0 -0
  90. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_fuzzy_search.py +0 -0
  91. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_gcal_notes.py +0 -0
  92. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_highlight.py +0 -0
  93. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_input.py +0 -0
  94. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_picker.py +0 -0
  95. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_styles.py +0 -0
  96. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_subtask_notes.py +0 -0
  97. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_terminal_wrap.py +0 -0
  98. {jott_cli-0.5.3 → jott_cli-0.5.5}/tests/test_today_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation
5
5
  Author-email: Scott Anderson <sonander@gmail.com>
6
6
  Maintainer-email: Scott Anderson <sonander@gmail.com>
@@ -7,7 +7,7 @@ import importlib.util
7
7
  import os
8
8
  from pathlib import Path
9
9
 
10
- __version__ = "0.5.2"
10
+ __version__ = "0.5.5"
11
11
 
12
12
  # When building Sphinx docs, skip the dynamic import bridge and heavy deps.
13
13
  # Set JOT_SPHINX_BUILD=1 in docs/sphinx/conf.py before importing jot.
@@ -38,16 +38,14 @@ class AppNavigationMixin:
38
38
  return False
39
39
 
40
40
  def _handle_toggle(self, key):
41
- """Process shared toggle keys A/O/Y/F/?. Returns True if
41
+ """Process shared toggle keys O/Y/F. Returns True if
42
42
  the key was a toggle key."""
43
43
  st = self.state
44
44
 
45
45
  toggle_map = {
46
- 'A': ('show_archived', 'archived tasks'),
47
46
  'O': ('sort_by_day', None),
48
47
  'Y': ('show_today_only', None),
49
48
  'F': ('show_notes_inline', 'inline notes'),
50
- '?': ('show_shortcuts', 'shortcuts'),
51
49
  }
52
50
 
53
51
  if key not in toggle_map:
@@ -30,7 +30,7 @@ class DispatchMixin:
30
30
  st.input_buffer = st.input_buffer[:-1]
31
31
  return
32
32
 
33
- if key == 'C':
33
+ if key == '\x03': # Ctrl+C
34
34
  self._handle_switch_category()
35
35
  return
36
36
 
@@ -153,7 +153,7 @@ class DispatchMixin:
153
153
  time.sleep(0.3)
154
154
  return
155
155
 
156
- if key in ('A', 'O', 'Y'):
156
+ if key in ('O', 'Y'):
157
157
  self._handle_toggle(key)
158
158
  return
159
159
 
@@ -296,11 +296,34 @@ class DispatchMixin:
296
296
  def _handle_dot_chord(self):
297
297
  """Handle '.' leader key — wait for second key to form chord."""
298
298
  st = self.state
299
- second = get_key(timeout=0.3)
300
- if second == 'c':
299
+ second = get_key(timeout=0.5)
300
+ if second == 's':
301
301
  self._toggle_collapse()
302
302
  st.input_buffer = ""
303
303
  return
304
+ if second == 'c':
305
+ self.command_handler.copy_to_clipboard()
306
+ st.input_buffer = ""
307
+ return
308
+ if second == '?':
309
+ st.show_shortcuts = not st.show_shortcuts
310
+ st.input_buffer = ""
311
+ return
312
+ if second == 'i':
313
+ self.command_handler.import_from_gcal()
314
+ st.input_buffer = ""
315
+ return
316
+ if second == 'g':
317
+ self.command_handler.web_search()
318
+ st.input_buffer = ""
319
+ return
320
+ if second == 'a':
321
+ st.show_archived = not st.show_archived
322
+ status = "Showing" if st.show_archived else "Hiding"
323
+ print(f"\n{CYAN}\u2713 {status} archived tasks{RESET}")
324
+ time.sleep(0.3)
325
+ st.input_buffer = ""
326
+ return
304
327
  # Not a chord — treat '.' as normal input
305
328
  st.input_buffer += '.'
306
329
  if second and second != '.':
@@ -59,16 +59,13 @@ class App(DispatchMixin, AppNavigationMixin):
59
59
  'M': 'move_task_to_project',
60
60
  '\x0b': 'copy_task_to_project', # Ctrl+K
61
61
  '\x14': 'transfer_task_to_category', # Ctrl+T
62
- 'S': 'web_search',
63
62
  '\x13': 'sync_subtasks', # Ctrl+S
64
- 'D': 'delete_current',
63
+ '\x04': 'delete_current', # Ctrl+D
65
64
  'W': 'assign_day',
66
65
  'P': 'set_priority',
67
66
  'H': 'set_priority_high',
68
67
  'X': 'set_status',
69
68
  'G': 'export_to_gcal',
70
- 'I': 'import_from_gcal',
71
- 'K': 'copy_to_clipboard',
72
69
  'E': 'trigger_keyword_action',
73
70
  'N': 'edit_task_notes',
74
71
  '\x15': 'open_url', # Ctrl+U
@@ -43,6 +43,58 @@ class TransferMixin:
43
43
  result = fuzzy_picker(items, prompt=prompt_text)
44
44
  return result.value
45
45
 
46
+ def _transfer_task_to_target(self, task, target_tm, labels=None):
47
+ """Add task to target_tm preserving metadata. Returns new task ID."""
48
+ new_id = target_tm.add_task(
49
+ task['text'],
50
+ priority=task.get('priority', 'none'),
51
+ status=task.get('status', 'todo'),
52
+ labels=labels if labels is not None else [],
53
+ effort=task.get('effort', None),
54
+ )
55
+ new_task = target_tm.tasks[-1]
56
+ for key in ('notes', 'agent_task', 'keyword', 'day', 'created_at'):
57
+ if task.get(key) is not None:
58
+ new_task[key] = task[key]
59
+ return new_id
60
+
61
+ def _transfer_descendants(self, parent_task, target_tm, id_map):
62
+ """Transfer all descendants of parent_task to target_tm.
63
+
64
+ Walks descendants in BFS order, remapping parent:{old} labels to
65
+ parent:{new} using the id_map. Updates id_map in place.
66
+ """
67
+ descendants = self.task_manager._get_descendants(parent_task['id'])
68
+ for child in descendants:
69
+ old_labels = child.get('labels', [])
70
+ new_labels = []
71
+ for label in old_labels:
72
+ if label.startswith('parent:'):
73
+ old_pid = int(label.split(':')[1])
74
+ new_pid = id_map.get(old_pid, old_pid)
75
+ new_labels.append(f"parent:{new_pid}")
76
+ else:
77
+ new_labels.append(label)
78
+ new_id = self._transfer_task_to_target(child, target_tm, labels=new_labels)
79
+ id_map[child['id']] = new_id
80
+ return descendants
81
+
82
+ def _remove_tasks_from_source(self, parent_id, descendants):
83
+ """Remove descendants first, then parent, from source task manager.
84
+
85
+ Removes children before parent so _unlink_children on the parent
86
+ doesn't redundantly strip labels from already-present tasks.
87
+ """
88
+ for child in reversed(descendants):
89
+ self.task_manager.remove_task(child['id'])
90
+ self.task_manager.remove_task(parent_id)
91
+
92
+ def _confirm_subtask_action(self, action, count, target_name):
93
+ """Prompt user to confirm moving/copying subtasks. Returns True if confirmed."""
94
+ prompt = f"{action} task + {count} subtask{'s' if count != 1 else ''} to {target_name}? (y/N): "
95
+ response = input(prompt).strip().lower()
96
+ return response == 'y'
97
+
46
98
  def copy_task_to_project(self):
47
99
  """Copy current task to a different project (keeps original)"""
48
100
  if not self.registry:
@@ -55,15 +107,20 @@ class TransferMixin:
55
107
  return True
56
108
 
57
109
  task_text = current_task['text']
110
+ descendants = self.task_manager._get_descendants(current_task['id'])
58
111
 
59
112
  target_project = self._pick_project_fuzzy(f"Copy '{task_text}' to:")
60
113
  if not target_project:
61
114
  print("\n✗ Cancelled")
62
115
  return True
63
116
 
117
+ if descendants:
118
+ if not self._confirm_subtask_action("Copy", len(descendants), target_project):
119
+ print("\n✗ Cancelled")
120
+ return True
121
+
64
122
  target_path = self.registry.get_project_path(target_project)
65
123
 
66
- # Check if destination has the same category
67
124
  current_category = self.task_manager.category
68
125
  target_category = None
69
126
  category_preserved = False
@@ -77,36 +134,32 @@ class TransferMixin:
77
134
  target_tm = TaskManager(
78
135
  directory=target_path, category=target_category, project_registry=self.registry
79
136
  )
80
- target_tm.add_task(
81
- task_text,
82
- priority=current_task.get('priority', 'none'),
83
- status=current_task.get('status', 'todo'),
84
- labels=[l for l in current_task.get('labels', [])
85
- if not l.startswith('parent:')],
86
- effort=current_task.get('effort', None),
137
+
138
+ # Copy parent task (strip parent: labels since parent is top-level in dest)
139
+ parent_labels = [l for l in current_task.get('labels', [])
140
+ if not l.startswith('parent:')]
141
+ new_parent_id = self._transfer_task_to_target(
142
+ current_task, target_tm, labels=parent_labels
87
143
  )
88
144
 
89
- # Preserve additional metadata on the new task
90
- new_task = target_tm.tasks[-1]
91
- new_task['notes'] = current_task.get('notes', '')
92
- new_task['agent_task'] = current_task.get('agent_task', False)
93
- new_task['keyword'] = current_task.get('keyword', None)
94
- new_task['day'] = current_task.get('day', None)
95
- if current_task.get('created_at'):
96
- new_task['created_at'] = current_task['created_at']
145
+ # Copy descendants with remapped parent labels
146
+ if descendants:
147
+ id_map = {current_task['id']: new_parent_id}
148
+ self._transfer_descendants(current_task, target_tm, id_map)
97
149
 
98
150
  target_tm._save_tasks()
99
151
  self.registry.record_usage(target_project)
100
152
 
153
+ count_msg = f" + {len(descendants)} subtask{'s' if len(descendants) != 1 else ''}" if descendants else ""
101
154
  if category_preserved:
102
- print(f"✓ Copied task to {target_project} (kept in '{current_category}' category)")
155
+ print(f"✓ Copied task{count_msg} to {target_project} (kept in '{current_category}' category)")
103
156
  elif current_category:
104
157
  print(
105
- f"✓ Copied task to {target_project} "
158
+ f"✓ Copied task{count_msg} to {target_project} "
106
159
  f"(category '{current_category}' not available, added to default)"
107
160
  )
108
161
  else:
109
- print(f"✓ Copied task to {target_project}")
162
+ print(f"✓ Copied task{count_msg} to {target_project}")
110
163
  return True
111
164
 
112
165
  def move_task_to_project(self):
@@ -122,12 +175,18 @@ class TransferMixin:
122
175
 
123
176
  task_id = current_task['id']
124
177
  task_text = current_task['text']
178
+ descendants = self.task_manager._get_descendants(task_id)
125
179
 
126
180
  target_project = self._pick_project_fuzzy(f"Move '{task_text}' to:")
127
181
  if not target_project:
128
182
  print("\n✗ Cancelled")
129
183
  return True
130
184
 
185
+ if descendants:
186
+ if not self._confirm_subtask_action("Move", len(descendants), target_project):
187
+ print("\n✗ Cancelled")
188
+ return True
189
+
131
190
  target_path = self.registry.get_project_path(target_project)
132
191
 
133
192
  current_category = self.task_manager.category
@@ -143,36 +202,35 @@ class TransferMixin:
143
202
  target_tm = TaskManager(
144
203
  directory=target_path, category=target_category, project_registry=self.registry
145
204
  )
146
- target_tm.add_task(
147
- task_text,
148
- priority=current_task.get('priority', 'none'),
149
- status=current_task.get('status', 'todo'),
150
- labels=[l for l in current_task.get('labels', [])
151
- if not l.startswith('parent:')],
152
- effort=current_task.get('effort', None),
205
+
206
+ # Move parent task
207
+ parent_labels = [l for l in current_task.get('labels', [])
208
+ if not l.startswith('parent:')]
209
+ new_parent_id = self._transfer_task_to_target(
210
+ current_task, target_tm, labels=parent_labels
153
211
  )
154
212
 
155
- new_task = target_tm.tasks[-1]
156
- new_task['notes'] = current_task.get('notes', '')
157
- new_task['agent_task'] = current_task.get('agent_task', False)
158
- new_task['keyword'] = current_task.get('keyword', None)
159
- new_task['day'] = current_task.get('day', None)
160
- if current_task.get('created_at'):
161
- new_task['created_at'] = current_task['created_at']
213
+ # Move descendants with remapped parent labels
214
+ if descendants:
215
+ id_map = {task_id: new_parent_id}
216
+ self._transfer_descendants(current_task, target_tm, id_map)
162
217
 
163
218
  target_tm._save_tasks()
164
- self.task_manager.remove_task(task_id)
219
+
220
+ # Remove from source: descendants first, then parent
221
+ self._remove_tasks_from_source(task_id, descendants)
165
222
  self.registry.record_usage(target_project)
166
223
 
224
+ count_msg = f" + {len(descendants)} subtask{'s' if len(descendants) != 1 else ''}" if descendants else ""
167
225
  if category_preserved:
168
- print(f"✓ Moved task to {target_project} (kept in '{current_category}' category)")
226
+ print(f"✓ Moved task{count_msg} to {target_project} (kept in '{current_category}' category)")
169
227
  elif current_category:
170
228
  print(
171
- f"✓ Moved task to {target_project} "
229
+ f"✓ Moved task{count_msg} to {target_project} "
172
230
  f"(category '{current_category}' not available, added to default)"
173
231
  )
174
232
  else:
175
- print(f"✓ Moved task to {target_project}")
233
+ print(f"✓ Moved task{count_msg} to {target_project}")
176
234
  return True
177
235
 
178
236
  def transfer_task_to_category(self):
@@ -184,7 +242,7 @@ class TransferMixin:
184
242
 
185
243
  task_text = current_task['text']
186
244
  task_id = current_task['id']
187
- task_done = current_task.get('done', False)
245
+ descendants = self.task_manager._get_descendants(task_id)
188
246
 
189
247
  project_dir = (
190
248
  self.task_manager.project_dir if not self.task_manager.is_global else Path.cwd()
@@ -235,6 +293,12 @@ class TransferMixin:
235
293
  return True
236
294
 
237
295
  selected_category, selected_is_global = result.value
296
+ dest_name = selected_category or 'default'
297
+
298
+ if descendants:
299
+ if not self._confirm_subtask_action("Transfer", len(descendants), dest_name):
300
+ print("\n✗ Cancelled")
301
+ return True
238
302
 
239
303
  if selected_is_global:
240
304
  dest_tm = TaskManager(
@@ -248,18 +312,28 @@ class TransferMixin:
248
312
  project_registry=self.registry,
249
313
  )
250
314
 
251
- dest_tm.add_task(task_text)
252
- if task_done:
253
- new_task_id = dest_tm.tasks[-1]['id']
254
- dest_tm.set_task_status(new_task_id, 'done')
315
+ # Transfer parent task with full metadata
316
+ parent_labels = [l for l in current_task.get('labels', [])
317
+ if not l.startswith('parent:')]
318
+ new_parent_id = self._transfer_task_to_target(
319
+ current_task, dest_tm, labels=parent_labels
320
+ )
255
321
 
256
- self.task_manager.remove_task(task_id)
322
+ # Transfer descendants with remapped parent labels
323
+ if descendants:
324
+ id_map = {task_id: new_parent_id}
325
+ self._transfer_descendants(current_task, dest_tm, id_map)
257
326
 
258
- dest_name = selected_category or 'default'
327
+ dest_tm._save_tasks()
328
+
329
+ # Remove from source: descendants first, then parent
330
+ self._remove_tasks_from_source(task_id, descendants)
331
+
332
+ count_msg = f" + {len(descendants)} subtask{'s' if len(descendants) != 1 else ''}" if descendants else ""
259
333
  if selected_is_global:
260
- print(f"✓ Transferred task to global:{dest_name}")
334
+ print(f"✓ Transferred task{count_msg} to global:{dest_name}")
261
335
  else:
262
- print(f"✓ Transferred task to {dest_name}")
336
+ print(f"✓ Transferred task{count_msg} to {dest_name}")
263
337
 
264
338
  print("\nPress Enter to continue...", end='', flush=True)
265
339
  input()
@@ -35,7 +35,7 @@ class CommandHandler(
35
35
  'a': self.add_task,
36
36
  'c': self.set_current,
37
37
  'd': self.delete_task,
38
- 'D': self.delete_current,
38
+ '\x04': self.delete_current, # Ctrl+D
39
39
  'e': self.edit_task,
40
40
  'E': self.edit_current,
41
41
  'M': self.move_task_to_project,
@@ -46,7 +46,7 @@ class CommandHandler(
46
46
 
47
47
  def handle(self, command):
48
48
  """Handle a command"""
49
- # Check for exact match first (for case-sensitive commands like 'D')
49
+ # Check for exact match first (for case-sensitive commands like 'E')
50
50
  handler = self.commands.get(command)
51
51
  if not handler:
52
52
  # Fall back to lowercase
@@ -55,6 +55,21 @@ def _finalize_nested(item, nested_lines):
55
55
  class SubtaskMixin:
56
56
  """Sync subtasks from task notes checklists."""
57
57
 
58
+ def _get_descendants(self, task_id):
59
+ """Return all descendant tasks (BFS) for a parent task."""
60
+ descendants = []
61
+ queue = [task_id]
62
+ visited = {task_id}
63
+ while queue:
64
+ pid = queue.pop(0)
65
+ parent_label = f"parent:{pid}"
66
+ for task in self.tasks:
67
+ if parent_label in task.get('labels', []) and task['id'] not in visited:
68
+ visited.add(task['id'])
69
+ descendants.append(task)
70
+ queue.append(task['id'])
71
+ return descendants
72
+
58
73
  def sync_subtasks_from_notes(self, task_id):
59
74
  """Parse checklist items from task notes and sync as subtasks.
60
75
 
@@ -117,6 +117,14 @@ class ProjectRegistry:
117
117
  entry["usage_count"] = entry.get("usage_count", 0) + 1
118
118
  self._save_registry()
119
119
 
120
+ def is_path_registered(self, path):
121
+ """Check if a directory path is already registered"""
122
+ resolved = str(Path(path).expanduser().resolve())
123
+ for entry in self.projects.values():
124
+ if _get_path(entry) == resolved:
125
+ return True
126
+ return False
127
+
120
128
  def refresh(self):
121
129
  """Re-run auto-discovery"""
122
130
  self._auto_discover()
@@ -33,7 +33,7 @@ def _render_quick_add(input_buffer, show_shortcuts):
33
33
  f"{GREEN}[QUICK ADD]{RESET} Type task, {BOLD}Enter{RESET} "
34
34
  f"to save | {BOLD}/{RESET}: search | {BOLD}ESC{RESET}: "
35
35
  f"commands | {BOLD}Shift+V{RESET}: multi-select | "
36
- f"{BOLD}?{RESET}: shortcuts"
36
+ f"{BOLD}.?{RESET}: shortcuts"
37
37
  )
38
38
  if show_shortcuts:
39
39
  from jot.ui.display_help import display_categorized_shortcuts
@@ -11,7 +11,7 @@ def display_categorized_shortcuts():
11
11
  ("Tab", "Cycle through categories"),
12
12
  ("Ctrl+F", "Fuzzy search tasks"),
13
13
  ("Shift+Z", "Switch project"),
14
- ("Shift+C", "Switch category"),
14
+ ("Ctrl+C", "Switch category"),
15
15
  ("Shift+L", "Toggle all categories view"),
16
16
  ],
17
17
  "Task Management": [
@@ -21,7 +21,7 @@ def display_categorized_shortcuts():
21
21
  ("d", "Delete task"),
22
22
  ("t", "Toggle task done"),
23
23
  ("a", "Archive task"),
24
- ("Shift+A", "Toggle archive view"),
24
+ (".a", "Toggle archive view"),
25
25
  ("m", "Move to different category"),
26
26
  ("Shift+M", "Move task to another project"),
27
27
  ("Ctrl+K", "Copy task to another project"),
@@ -40,7 +40,7 @@ def display_categorized_shortcuts():
40
40
  ("Shift+W", "Assign day to task"),
41
41
  ("Shift+O", "Toggle day sorting"),
42
42
  ("Shift+Y", "Toggle today filter"),
43
- ("Shift+D", "Mark current as done"),
43
+ ("Ctrl+D", "Mark current as done"),
44
44
  ("Shift+J", "Mark as agent task"),
45
45
  ("*", "Highlight color picker"),
46
46
  ("~", "Quick highlight toggle"),
@@ -64,12 +64,12 @@ def display_categorized_shortcuts():
64
64
  ("Shift+4", "AI task suggestion"),
65
65
  ("Shift+E", "Execute keyword action"),
66
66
  ("Shift+G", "Google Calendar setup"),
67
- ("Shift+I", "Import from Google Calendar"),
68
- ("Shift+K", "Copy task to clipboard"),
67
+ (".i", "Import from Google Calendar"),
68
+ (".c", "Copy task to clipboard"),
69
69
  ("Ctrl+U", "Open URLs in task"),
70
70
  ],
71
71
  "System": [
72
- ("h/?", "Show this help"),
72
+ ("h/.?", "Show this help"),
73
73
  ("r", "Refresh display"),
74
74
  ("Ctrl+R", "Register current project"),
75
75
  ("q", "Quit"),
@@ -97,7 +97,7 @@ def display_help():
97
97
  """Display comprehensive help information."""
98
98
  help_text = f"""
99
99
  {BOLD}Jott - Simple Interactive Task List{RESET}
100
- {DIM}Version 0.5.2{RESET}
100
+ {DIM}Version 0.5.5{RESET}
101
101
 
102
102
  {BOLD}BASIC USAGE:{RESET}
103
103
  jott Start interactive task manager (current project)
@@ -119,7 +119,7 @@ def display_help():
119
119
  {CYAN}Tab{RESET} Cycle through categories
120
120
  {CYAN}Ctrl+F{RESET} Fuzzy search tasks
121
121
  {CYAN}Shift+Z{RESET} Switch between projects
122
- {CYAN}Shift+C{RESET} Switch between categories
122
+ {CYAN}Ctrl+C{RESET} Switch between categories
123
123
  {CYAN}Shift+L{RESET} Toggle all categories view
124
124
 
125
125
  {BOLD}TASK MANAGEMENT:{RESET}
@@ -129,7 +129,7 @@ def display_help():
129
129
  {CYAN}d{RESET} Delete selected task
130
130
  {CYAN}t{RESET} Toggle task done/undone
131
131
  {CYAN}a{RESET} Archive task (mark as done/canceled)
132
- {CYAN}Shift+A{RESET} Toggle archive view
132
+ {CYAN}.a{RESET} Toggle archive view
133
133
  {CYAN}m{RESET} Move task to different category
134
134
  {CYAN}Shift+M{RESET} Move task to another project
135
135
  {CYAN}Ctrl+K{RESET} Copy task to another project
@@ -147,7 +147,7 @@ def display_help():
147
147
  {CYAN}Shift+W{RESET} Assign day to task
148
148
  {CYAN}Shift+O{RESET} Toggle day sorting
149
149
  {CYAN}Shift+Y{RESET} Toggle today filter
150
- {CYAN}Shift+D{RESET} Mark current task as done
150
+ {CYAN}Ctrl+D{RESET} Mark current task as done
151
151
  {CYAN}Shift+J{RESET} Mark task for agent/AI assistance
152
152
  {CYAN}*{RESET} Highlight color picker (6 colors)
153
153
  {CYAN}~{RESET} Quick highlight toggle (default color)
@@ -185,7 +185,7 @@ def display_help():
185
185
 
186
186
  {CYAN}jott -c backend{RESET} Start in "backend" category
187
187
  {CYAN}Tab{RESET} Cycle through categories
188
- {CYAN}Shift+C{RESET} Create/switch categories interactively
188
+ {CYAN}Ctrl+C{RESET} Create/switch categories interactively
189
189
  {CYAN}m{RESET} Move task between categories
190
190
 
191
191
  {BOLD}PROJECTS:{RESET}
@@ -200,8 +200,8 @@ def display_help():
200
200
  {CYAN}Shift+4{RESET} AI task suggestion
201
201
  {CYAN}Shift+E{RESET} Execute keyword action for task
202
202
  {CYAN}Shift+G{RESET} Set up Google Calendar integration
203
- {CYAN}Shift+I{RESET} Import tasks from Google Calendar
204
- {CYAN}Shift+K{RESET} Copy task text to clipboard
203
+ {CYAN}.i{RESET} Import tasks from Google Calendar
204
+ {CYAN}.c{RESET} Copy task text to clipboard
205
205
  {CYAN}Ctrl+U{RESET} Open URLs found in task text
206
206
 
207
207
  {BOLD}FUZZY SEARCH:{RESET}
@@ -212,7 +212,7 @@ def display_help():
212
212
  {CYAN}Esc{RESET} Exit search
213
213
 
214
214
  {BOLD}SYSTEM:{RESET}
215
- {CYAN}h or ?{RESET} Show keyboard shortcuts
215
+ {CYAN}h or .?{RESET} Show keyboard shortcuts
216
216
  {CYAN}r{RESET} Refresh display
217
217
  {CYAN}Ctrl+R{RESET} Register current project in registry
218
218
  {CYAN}q{RESET} Quit
@@ -227,7 +227,7 @@ def display_help():
227
227
 
228
228
  {BOLD}CONFIGURATION:{RESET}
229
229
  • Edit ~/.jot-keywords.json to customize keyword actions
230
- • Category colors: Edit category config (Shift+C)
230
+ • Category colors: Edit category config (Ctrl+C)
231
231
  • Global vs local: Use -g flag or create global categories
232
232
 
233
233
  {DIM}For more info: https://github.com/your-repo/jot{RESET}"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation
5
5
  Author-email: Scott Anderson <sonander@gmail.com>
6
6
  Maintainer-email: Scott Anderson <sonander@gmail.com>
@@ -92,4 +92,5 @@ tests/test_picker.py
92
92
  tests/test_styles.py
93
93
  tests/test_subtask_notes.py
94
94
  tests/test_terminal_wrap.py
95
- tests/test_today_filter.py
95
+ tests/test_today_filter.py
96
+ tests/test_transfer_subtasks.py
@@ -7,7 +7,7 @@ include = ["jot", "jot.*"]
7
7
 
8
8
  [project]
9
9
  name = "jott-cli"
10
- version = "0.5.3"
10
+ version = "0.5.5"
11
11
  description = "Feature-rich interactive CLI task manager with AI integration, calendar sync, and keyword automation"
12
12
  readme = {file = "README.md", content-type = "text/markdown"}
13
13
  requires-python = ">=3.6"
@@ -82,13 +82,12 @@ class TestQuickAddDispatchTable:
82
82
  def test_critical_keys_present(self):
83
83
  """Verify that the most important shortcuts are wired up."""
84
84
  critical = {
85
- 'D': 'delete_current',
85
+ '\x04': 'delete_current', # Ctrl+D
86
86
  'W': 'assign_day',
87
87
  'P': 'set_priority',
88
88
  'X': 'set_status',
89
89
  'M': 'move_task_to_project',
90
90
  'N': 'edit_task_notes',
91
- 'S': 'web_search',
92
91
  '\x14': 'transfer_task_to_category', # Ctrl+T
93
92
  }
94
93
  for key, expected_method in critical.items():