jott-cli 0.5.2__tar.gz → 0.5.4__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 (97) hide show
  1. {jott_cli-0.5.2/jott_cli.egg-info → jott_cli-0.5.4}/PKG-INFO +1 -1
  2. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/__init__.py +1 -1
  3. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/_app_navigation_mixin.py +5 -3
  4. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/_dispatch_mixin.py +106 -3
  5. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/app.py +76 -29
  6. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_transfer_mixin.py +4 -2
  7. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/handler.py +2 -2
  8. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_delete_mixin.py +10 -0
  9. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/projects/registry.py +8 -0
  10. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_footer.py +3 -3
  11. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_help.py +12 -12
  12. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_tasks.py +19 -6
  13. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/picker.py +59 -56
  14. jott_cli-0.5.4/jot/ui/rendering.py +24 -0
  15. {jott_cli-0.5.2 → jott_cli-0.5.4/jott_cli.egg-info}/PKG-INFO +1 -1
  16. {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/SOURCES.txt +1 -0
  17. {jott_cli-0.5.2 → jott_cli-0.5.4}/pyproject.toml +1 -1
  18. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_dispatch.py +1 -2
  19. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_jot.py +223 -108
  20. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_subtask_notes.py +340 -1
  21. {jott_cli-0.5.2 → jott_cli-0.5.4}/LICENSE +0 -0
  22. {jott_cli-0.5.2 → jott_cli-0.5.4}/README.md +0 -0
  23. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/__init__.py +0 -0
  24. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/config.py +0 -0
  25. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/manager.py +0 -0
  26. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/categories/templates.py +0 -0
  27. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/__init__.py +0 -0
  28. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/archive.py +0 -0
  29. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/config.py +0 -0
  30. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/cli/views.py +0 -0
  31. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/__init__.py +0 -0
  32. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_ai_analysis_mixin.py +0 -0
  33. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_ai_suggest_mixin.py +0 -0
  34. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_audio_timer_mixin.py +0 -0
  35. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_bulk_mixin.py +0 -0
  36. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_context_mixin.py +0 -0
  37. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_core_mixin.py +0 -0
  38. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_gcal_mixin.py +0 -0
  39. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_metadata_mixin.py +0 -0
  40. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_notes_mixin.py +0 -0
  41. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/commands/_web_clipboard_mixin.py +0 -0
  42. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/__init__.py +0 -0
  43. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_age_backlog_mixin.py +0 -0
  44. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_compress_mixin.py +0 -0
  45. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_crud_mixin.py +0 -0
  46. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_export_mixin.py +0 -0
  47. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_id_migration_mixin.py +0 -0
  48. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_metadata_mixin.py +0 -0
  49. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_navigation_mixin.py +0 -0
  50. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_persistence_mixin.py +0 -0
  51. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/_subtask_mixin.py +0 -0
  52. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/archive_manager.py +0 -0
  53. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/constants.py +0 -0
  54. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/id_manager.py +0 -0
  55. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/core/task_manager.py +0 -0
  56. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/__init__.py +0 -0
  57. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/__init__.py +0 -0
  58. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/account_manager.py +0 -0
  59. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/auth.py +0 -0
  60. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/gcal/events.py +0 -0
  61. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/__init__.py +0 -0
  62. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/_config_mixin.py +0 -0
  63. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  64. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/integrations/keywords/handler.py +0 -0
  65. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/__init__.py +0 -0
  66. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/handlers.py +0 -0
  67. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/schemas.py +0 -0
  68. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/mcp/server.py +0 -0
  69. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/projects/__init__.py +0 -0
  70. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/projects/backup.py +0 -0
  71. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/__init__.py +0 -0
  72. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display.py +0 -0
  73. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_archive.py +0 -0
  74. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/display_projects.py +0 -0
  75. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/formatting.py +0 -0
  76. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/input.py +0 -0
  77. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/ui/styles.py +0 -0
  78. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/__init__.py +0 -0
  79. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/date_utils.py +0 -0
  80. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/text_utils.py +0 -0
  81. {jott_cli-0.5.2 → jott_cli-0.5.4}/jot/utils/validation.py +0 -0
  82. {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/dependency_links.txt +0 -0
  83. {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/entry_points.txt +0 -0
  84. {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/requires.txt +0 -0
  85. {jott_cli-0.5.2 → jott_cli-0.5.4}/jott_cli.egg-info/top_level.txt +0 -0
  86. {jott_cli-0.5.2 → jott_cli-0.5.4}/setup.cfg +0 -0
  87. {jott_cli-0.5.2 → jott_cli-0.5.4}/setup.py +0 -0
  88. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_command_handler.py +0 -0
  89. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_edit_edge_cases.py +0 -0
  90. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_fuzzy_search.py +0 -0
  91. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_gcal_notes.py +0 -0
  92. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_highlight.py +0 -0
  93. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_input.py +0 -0
  94. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_picker.py +0 -0
  95. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_styles.py +0 -0
  96. {jott_cli-0.5.2 → jott_cli-0.5.4}/tests/test_terminal_wrap.py +0 -0
  97. {jott_cli-0.5.2 → jott_cli-0.5.4}/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.2
3
+ Version: 0.5.4
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.4"
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.
@@ -17,8 +17,11 @@ class AppNavigationMixin:
17
17
  def _handle_navigation(self, key, tasks_to_display):
18
18
  """Process UP/DOWN/SHIFT_UP/SHIFT_DOWN. Returns True if
19
19
  the key was a navigation key."""
20
+ st = self.state
20
21
  filtered = (
21
- tasks_to_display if self.state.show_today_only else None)
22
+ tasks_to_display
23
+ if st.show_today_only or st.collapsed_parents
24
+ else None)
22
25
 
23
26
  if key == 'UP':
24
27
  self.task_manager.set_prev_current(filtered)
@@ -35,7 +38,7 @@ class AppNavigationMixin:
35
38
  return False
36
39
 
37
40
  def _handle_toggle(self, key):
38
- """Process shared toggle keys A/O/Y/F/?. Returns True if
41
+ """Process shared toggle keys A/O/Y/F. Returns True if
39
42
  the key was a toggle key."""
40
43
  st = self.state
41
44
 
@@ -44,7 +47,6 @@ class AppNavigationMixin:
44
47
  'O': ('sort_by_day', None),
45
48
  'Y': ('show_today_only', None),
46
49
  'F': ('show_notes_inline', 'inline notes'),
47
- '?': ('show_shortcuts', 'shortcuts'),
48
50
  }
49
51
 
50
52
  if key not in toggle_map:
@@ -6,6 +6,7 @@ from jot.core.constants import (
6
6
  MODE_QUICK_ADD, MODE_COMMAND, MODE_MULTISELECT,
7
7
  MODE_FUZZY_SEARCH, MODE_ALL_CATEGORIES,
8
8
  )
9
+ from jot.ui.input import get_key
9
10
  from jot.ui.styles import RESET, CYAN
10
11
 
11
12
 
@@ -29,7 +30,7 @@ class DispatchMixin:
29
30
  st.input_buffer = st.input_buffer[:-1]
30
31
  return
31
32
 
32
- if key == 'C':
33
+ if key == '\x03': # Ctrl+C
33
34
  self._handle_switch_category()
34
35
  return
35
36
 
@@ -79,6 +80,10 @@ class DispatchMixin:
79
80
  if self._handle_navigation(key, tasks_to_display):
80
81
  return
81
82
 
83
+ if key == '.':
84
+ self._handle_dot_chord()
85
+ return
86
+
82
87
  if len(key) == 1 and key.isprintable():
83
88
  st.input_buffer += key
84
89
 
@@ -184,7 +189,9 @@ class DispatchMixin:
184
189
  return
185
190
 
186
191
  filtered = (
187
- tasks_to_display if st.show_today_only else None)
192
+ tasks_to_display
193
+ if st.show_today_only or st.collapsed_parents
194
+ else None)
188
195
 
189
196
  if key == 'UP':
190
197
  self.task_manager.set_prev_current(filtered)
@@ -286,6 +293,100 @@ class DispatchMixin:
286
293
  self.task_manager.set_current(
287
294
  st.filtered_tasks[idx + 1][0]['id'])
288
295
 
296
+ def _handle_dot_chord(self):
297
+ """Handle '.' leader key — wait for second key to form chord."""
298
+ st = self.state
299
+ second = get_key(timeout=0.5)
300
+ if second == 's':
301
+ self._toggle_collapse()
302
+ st.input_buffer = ""
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
+ # Not a chord — treat '.' as normal input
321
+ st.input_buffer += '.'
322
+ if second and second != '.':
323
+ st.input_buffer += second
324
+
325
+ def _toggle_collapse(self):
326
+ """Toggle collapse state for current task's parent group."""
327
+ task = self.task_manager.get_current_task()
328
+ if not task:
329
+ return
330
+
331
+ tasks = self.task_manager.get_tasks()
332
+ collapsed = self.state.collapsed_parents
333
+
334
+ # Check if current task is a parent (has descendants)
335
+ has_children = any(
336
+ f'parent:{task["id"]}' in t.get('labels', [])
337
+ for t in tasks
338
+ )
339
+
340
+ if has_children:
341
+ target_id = task['id']
342
+ else:
343
+ # Check if it's a child — toggle its parent
344
+ target_id = None
345
+ for label in task.get('labels', []):
346
+ if label.startswith('parent:'):
347
+ pid = label.split(':', 1)[1]
348
+ try:
349
+ target_id = int(pid)
350
+ except ValueError:
351
+ target_id = pid
352
+ break
353
+
354
+ if target_id is None:
355
+ return
356
+
357
+ # Count descendants for feedback
358
+ children_of = {}
359
+ for t in tasks:
360
+ for label in t.get('labels', []):
361
+ if label.startswith('parent:'):
362
+ pid = label.split(':', 1)[1]
363
+ try:
364
+ pid = int(pid)
365
+ except ValueError:
366
+ pass
367
+ children_of.setdefault(pid, []).append(t['id'])
368
+ break
369
+
370
+ count = 0
371
+ stack = list(children_of.get(target_id, []))
372
+ seen = set()
373
+ while stack:
374
+ cid = stack.pop()
375
+ if cid not in seen:
376
+ seen.add(cid)
377
+ count += 1
378
+ stack.extend(children_of.get(cid, []))
379
+
380
+ if target_id in collapsed:
381
+ collapsed.discard(target_id)
382
+ print(f"\n{CYAN}▼ Expanded {count} subtask"
383
+ f"{'s' if count != 1 else ''}{RESET}")
384
+ else:
385
+ collapsed.add(target_id)
386
+ print(f"\n{CYAN}▶ Collapsed {count} subtask"
387
+ f"{'s' if count != 1 else ''}{RESET}")
388
+ time.sleep(0.3)
389
+
289
390
  def _dispatch_all_categories(self, key, tasks_to_display):
290
391
  """Handle a keypress in ALL_CATEGORIES mode."""
291
392
  st = self.state
@@ -295,7 +396,9 @@ class DispatchMixin:
295
396
  return
296
397
 
297
398
  filtered = (
298
- tasks_to_display if st.show_today_only else None)
399
+ tasks_to_display
400
+ if st.show_today_only or st.collapsed_parents
401
+ else None)
299
402
  if key == 'UP':
300
403
  self.task_manager.set_prev_current(filtered)
301
404
  elif key == 'DOWN':
@@ -20,6 +20,7 @@ from jot.utils.date_utils import (
20
20
  from jot.utils.text_utils import fuzzy_match
21
21
  from jot._dispatch_mixin import DispatchMixin
22
22
  from jot._app_navigation_mixin import AppNavigationMixin
23
+ from jot.ui.rendering import buffered_output
23
24
 
24
25
 
25
26
  # ---------------------------------------------------------------------------
@@ -44,6 +45,7 @@ class AppState:
44
45
  filtered_tasks: list = field(default_factory=list)
45
46
  archived_task_ids: set = field(default_factory=set)
46
47
  match_positions: dict = field(default_factory=dict)
48
+ collapsed_parents: set = field(default_factory=set)
47
49
 
48
50
 
49
51
  # ---------------------------------------------------------------------------
@@ -57,16 +59,13 @@ class App(DispatchMixin, AppNavigationMixin):
57
59
  'M': 'move_task_to_project',
58
60
  '\x0b': 'copy_task_to_project', # Ctrl+K
59
61
  '\x14': 'transfer_task_to_category', # Ctrl+T
60
- 'S': 'web_search',
61
62
  '\x13': 'sync_subtasks', # Ctrl+S
62
- 'D': 'delete_current',
63
+ '\x04': 'delete_current', # Ctrl+D
63
64
  'W': 'assign_day',
64
65
  'P': 'set_priority',
65
66
  'H': 'set_priority_high',
66
67
  'X': 'set_status',
67
68
  'G': 'export_to_gcal',
68
- 'I': 'import_from_gcal',
69
- 'K': 'copy_to_clipboard',
70
69
  'E': 'trigger_keyword_action',
71
70
  'N': 'edit_task_notes',
72
71
  '\x15': 'open_url', # Ctrl+U
@@ -93,6 +92,7 @@ class App(DispatchMixin, AppNavigationMixin):
93
92
  self.project_name = project_name
94
93
 
95
94
  self.state = AppState()
95
+ self._needs_render = True
96
96
  self._last_mtime = (
97
97
  os.path.getmtime(task_manager.storage_file)
98
98
  if task_manager.storage_file.exists() else 0)
@@ -113,8 +113,12 @@ class App(DispatchMixin, AppNavigationMixin):
113
113
  """Inner event loop — separated so run() can wrap with
114
114
  try/finally."""
115
115
  while self.state.running:
116
- tasks_to_display = self._prepare_tasks()
117
- self._render(tasks_to_display)
116
+ if self._needs_render:
117
+ tasks_to_display = self._prepare_tasks()
118
+ self._render(tasks_to_display)
119
+ self._needs_render = False
120
+ else:
121
+ tasks_to_display = self._prepare_tasks()
118
122
 
119
123
  try:
120
124
  key = get_key()
@@ -124,6 +128,9 @@ class App(DispatchMixin, AppNavigationMixin):
124
128
  if self._handle_paste(key):
125
129
  continue
126
130
 
131
+ # Any real key press triggers a render
132
+ self._needs_render = True
133
+
127
134
  # Global handlers (any mode)
128
135
  if key == '\x05': # Ctrl+E
129
136
  self.command_handler.edit_current()
@@ -159,6 +166,12 @@ class App(DispatchMixin, AppNavigationMixin):
159
166
  """Fetch, filter, and sort the task list for display."""
160
167
  tasks = self.task_manager.get_tasks()
161
168
 
169
+ # Stash full list before collapse filtering so display can
170
+ # compute correct descendant counts for collapsed parents.
171
+ self._all_tasks = tasks
172
+
173
+ if self.state.collapsed_parents:
174
+ tasks = self._filter_collapsed(tasks)
162
175
  if self.state.show_today_only:
163
176
  today = get_today_day_name()
164
177
  tasks = filter_today_tasks(tasks, today)
@@ -167,6 +180,33 @@ class App(DispatchMixin, AppNavigationMixin):
167
180
 
168
181
  return self._get_filtered_tasks(tasks)
169
182
 
183
+ def _filter_collapsed(self, tasks):
184
+ """Remove descendants of collapsed parents from the task list."""
185
+ # Build parent→children map
186
+ children_of = {}
187
+ for task in tasks:
188
+ for label in task.get('labels', []):
189
+ if label.startswith('parent:'):
190
+ pid = label.split(':', 1)[1]
191
+ try:
192
+ pid = int(pid)
193
+ except ValueError:
194
+ pass
195
+ children_of.setdefault(pid, []).append(task['id'])
196
+ break
197
+
198
+ # Collect all descendants of collapsed parents (BFS)
199
+ hidden = set()
200
+ for root_id in self.state.collapsed_parents:
201
+ stack = list(children_of.get(root_id, []))
202
+ while stack:
203
+ cid = stack.pop()
204
+ if cid not in hidden:
205
+ hidden.add(cid)
206
+ stack.extend(children_of.get(cid, []))
207
+
208
+ return [t for t in tasks if t['id'] not in hidden]
209
+
170
210
  def _get_filtered_tasks(self, tasks_to_display):
171
211
  """Apply fuzzy-search filtering when in FUZZY_SEARCH mode."""
172
212
  st = self.state
@@ -214,30 +254,35 @@ class App(DispatchMixin, AppNavigationMixin):
214
254
 
215
255
  def _render(self, tasks_to_display):
216
256
  """Clear screen and draw the current view."""
217
- sys.stdout.write('\033[?25l\033[H\033[J')
218
- st = self.state
257
+ with buffered_output():
258
+ sys.stdout.write('\033[?25l\033[H\033[J')
259
+ st = self.state
260
+
261
+ if st.mode == MODE_ALL_CATEGORIES:
262
+ self._render_all_categories()
263
+ else:
264
+ all_tasks = (
265
+ self._all_tasks
266
+ if st.collapsed_parents else None)
267
+ display_tasks(
268
+ tasks_to_display, st.mode, st.input_buffer,
269
+ self.project_name,
270
+ category=self.task_manager.category,
271
+ is_global=self.task_manager.is_global,
272
+ show_archived=st.show_archived,
273
+ archived_tasks=self.task_manager.archived,
274
+ selected_tasks=st.selected_tasks,
275
+ search_buffer=st.search_buffer,
276
+ archived_task_ids=st.archived_task_ids,
277
+ include_archived_in_search=st.include_archived_in_search,
278
+ show_shortcuts=st.show_shortcuts,
279
+ show_notes_inline=st.show_notes_inline,
280
+ match_positions=st.match_positions,
281
+ collapsed_parents=st.collapsed_parents,
282
+ all_tasks=all_tasks,
283
+ )
219
284
 
220
- if st.mode == MODE_ALL_CATEGORIES:
221
- self._render_all_categories()
222
- else:
223
- display_tasks(
224
- tasks_to_display, st.mode, st.input_buffer,
225
- self.project_name,
226
- category=self.task_manager.category,
227
- is_global=self.task_manager.is_global,
228
- show_archived=st.show_archived,
229
- archived_tasks=self.task_manager.archived,
230
- selected_tasks=st.selected_tasks,
231
- search_buffer=st.search_buffer,
232
- archived_task_ids=st.archived_task_ids,
233
- include_archived_in_search=st.include_archived_in_search,
234
- show_shortcuts=st.show_shortcuts,
235
- show_notes_inline=st.show_notes_inline,
236
- match_positions=st.match_positions,
237
- )
238
-
239
- sys.stdout.write('\033[?25h')
240
- sys.stdout.flush()
285
+ sys.stdout.write('\033[?25h')
241
286
 
242
287
  def _render_all_categories(self):
243
288
  """Delegate ALL_CATEGORIES rendering to the display module."""
@@ -265,6 +310,7 @@ class App(DispatchMixin, AppNavigationMixin):
265
310
  if current_mtime != self._last_mtime:
266
311
  self.task_manager._load_tasks()
267
312
  self._last_mtime = current_mtime
313
+ self._needs_render = True
268
314
  return True
269
315
 
270
316
  def _handle_paste(self, key):
@@ -276,4 +322,5 @@ class App(DispatchMixin, AppNavigationMixin):
276
322
  if self.state.mode == MODE_QUICK_ADD:
277
323
  pasted_text = pasted_text.replace('\r', '').replace('\n', '')
278
324
  self.state.input_buffer += pasted_text
325
+ self._needs_render = True
279
326
  return True
@@ -81,7 +81,8 @@ class TransferMixin:
81
81
  task_text,
82
82
  priority=current_task.get('priority', 'none'),
83
83
  status=current_task.get('status', 'todo'),
84
- labels=current_task.get('labels', []),
84
+ labels=[l for l in current_task.get('labels', [])
85
+ if not l.startswith('parent:')],
85
86
  effort=current_task.get('effort', None),
86
87
  )
87
88
 
@@ -146,7 +147,8 @@ class TransferMixin:
146
147
  task_text,
147
148
  priority=current_task.get('priority', 'none'),
148
149
  status=current_task.get('status', 'todo'),
149
- labels=current_task.get('labels', []),
150
+ labels=[l for l in current_task.get('labels', [])
151
+ if not l.startswith('parent:')],
150
152
  effort=current_task.get('effort', None),
151
153
  )
152
154
 
@@ -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
@@ -7,8 +7,17 @@ from datetime import datetime
7
7
  class DeleteMixin:
8
8
  """Task removal, archival, and notes preservation."""
9
9
 
10
+ def _unlink_children(self, task_id):
11
+ """Remove parent:{task_id} labels from all children."""
12
+ parent_label = f"parent:{task_id}"
13
+ for task in self.tasks:
14
+ labels = task.get('labels', [])
15
+ if parent_label in labels:
16
+ task['labels'] = [l for l in labels if l != parent_label]
17
+
10
18
  def remove_task(self, task_id):
11
19
  """Archive a task by ID and set previous task as current"""
20
+ self._unlink_children(task_id)
12
21
  org_file = self.save_notes_to_org_file(task_id)
13
22
  if org_file:
14
23
  print(f"\n\u2713 Notes saved to {os.path.basename(org_file)}")
@@ -43,6 +52,7 @@ class DeleteMixin:
43
52
 
44
53
  def delete_task_permanently(self, task_id):
45
54
  """Permanently delete a task and release its ID for reuse"""
55
+ self._unlink_children(task_id)
46
56
  org_file = self.save_notes_to_org_file(task_id)
47
57
  if org_file:
48
58
  print(f"\n\u2713 Notes saved to {os.path.basename(org_file)}")
@@ -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,12 +33,12 @@ 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
40
40
  display_categorized_shortcuts()
41
- print(f"→ {input_buffer}█", end='', flush=True)
41
+ print(f"→ {input_buffer}█", end='')
42
42
 
43
43
 
44
44
  def _render_multiselect(selected_tasks):
@@ -106,7 +106,7 @@ def _render_command():
106
106
  f"{BOLD}(h){RESET}elp | {BOLD}(q){RESET}uit"
107
107
  )
108
108
  print("-" * get_terminal_width())
109
- print("Command: ", end='', flush=True)
109
+ print("Command: ", end='')
110
110
 
111
111
 
112
112
  def render_archived_section(archived_tasks):
@@ -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": [
@@ -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"),
@@ -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}
@@ -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}"""
@@ -36,12 +36,16 @@ def display_tasks(
36
36
  show_shortcuts=False,
37
37
  show_notes_inline=False,
38
38
  match_positions=None,
39
+ collapsed_parents=None,
40
+ all_tasks=None,
39
41
  ):
40
42
  """Display current task list with mode indicator."""
41
43
  if selected_tasks is None:
42
44
  selected_tasks = set()
43
45
  if archived_task_ids is None:
44
46
  archived_task_ids = set()
47
+ if collapsed_parents is None:
48
+ collapsed_parents = set()
45
49
 
46
50
  cat_color = _get_category_color(category, is_global)
47
51
  _render_header(tasks, project_name, category, is_global, cat_color)
@@ -49,14 +53,18 @@ def display_tasks(
49
53
  if not tasks:
50
54
  print("\n No tasks yet. Start typing to add one!")
51
55
  else:
52
- desc_counts, depths = _build_subtask_info(tasks)
56
+ # Use full task list for subtask info when available so
57
+ # collapsed parents still show correct descendant counts.
58
+ info_source = all_tasks if all_tasks is not None else tasks
59
+ desc_counts, depths = _build_subtask_info(info_source)
53
60
  for task in tasks:
54
61
  positions = (match_positions or {}).get(task['id'], [])
55
62
  _render_task_line(
56
63
  task, mode, selected_tasks, archived_task_ids,
57
64
  show_notes_inline, positions,
58
65
  child_count=desc_counts.get(task['id'], 0),
59
- depth=depths.get(task['id'], 0))
66
+ depth=depths.get(task['id'], 0),
67
+ collapsed=task['id'] in collapsed_parents)
60
68
 
61
69
  print("\n" + "-" * get_terminal_width())
62
70
 
@@ -239,7 +247,7 @@ def _build_task_prefixes(task, mode, selected_tasks, archived_task_ids,
239
247
  + day_prefix + subtask_prefix)
240
248
 
241
249
 
242
- def _build_task_suffixes(task, child_count=0):
250
+ def _build_task_suffixes(task, child_count=0, collapsed=False):
243
251
  """Build tally, stale-warning, and subtask-count suffixes."""
244
252
  tally = task.get('tally', 0)
245
253
  tally_suffix = f" {DIM}(\u00d7{tally}){RESET}" if tally > 0 else ""
@@ -260,7 +268,12 @@ def _build_task_suffixes(task, child_count=0):
260
268
  subtask_suffix = ""
261
269
  if child_count > 0:
262
270
  noun = "subtask" if child_count == 1 else "subtasks"
263
- subtask_suffix = f" {DIM}({child_count} {noun}){RESET}"
271
+ if collapsed:
272
+ subtask_suffix = (
273
+ f" {YELLOW}\u25b6 ({child_count} {noun}){RESET}")
274
+ else:
275
+ subtask_suffix = (
276
+ f" {DIM}\u25bc ({child_count} {noun}){RESET}")
264
277
 
265
278
  return tally_suffix, stale_warning, subtask_suffix
266
279
 
@@ -288,7 +301,7 @@ def _highlight_text(text, positions, base_fmt=""):
288
301
  def _render_task_line(
289
302
  task, mode, selected_tasks, archived_task_ids,
290
303
  show_notes_inline, match_positions=None,
291
- child_count=0, depth=0):
304
+ child_count=0, depth=0, collapsed=False):
292
305
  """Render a single task line with all decorations and wrapping."""
293
306
  status = "\u2713" if task.get('done', False) else " "
294
307
  is_current = task.get('current', False)
@@ -298,7 +311,7 @@ def _render_task_line(
298
311
  prefixes = _build_task_prefixes(
299
312
  task, mode, selected_tasks, archived_task_ids, depth)
300
313
  tally_suffix, stale_warning, subtask_suffix = _build_task_suffixes(
301
- task, child_count)
314
+ task, child_count, collapsed)
302
315
 
303
316
  if is_agent:
304
317
  color, icon = MAGENTA, "\U0001f916"