jott-cli 0.5.2__tar.gz → 0.5.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 (97) hide show
  1. {jott_cli-0.5.2/jott_cli.egg-info → jott_cli-0.5.3}/PKG-INFO +1 -1
  2. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/_app_navigation_mixin.py +4 -1
  3. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/_dispatch_mixin.py +89 -2
  4. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/app.py +75 -25
  5. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_transfer_mixin.py +4 -2
  6. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_delete_mixin.py +10 -0
  7. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_footer.py +2 -2
  8. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_tasks.py +19 -6
  9. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/picker.py +59 -56
  10. jott_cli-0.5.3/jot/ui/rendering.py +24 -0
  11. {jott_cli-0.5.2 → jott_cli-0.5.3/jott_cli.egg-info}/PKG-INFO +1 -1
  12. {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/SOURCES.txt +1 -0
  13. {jott_cli-0.5.2 → jott_cli-0.5.3}/pyproject.toml +1 -1
  14. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_subtask_notes.py +340 -1
  15. {jott_cli-0.5.2 → jott_cli-0.5.3}/LICENSE +0 -0
  16. {jott_cli-0.5.2 → jott_cli-0.5.3}/README.md +0 -0
  17. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/__init__.py +0 -0
  18. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/__init__.py +0 -0
  19. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/config.py +0 -0
  20. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/manager.py +0 -0
  21. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/templates.py +0 -0
  22. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/__init__.py +0 -0
  23. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/archive.py +0 -0
  24. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/config.py +0 -0
  25. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/views.py +0 -0
  26. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/__init__.py +0 -0
  27. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_ai_analysis_mixin.py +0 -0
  28. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_ai_suggest_mixin.py +0 -0
  29. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_audio_timer_mixin.py +0 -0
  30. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_bulk_mixin.py +0 -0
  31. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_context_mixin.py +0 -0
  32. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_core_mixin.py +0 -0
  33. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_gcal_mixin.py +0 -0
  34. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_metadata_mixin.py +0 -0
  35. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_notes_mixin.py +0 -0
  36. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_web_clipboard_mixin.py +0 -0
  37. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/handler.py +0 -0
  38. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/__init__.py +0 -0
  39. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_age_backlog_mixin.py +0 -0
  40. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_compress_mixin.py +0 -0
  41. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_crud_mixin.py +0 -0
  42. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_export_mixin.py +0 -0
  43. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_id_migration_mixin.py +0 -0
  44. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_metadata_mixin.py +0 -0
  45. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_navigation_mixin.py +0 -0
  46. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_persistence_mixin.py +0 -0
  47. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_subtask_mixin.py +0 -0
  48. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/archive_manager.py +0 -0
  49. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/constants.py +0 -0
  50. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/id_manager.py +0 -0
  51. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/task_manager.py +0 -0
  52. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/__init__.py +0 -0
  53. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/__init__.py +0 -0
  54. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/account_manager.py +0 -0
  55. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/auth.py +0 -0
  56. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/events.py +0 -0
  57. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/__init__.py +0 -0
  58. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/_config_mixin.py +0 -0
  59. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  60. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/handler.py +0 -0
  61. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/__init__.py +0 -0
  62. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/handlers.py +0 -0
  63. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/schemas.py +0 -0
  64. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/server.py +0 -0
  65. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/projects/__init__.py +0 -0
  66. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/projects/backup.py +0 -0
  67. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/projects/registry.py +0 -0
  68. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/__init__.py +0 -0
  69. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display.py +0 -0
  70. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_archive.py +0 -0
  71. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_help.py +0 -0
  72. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_projects.py +0 -0
  73. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/formatting.py +0 -0
  74. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/input.py +0 -0
  75. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/styles.py +0 -0
  76. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/__init__.py +0 -0
  77. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/date_utils.py +0 -0
  78. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/text_utils.py +0 -0
  79. {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/validation.py +0 -0
  80. {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/dependency_links.txt +0 -0
  81. {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/entry_points.txt +0 -0
  82. {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/requires.txt +0 -0
  83. {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/top_level.txt +0 -0
  84. {jott_cli-0.5.2 → jott_cli-0.5.3}/setup.cfg +0 -0
  85. {jott_cli-0.5.2 → jott_cli-0.5.3}/setup.py +0 -0
  86. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_command_handler.py +0 -0
  87. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_dispatch.py +0 -0
  88. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_edit_edge_cases.py +0 -0
  89. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_fuzzy_search.py +0 -0
  90. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_gcal_notes.py +0 -0
  91. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_highlight.py +0 -0
  92. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_input.py +0 -0
  93. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_jot.py +0 -0
  94. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_picker.py +0 -0
  95. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_styles.py +0 -0
  96. {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_terminal_wrap.py +0 -0
  97. {jott_cli-0.5.2 → jott_cli-0.5.3}/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.3
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>
@@ -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)
@@ -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
 
@@ -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,84 @@ 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.3)
300
+ if second == 'c':
301
+ self._toggle_collapse()
302
+ st.input_buffer = ""
303
+ return
304
+ # Not a chord — treat '.' as normal input
305
+ st.input_buffer += '.'
306
+ if second and second != '.':
307
+ st.input_buffer += second
308
+
309
+ def _toggle_collapse(self):
310
+ """Toggle collapse state for current task's parent group."""
311
+ task = self.task_manager.get_current_task()
312
+ if not task:
313
+ return
314
+
315
+ tasks = self.task_manager.get_tasks()
316
+ collapsed = self.state.collapsed_parents
317
+
318
+ # Check if current task is a parent (has descendants)
319
+ has_children = any(
320
+ f'parent:{task["id"]}' in t.get('labels', [])
321
+ for t in tasks
322
+ )
323
+
324
+ if has_children:
325
+ target_id = task['id']
326
+ else:
327
+ # Check if it's a child — toggle its parent
328
+ target_id = None
329
+ for label in task.get('labels', []):
330
+ if label.startswith('parent:'):
331
+ pid = label.split(':', 1)[1]
332
+ try:
333
+ target_id = int(pid)
334
+ except ValueError:
335
+ target_id = pid
336
+ break
337
+
338
+ if target_id is None:
339
+ return
340
+
341
+ # Count descendants for feedback
342
+ children_of = {}
343
+ for t in tasks:
344
+ for label in t.get('labels', []):
345
+ if label.startswith('parent:'):
346
+ pid = label.split(':', 1)[1]
347
+ try:
348
+ pid = int(pid)
349
+ except ValueError:
350
+ pass
351
+ children_of.setdefault(pid, []).append(t['id'])
352
+ break
353
+
354
+ count = 0
355
+ stack = list(children_of.get(target_id, []))
356
+ seen = set()
357
+ while stack:
358
+ cid = stack.pop()
359
+ if cid not in seen:
360
+ seen.add(cid)
361
+ count += 1
362
+ stack.extend(children_of.get(cid, []))
363
+
364
+ if target_id in collapsed:
365
+ collapsed.discard(target_id)
366
+ print(f"\n{CYAN}▼ Expanded {count} subtask"
367
+ f"{'s' if count != 1 else ''}{RESET}")
368
+ else:
369
+ collapsed.add(target_id)
370
+ print(f"\n{CYAN}▶ Collapsed {count} subtask"
371
+ f"{'s' if count != 1 else ''}{RESET}")
372
+ time.sleep(0.3)
373
+
289
374
  def _dispatch_all_categories(self, key, tasks_to_display):
290
375
  """Handle a keypress in ALL_CATEGORIES mode."""
291
376
  st = self.state
@@ -295,7 +380,9 @@ class DispatchMixin:
295
380
  return
296
381
 
297
382
  filtered = (
298
- tasks_to_display if st.show_today_only else None)
383
+ tasks_to_display
384
+ if st.show_today_only or st.collapsed_parents
385
+ else None)
299
386
  if key == 'UP':
300
387
  self.task_manager.set_prev_current(filtered)
301
388
  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
  # ---------------------------------------------------------------------------
@@ -93,6 +95,7 @@ class App(DispatchMixin, AppNavigationMixin):
93
95
  self.project_name = project_name
94
96
 
95
97
  self.state = AppState()
98
+ self._needs_render = True
96
99
  self._last_mtime = (
97
100
  os.path.getmtime(task_manager.storage_file)
98
101
  if task_manager.storage_file.exists() else 0)
@@ -113,8 +116,12 @@ class App(DispatchMixin, AppNavigationMixin):
113
116
  """Inner event loop — separated so run() can wrap with
114
117
  try/finally."""
115
118
  while self.state.running:
116
- tasks_to_display = self._prepare_tasks()
117
- self._render(tasks_to_display)
119
+ if self._needs_render:
120
+ tasks_to_display = self._prepare_tasks()
121
+ self._render(tasks_to_display)
122
+ self._needs_render = False
123
+ else:
124
+ tasks_to_display = self._prepare_tasks()
118
125
 
119
126
  try:
120
127
  key = get_key()
@@ -124,6 +131,9 @@ class App(DispatchMixin, AppNavigationMixin):
124
131
  if self._handle_paste(key):
125
132
  continue
126
133
 
134
+ # Any real key press triggers a render
135
+ self._needs_render = True
136
+
127
137
  # Global handlers (any mode)
128
138
  if key == '\x05': # Ctrl+E
129
139
  self.command_handler.edit_current()
@@ -159,6 +169,12 @@ class App(DispatchMixin, AppNavigationMixin):
159
169
  """Fetch, filter, and sort the task list for display."""
160
170
  tasks = self.task_manager.get_tasks()
161
171
 
172
+ # Stash full list before collapse filtering so display can
173
+ # compute correct descendant counts for collapsed parents.
174
+ self._all_tasks = tasks
175
+
176
+ if self.state.collapsed_parents:
177
+ tasks = self._filter_collapsed(tasks)
162
178
  if self.state.show_today_only:
163
179
  today = get_today_day_name()
164
180
  tasks = filter_today_tasks(tasks, today)
@@ -167,6 +183,33 @@ class App(DispatchMixin, AppNavigationMixin):
167
183
 
168
184
  return self._get_filtered_tasks(tasks)
169
185
 
186
+ def _filter_collapsed(self, tasks):
187
+ """Remove descendants of collapsed parents from the task list."""
188
+ # Build parent→children map
189
+ children_of = {}
190
+ for task in tasks:
191
+ for label in task.get('labels', []):
192
+ if label.startswith('parent:'):
193
+ pid = label.split(':', 1)[1]
194
+ try:
195
+ pid = int(pid)
196
+ except ValueError:
197
+ pass
198
+ children_of.setdefault(pid, []).append(task['id'])
199
+ break
200
+
201
+ # Collect all descendants of collapsed parents (BFS)
202
+ hidden = set()
203
+ for root_id in self.state.collapsed_parents:
204
+ stack = list(children_of.get(root_id, []))
205
+ while stack:
206
+ cid = stack.pop()
207
+ if cid not in hidden:
208
+ hidden.add(cid)
209
+ stack.extend(children_of.get(cid, []))
210
+
211
+ return [t for t in tasks if t['id'] not in hidden]
212
+
170
213
  def _get_filtered_tasks(self, tasks_to_display):
171
214
  """Apply fuzzy-search filtering when in FUZZY_SEARCH mode."""
172
215
  st = self.state
@@ -214,30 +257,35 @@ class App(DispatchMixin, AppNavigationMixin):
214
257
 
215
258
  def _render(self, tasks_to_display):
216
259
  """Clear screen and draw the current view."""
217
- sys.stdout.write('\033[?25l\033[H\033[J')
218
- st = self.state
260
+ with buffered_output():
261
+ sys.stdout.write('\033[?25l\033[H\033[J')
262
+ st = self.state
263
+
264
+ if st.mode == MODE_ALL_CATEGORIES:
265
+ self._render_all_categories()
266
+ else:
267
+ all_tasks = (
268
+ self._all_tasks
269
+ if st.collapsed_parents else None)
270
+ display_tasks(
271
+ tasks_to_display, st.mode, st.input_buffer,
272
+ self.project_name,
273
+ category=self.task_manager.category,
274
+ is_global=self.task_manager.is_global,
275
+ show_archived=st.show_archived,
276
+ archived_tasks=self.task_manager.archived,
277
+ selected_tasks=st.selected_tasks,
278
+ search_buffer=st.search_buffer,
279
+ archived_task_ids=st.archived_task_ids,
280
+ include_archived_in_search=st.include_archived_in_search,
281
+ show_shortcuts=st.show_shortcuts,
282
+ show_notes_inline=st.show_notes_inline,
283
+ match_positions=st.match_positions,
284
+ collapsed_parents=st.collapsed_parents,
285
+ all_tasks=all_tasks,
286
+ )
219
287
 
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()
288
+ sys.stdout.write('\033[?25h')
241
289
 
242
290
  def _render_all_categories(self):
243
291
  """Delegate ALL_CATEGORIES rendering to the display module."""
@@ -265,6 +313,7 @@ class App(DispatchMixin, AppNavigationMixin):
265
313
  if current_mtime != self._last_mtime:
266
314
  self.task_manager._load_tasks()
267
315
  self._last_mtime = current_mtime
316
+ self._needs_render = True
268
317
  return True
269
318
 
270
319
  def _handle_paste(self, key):
@@ -276,4 +325,5 @@ class App(DispatchMixin, AppNavigationMixin):
276
325
  if self.state.mode == MODE_QUICK_ADD:
277
326
  pasted_text = pasted_text.replace('\r', '').replace('\n', '')
278
327
  self.state.input_buffer += pasted_text
328
+ self._needs_render = True
279
329
  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
 
@@ -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)}")
@@ -38,7 +38,7 @@ def _render_quick_add(input_buffer, show_shortcuts):
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):
@@ -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"
@@ -10,6 +10,7 @@ from dataclasses import dataclass
10
10
 
11
11
  from jot.ui.input import get_key
12
12
  from jot.ui.styles import RESET, BOLD, DIM, CYAN, GREEN
13
+ from jot.ui.rendering import buffered_output
13
14
  from jot.utils.text_utils import fuzzy_match
14
15
 
15
16
 
@@ -90,47 +91,48 @@ def fuzzy_picker(items, prompt="Select", *, enable_filter=True,
90
91
  display_items = visible_items[:max_display]
91
92
 
92
93
  # Render
93
- sys.stdout.write('\033[2J\033[H')
94
- print(f"{BOLD}{prompt}{RESET}")
95
-
96
- if enable_filter:
97
- print(f"{DIM}Type to filter \u00b7 \u2191\u2193 navigate \u00b7 Enter select \u00b7 ESC cancel{RESET}")
98
- cursor = "\u258c" if not search_buffer else ""
99
- print(f"\n{CYAN}> {search_buffer}{cursor}{RESET}")
100
- else:
101
- print(f"{DIM}\u2191\u2193 navigate \u00b7 Enter select \u00b7 ESC cancel{RESET}")
102
-
103
- print()
104
-
105
- if not visible_items:
106
- if allow_freeform and search_buffer:
107
- print(f" {DIM}{freeform_hint}{RESET}")
94
+ with buffered_output():
95
+ sys.stdout.write('\033[2J\033[H')
96
+ print(f"{BOLD}{prompt}{RESET}")
97
+
98
+ if enable_filter:
99
+ print(f"{DIM}Type to filter \u00b7 \u2191\u2193 navigate \u00b7 Enter select \u00b7 ESC cancel{RESET}")
100
+ cursor = "\u258c" if not search_buffer else ""
101
+ print(f"\n{CYAN}> {search_buffer}{cursor}{RESET}")
108
102
  else:
109
- print(f" {DIM}No matches{RESET}")
110
- else:
111
- for i, item in enumerate(display_items):
112
- # Build display text
113
- if item.symbol:
114
- text = f"{item.color}{item.symbol} {item.label}{RESET}"
115
- elif item.color:
116
- text = f"{item.color}{item.label}{RESET}"
117
- else:
118
- text = item.label
103
+ print(f"{DIM}\u2191\u2193 navigate \u00b7 Enter select \u00b7 ESC cancel{RESET}")
119
104
 
120
- if item.annotation:
121
- text += f" {DIM}{item.annotation}{RESET}"
105
+ print()
122
106
 
123
- if i == selected_idx:
124
- print(f" {GREEN}{BOLD}\u2192 {text}{RESET}")
107
+ if not visible_items:
108
+ if allow_freeform and search_buffer:
109
+ print(f" {DIM}{freeform_hint}{RESET}")
125
110
  else:
126
- print(f" {text}")
127
-
128
- if len(visible_items) > max_display:
129
- remaining = len(visible_items) - max_display
130
- print(f"\n {DIM}... and {remaining} more{RESET}")
131
-
132
- if enable_filter:
133
- print(f"\n{DIM}{len(visible_items)}/{len(items)} items{RESET}")
111
+ print(f" {DIM}No matches{RESET}")
112
+ else:
113
+ for i, item in enumerate(display_items):
114
+ # Build display text
115
+ if item.symbol:
116
+ text = f"{item.color}{item.symbol} {item.label}{RESET}"
117
+ elif item.color:
118
+ text = f"{item.color}{item.label}{RESET}"
119
+ else:
120
+ text = item.label
121
+
122
+ if item.annotation:
123
+ text += f" {DIM}{item.annotation}{RESET}"
124
+
125
+ if i == selected_idx:
126
+ print(f" {GREEN}{BOLD}\u2192 {text}{RESET}")
127
+ else:
128
+ print(f" {text}")
129
+
130
+ if len(visible_items) > max_display:
131
+ remaining = len(visible_items) - max_display
132
+ print(f"\n {DIM}... and {remaining} more{RESET}")
133
+
134
+ if enable_filter:
135
+ print(f"\n{DIM}{len(visible_items)}/{len(items)} items{RESET}")
134
136
 
135
137
  # Read key
136
138
  key = get_key(timeout=30.0)
@@ -192,29 +194,30 @@ def multiselect_picker(items, prompt="Select", *, max_display=20,
192
194
  while True:
193
195
  display_items = items[:max_display]
194
196
 
195
- sys.stdout.write('\033[2J\033[H')
196
- print(f"{BOLD}{prompt}{RESET}")
197
- print(f"{DIM}\u2191\u2193 navigate \u00b7 Space toggle \u00b7"
198
- f" a all \u00b7 n none \u00b7 Enter confirm \u00b7"
199
- f" ESC cancel{RESET}\n")
197
+ with buffered_output():
198
+ sys.stdout.write('\033[2J\033[H')
199
+ print(f"{BOLD}{prompt}{RESET}")
200
+ print(f"{DIM}\u2191\u2193 navigate \u00b7 Space toggle \u00b7"
201
+ f" a all \u00b7 n none \u00b7 Enter confirm \u00b7"
202
+ f" ESC cancel{RESET}\n")
200
203
 
201
- for i, item in enumerate(display_items):
202
- box = f"{GREEN}[\u2713]{RESET}" if i in checked else "[ ]"
203
- text = item.label
204
- if item.annotation:
205
- text += f" {DIM}{item.annotation}{RESET}"
204
+ for i, item in enumerate(display_items):
205
+ box = f"{GREEN}[\u2713]{RESET}" if i in checked else "[ ]"
206
+ text = item.label
207
+ if item.annotation:
208
+ text += f" {DIM}{item.annotation}{RESET}"
206
209
 
207
- if i == cursor:
208
- print(f" {GREEN}\u2192{RESET} {box} {text}")
209
- else:
210
- print(f" {box} {text}")
210
+ if i == cursor:
211
+ print(f" {GREEN}\u2192{RESET} {box} {text}")
212
+ else:
213
+ print(f" {box} {text}")
211
214
 
212
- if len(items) > max_display:
213
- remaining = len(items) - max_display
214
- print(f"\n {DIM}... and {remaining} more{RESET}")
215
+ if len(items) > max_display:
216
+ remaining = len(items) - max_display
217
+ print(f"\n {DIM}... and {remaining} more{RESET}")
215
218
 
216
- print(f"\n{DIM}{len(items)} items \u00b7"
217
- f" {len(checked)} selected{RESET}")
219
+ print(f"\n{DIM}{len(items)} items \u00b7"
220
+ f" {len(checked)} selected{RESET}")
218
221
 
219
222
  key = get_key(timeout=30.0)
220
223
  if key is None:
@@ -0,0 +1,24 @@
1
+ """Rendering utilities for flicker-free terminal output."""
2
+
3
+ import io
4
+ import sys
5
+ from contextlib import contextmanager
6
+
7
+
8
+ @contextmanager
9
+ def buffered_output():
10
+ """Redirect stdout to a buffer, then flush in one write.
11
+
12
+ Wrapping a render pass in this context manager ensures the
13
+ terminal receives the entire frame atomically, eliminating
14
+ partial-frame tearing.
15
+ """
16
+ buf = io.StringIO()
17
+ old_stdout = sys.stdout
18
+ sys.stdout = buf
19
+ try:
20
+ yield buf
21
+ finally:
22
+ sys.stdout = old_stdout
23
+ old_stdout.write(buf.getvalue())
24
+ old_stdout.flush()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.5.2
3
+ Version: 0.5.3
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>
@@ -68,6 +68,7 @@ jot/ui/display_tasks.py
68
68
  jot/ui/formatting.py
69
69
  jot/ui/input.py
70
70
  jot/ui/picker.py
71
+ jot/ui/rendering.py
71
72
  jot/ui/styles.py
72
73
  jot/utils/__init__.py
73
74
  jot/utils/date_utils.py
@@ -7,7 +7,7 @@ include = ["jot", "jot.*"]
7
7
 
8
8
  [project]
9
9
  name = "jott-cli"
10
- version = "0.5.2"
10
+ version = "0.5.3"
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"