jott-cli 0.5.1__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 (98) hide show
  1. {jott_cli-0.5.1/jott_cli.egg-info → jott_cli-0.5.3}/PKG-INFO +1 -1
  2. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/__init__.py +1 -1
  3. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/_app_navigation_mixin.py +4 -1
  4. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/_dispatch_mixin.py +89 -2
  5. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/app.py +77 -25
  6. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_metadata_mixin.py +115 -5
  7. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_transfer_mixin.py +52 -74
  8. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_delete_mixin.py +10 -0
  9. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_metadata_mixin.py +8 -3
  10. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_subtask_mixin.py +1 -1
  11. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/__init__.py +5 -2
  12. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_archive.py +5 -4
  13. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_footer.py +7 -6
  14. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_help.py +11 -6
  15. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_projects.py +10 -6
  16. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display_tasks.py +138 -56
  17. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/picker.py +59 -56
  18. jott_cli-0.5.3/jot/ui/rendering.py +24 -0
  19. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/styles.py +43 -2
  20. {jott_cli-0.5.1 → jott_cli-0.5.3/jott_cli.egg-info}/PKG-INFO +1 -1
  21. {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/SOURCES.txt +2 -0
  22. {jott_cli-0.5.1 → jott_cli-0.5.3}/pyproject.toml +1 -1
  23. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_highlight.py +64 -44
  24. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_jot.py +4 -4
  25. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_styles.py +14 -4
  26. jott_cli-0.5.3/tests/test_subtask_notes.py +884 -0
  27. jott_cli-0.5.3/tests/test_terminal_wrap.py +212 -0
  28. jott_cli-0.5.1/tests/test_subtask_notes.py +0 -246
  29. {jott_cli-0.5.1 → jott_cli-0.5.3}/LICENSE +0 -0
  30. {jott_cli-0.5.1 → jott_cli-0.5.3}/README.md +0 -0
  31. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/__init__.py +0 -0
  32. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/config.py +0 -0
  33. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/manager.py +0 -0
  34. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/categories/templates.py +0 -0
  35. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/__init__.py +0 -0
  36. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/archive.py +0 -0
  37. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/config.py +0 -0
  38. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/cli/views.py +0 -0
  39. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/__init__.py +0 -0
  40. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_ai_analysis_mixin.py +0 -0
  41. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_ai_suggest_mixin.py +0 -0
  42. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_audio_timer_mixin.py +0 -0
  43. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_bulk_mixin.py +0 -0
  44. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_context_mixin.py +0 -0
  45. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_core_mixin.py +0 -0
  46. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_gcal_mixin.py +0 -0
  47. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_notes_mixin.py +0 -0
  48. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/_web_clipboard_mixin.py +0 -0
  49. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/commands/handler.py +0 -0
  50. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/__init__.py +0 -0
  51. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_age_backlog_mixin.py +0 -0
  52. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_compress_mixin.py +0 -0
  53. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_crud_mixin.py +0 -0
  54. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_export_mixin.py +0 -0
  55. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_id_migration_mixin.py +0 -0
  56. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_navigation_mixin.py +0 -0
  57. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/_persistence_mixin.py +0 -0
  58. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/archive_manager.py +0 -0
  59. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/constants.py +0 -0
  60. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/id_manager.py +0 -0
  61. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/core/task_manager.py +0 -0
  62. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/__init__.py +0 -0
  63. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/__init__.py +0 -0
  64. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/account_manager.py +0 -0
  65. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/auth.py +0 -0
  66. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/gcal/events.py +0 -0
  67. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/__init__.py +0 -0
  68. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/_config_mixin.py +0 -0
  69. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  70. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/integrations/keywords/handler.py +0 -0
  71. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/__init__.py +0 -0
  72. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/handlers.py +0 -0
  73. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/schemas.py +0 -0
  74. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/mcp/server.py +0 -0
  75. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/projects/__init__.py +0 -0
  76. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/projects/backup.py +0 -0
  77. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/projects/registry.py +0 -0
  78. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/display.py +0 -0
  79. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/formatting.py +0 -0
  80. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/ui/input.py +0 -0
  81. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/__init__.py +0 -0
  82. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/date_utils.py +0 -0
  83. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/text_utils.py +0 -0
  84. {jott_cli-0.5.1 → jott_cli-0.5.3}/jot/utils/validation.py +0 -0
  85. {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/dependency_links.txt +0 -0
  86. {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/entry_points.txt +0 -0
  87. {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/requires.txt +0 -0
  88. {jott_cli-0.5.1 → jott_cli-0.5.3}/jott_cli.egg-info/top_level.txt +0 -0
  89. {jott_cli-0.5.1 → jott_cli-0.5.3}/setup.cfg +0 -0
  90. {jott_cli-0.5.1 → jott_cli-0.5.3}/setup.py +0 -0
  91. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_command_handler.py +0 -0
  92. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_dispatch.py +0 -0
  93. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_edit_edge_cases.py +0 -0
  94. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_fuzzy_search.py +0 -0
  95. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_gcal_notes.py +0 -0
  96. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_input.py +0 -0
  97. {jott_cli-0.5.1 → jott_cli-0.5.3}/tests/test_picker.py +0 -0
  98. {jott_cli-0.5.1 → 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.1
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>
@@ -7,7 +7,7 @@ import importlib.util
7
7
  import os
8
8
  from pathlib import Path
9
9
 
10
- __version__ = "0.5.1"
10
+ __version__ = "0.5.2"
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)
@@ -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
  # ---------------------------------------------------------------------------
@@ -80,6 +82,8 @@ class App(DispatchMixin, AppNavigationMixin):
80
82
  '#': 'reauthenticate_google_calendar',
81
83
  '\x12': 'register_current_project', # Ctrl+R
82
84
  '*': 'toggle_highlight',
85
+ '~': 'quick_highlight',
86
+ '\x0c': 'assign_parent', # Ctrl+L
83
87
  }
84
88
 
85
89
  def __init__(self, task_manager, command_handler, registry,
@@ -91,6 +95,7 @@ class App(DispatchMixin, AppNavigationMixin):
91
95
  self.project_name = project_name
92
96
 
93
97
  self.state = AppState()
98
+ self._needs_render = True
94
99
  self._last_mtime = (
95
100
  os.path.getmtime(task_manager.storage_file)
96
101
  if task_manager.storage_file.exists() else 0)
@@ -111,8 +116,12 @@ class App(DispatchMixin, AppNavigationMixin):
111
116
  """Inner event loop — separated so run() can wrap with
112
117
  try/finally."""
113
118
  while self.state.running:
114
- tasks_to_display = self._prepare_tasks()
115
- 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()
116
125
 
117
126
  try:
118
127
  key = get_key()
@@ -122,6 +131,9 @@ class App(DispatchMixin, AppNavigationMixin):
122
131
  if self._handle_paste(key):
123
132
  continue
124
133
 
134
+ # Any real key press triggers a render
135
+ self._needs_render = True
136
+
125
137
  # Global handlers (any mode)
126
138
  if key == '\x05': # Ctrl+E
127
139
  self.command_handler.edit_current()
@@ -157,6 +169,12 @@ class App(DispatchMixin, AppNavigationMixin):
157
169
  """Fetch, filter, and sort the task list for display."""
158
170
  tasks = self.task_manager.get_tasks()
159
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)
160
178
  if self.state.show_today_only:
161
179
  today = get_today_day_name()
162
180
  tasks = filter_today_tasks(tasks, today)
@@ -165,6 +183,33 @@ class App(DispatchMixin, AppNavigationMixin):
165
183
 
166
184
  return self._get_filtered_tasks(tasks)
167
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
+
168
213
  def _get_filtered_tasks(self, tasks_to_display):
169
214
  """Apply fuzzy-search filtering when in FUZZY_SEARCH mode."""
170
215
  st = self.state
@@ -212,30 +257,35 @@ class App(DispatchMixin, AppNavigationMixin):
212
257
 
213
258
  def _render(self, tasks_to_display):
214
259
  """Clear screen and draw the current view."""
215
- sys.stdout.write('\033[?25l\033[H\033[J')
216
- 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
+ )
217
287
 
218
- if st.mode == MODE_ALL_CATEGORIES:
219
- self._render_all_categories()
220
- else:
221
- display_tasks(
222
- tasks_to_display, st.mode, st.input_buffer,
223
- self.project_name,
224
- category=self.task_manager.category,
225
- is_global=self.task_manager.is_global,
226
- show_archived=st.show_archived,
227
- archived_tasks=self.task_manager.archived,
228
- selected_tasks=st.selected_tasks,
229
- search_buffer=st.search_buffer,
230
- archived_task_ids=st.archived_task_ids,
231
- include_archived_in_search=st.include_archived_in_search,
232
- show_shortcuts=st.show_shortcuts,
233
- show_notes_inline=st.show_notes_inline,
234
- match_positions=st.match_positions,
235
- )
236
-
237
- sys.stdout.write('\033[?25h')
238
- sys.stdout.flush()
288
+ sys.stdout.write('\033[?25h')
239
289
 
240
290
  def _render_all_categories(self):
241
291
  """Delegate ALL_CATEGORIES rendering to the display module."""
@@ -263,6 +313,7 @@ class App(DispatchMixin, AppNavigationMixin):
263
313
  if current_mtime != self._last_mtime:
264
314
  self.task_manager._load_tasks()
265
315
  self._last_mtime = current_mtime
316
+ self._needs_render = True
266
317
  return True
267
318
 
268
319
  def _handle_paste(self, key):
@@ -274,4 +325,5 @@ class App(DispatchMixin, AppNavigationMixin):
274
325
  if self.state.mode == MODE_QUICK_ADD:
275
326
  pasted_text = pasted_text.replace('\r', '').replace('\n', '')
276
327
  self.state.input_buffer += pasted_text
328
+ self._needs_render = True
277
329
  return True
@@ -3,10 +3,11 @@
3
3
  from datetime import datetime
4
4
 
5
5
  from jot.core.constants import DAY_COLORS, DAY_ABBREVIATIONS
6
- from jot.ui.picker import PickerItem, quick_picker
6
+ from jot.ui.picker import PickerItem, quick_picker, fuzzy_picker
7
7
  from jot.ui.styles import (
8
8
  RESET, BOLD, DIM, GREEN, RED,
9
9
  PRIORITY_COLORS, PRIORITY_SYMBOLS, STATUS_COLORS, STATUS_SYMBOLS,
10
+ HIGHLIGHT_COLORS,
10
11
  )
11
12
  from jot.ui.input import restore_terminal
12
13
 
@@ -124,14 +125,52 @@ class MetadataMixin:
124
125
  return True
125
126
 
126
127
  def toggle_highlight(self):
127
- """Toggle highlight on the current task."""
128
+ """Pick a highlight color for the current task, or clear it."""
128
129
  current_task = self.task_manager.get_current_task()
129
130
  if not current_task:
130
131
  return True
132
+
133
+ items = [PickerItem(value="_clear", label="none (clear)", color=DIM)]
134
+ for name, fmt in HIGHLIGHT_COLORS.items():
135
+ items.append(PickerItem(
136
+ value=name, label=name, color=fmt,
137
+ ))
138
+
139
+ result = quick_picker(
140
+ items,
141
+ prompt=f"Highlight color for: {current_task['text']}",
142
+ )
143
+
144
+ if result.value is None:
145
+ return True
146
+
147
+ if result.value == "_clear":
148
+ self.task_manager.set_highlight(color=None)
149
+ print(f"\n{GREEN}✓{RESET} Highlight cleared")
150
+ else:
151
+ self.task_manager.set_highlight(color=result.value)
152
+ fmt = HIGHLIGHT_COLORS[result.value]
153
+ print(f"\n{GREEN}✓{RESET} Highlight set to {fmt} {result.value} {RESET}")
154
+
155
+ print("\nPress Enter to continue...", end='', flush=True)
156
+ restore_terminal()
157
+ input()
158
+ return True
159
+
160
+ def quick_highlight(self):
161
+ """Toggle default highlight color on the current task."""
162
+ current_task = self.task_manager.get_current_task()
163
+ if not current_task:
164
+ return True
165
+ from jot.ui.styles import HIGHLIGHT_DEFAULT
131
166
  was_highlighted = current_task.get('highlight', False)
132
- self.task_manager.toggle_highlight()
133
- label = "unhighlighted" if was_highlighted else "highlighted"
134
- print(f"\n{GREEN}✓{RESET} Task {label}")
167
+ if was_highlighted:
168
+ self.task_manager.set_highlight(color=None)
169
+ print(f"\n{GREEN}✓{RESET} Highlight cleared")
170
+ else:
171
+ self.task_manager.set_highlight(color=HIGHLIGHT_DEFAULT)
172
+ fmt = HIGHLIGHT_COLORS[HIGHLIGHT_DEFAULT]
173
+ print(f"\n{GREEN}✓{RESET} Highlighted {fmt} {HIGHLIGHT_DEFAULT} {RESET}")
135
174
  return True
136
175
 
137
176
  def set_priority_high(self):
@@ -176,3 +215,74 @@ class MetadataMixin:
176
215
  input()
177
216
 
178
217
  return True
218
+
219
+ def assign_parent(self):
220
+ """Assign current task as a subtask of another task (Ctrl+L)."""
221
+ current_task = self.task_manager.get_current_task()
222
+ if not current_task:
223
+ print("\n✗ No current task selected")
224
+ return True
225
+
226
+ task_id = current_task['id']
227
+ labels = current_task.get('labels', [])
228
+
229
+ # Find existing parent label
230
+ current_parent = None
231
+ for lbl in labels:
232
+ if lbl.startswith('parent:'):
233
+ current_parent = lbl.split(':', 1)[1]
234
+ break
235
+
236
+ # Build picker items
237
+ items = []
238
+ if current_parent is not None:
239
+ items.append(PickerItem(
240
+ value="_clear", label="Remove parent link", color=DIM,
241
+ ))
242
+
243
+ for task in self.task_manager.get_tasks():
244
+ if task['id'] == task_id:
245
+ continue
246
+ if task.get('status') == 'done':
247
+ continue
248
+ annotation = "(current parent)" if str(task['id']) == str(current_parent) else ""
249
+ items.append(PickerItem(
250
+ value=str(task['id']),
251
+ label=f"#{task['id']} {task['text']}",
252
+ annotation=annotation,
253
+ ))
254
+
255
+ if not items:
256
+ print("\n✗ No candidate tasks found")
257
+ return True
258
+
259
+ result = fuzzy_picker(
260
+ items,
261
+ prompt=f"Assign parent for: {current_task['text']}",
262
+ )
263
+
264
+ if result.value is None:
265
+ return True
266
+
267
+ # Strip existing parent: label
268
+ new_labels = [l for l in labels if not l.startswith('parent:')]
269
+
270
+ if result.value != "_clear":
271
+ new_labels.append(f"parent:{result.value}")
272
+
273
+ # Save updated labels
274
+ for task in self.task_manager.tasks:
275
+ if task['id'] == task_id:
276
+ task['labels'] = new_labels
277
+ self.task_manager._save_tasks()
278
+ break
279
+
280
+ if result.value == "_clear":
281
+ print(f"\n{GREEN}✓{RESET} Parent link removed")
282
+ else:
283
+ print(f"\n{GREEN}✓{RESET} Assigned as subtask of #{result.value}")
284
+
285
+ print("\nPress Enter to continue...", end='', flush=True)
286
+ restore_terminal()
287
+ input()
288
+ return True
@@ -5,9 +5,8 @@ from pathlib import Path
5
5
  from jot.categories.config import CategoryConfig
6
6
  from jot.categories.manager import CategoryManager
7
7
  from jot.core.task_manager import TaskManager
8
- from jot.ui.picker import PickerItem, fuzzy_picker
8
+ from jot.ui.picker import PickerItem, fuzzy_picker, quick_picker
9
9
  from jot.ui.styles import RESET, RED
10
- from jot.ui.input import restore_terminal
11
10
 
12
11
 
13
12
  class TransferMixin:
@@ -82,7 +81,8 @@ class TransferMixin:
82
81
  task_text,
83
82
  priority=current_task.get('priority', 'none'),
84
83
  status=current_task.get('status', 'todo'),
85
- labels=current_task.get('labels', []),
84
+ labels=[l for l in current_task.get('labels', [])
85
+ if not l.startswith('parent:')],
86
86
  effort=current_task.get('effort', None),
87
87
  )
88
88
 
@@ -147,7 +147,8 @@ class TransferMixin:
147
147
  task_text,
148
148
  priority=current_task.get('priority', 'none'),
149
149
  status=current_task.get('status', 'todo'),
150
- labels=current_task.get('labels', []),
150
+ labels=[l for l in current_task.get('labels', [])
151
+ if not l.startswith('parent:')],
151
152
  effort=current_task.get('effort', None),
152
153
  )
153
154
 
@@ -211,78 +212,55 @@ class TransferMixin:
211
212
 
212
213
  cat_config = CategoryConfig(project_dir=project_dir)
213
214
 
214
- print(f"\nTransfer task '{task_text}' to:")
215
- for i, (display_name, cat_name, is_global) in enumerate(categories, 1):
215
+ items = []
216
+ for display_name, cat_name, is_global in categories:
216
217
  if display_name == "default":
217
- cat_color = cat_config.get_color('default')
218
- print(f" {i}. {cat_color}default (no category){RESET}")
218
+ items.append(PickerItem(
219
+ value=(None, False),
220
+ label="default",
221
+ annotation="(no category)",
222
+ color=cat_config.get_color('default'),
223
+ ))
219
224
  else:
220
- cat_color = cat_config.get_color(cat_name, for_global=is_global)
221
225
  scope = "[global]" if is_global else "[local]"
222
- print(f" {i}. {cat_color}{cat_name} {scope}{RESET}")
223
-
224
- print("\nEnter number or name (ESC to cancel): ", end='', flush=True)
225
- restore_terminal()
226
-
227
- try:
228
- user_input = input().strip()
229
- if not user_input or user_input == '\x1b':
230
- print("✗ Cancelled")
231
- return True
232
-
233
- selected_category = None
234
- selected_is_global = False
235
-
236
- try:
237
- choice_index = int(user_input) - 1
238
- if 0 <= choice_index < len(categories):
239
- _, cat_name, is_global = categories[choice_index]
240
- selected_category = cat_name
241
- selected_is_global = is_global
242
- else:
243
- print("✗ Invalid number")
244
- return True
245
- except ValueError:
246
- found = False
247
- for display_name, cat_name, is_global in categories:
248
- if cat_name == user_input or display_name == user_input:
249
- selected_category = cat_name
250
- selected_is_global = is_global
251
- found = True
252
- break
253
- if not found:
254
- print(f"✗ Category '{user_input}' not found")
255
- return True
256
-
257
- if selected_is_global:
258
- dest_tm = TaskManager(
259
- category=selected_category, is_global=True, project_registry=self.registry
260
- )
261
- else:
262
- dest_tm = TaskManager(
263
- directory=project_dir,
264
- category=selected_category,
265
- is_global=False,
266
- project_registry=self.registry,
267
- )
268
-
269
- dest_tm.add_task(task_text)
270
- if task_done:
271
- new_task_id = dest_tm.tasks[-1]['id']
272
- dest_tm.set_task_status(new_task_id, 'done')
273
-
274
- self.task_manager.remove_task(task_id)
275
-
276
- dest_name = selected_category or 'default'
277
- if selected_is_global:
278
- print(f"✓ Transferred task to global:{dest_name}")
279
- else:
280
- print(f"✓ Transferred task to {dest_name}")
281
-
282
- print("\nPress Enter to continue...", end='', flush=True)
283
- input()
226
+ items.append(PickerItem(
227
+ value=(cat_name, is_global),
228
+ label=cat_name,
229
+ annotation=scope,
230
+ color=cat_config.get_color(cat_name, for_global=is_global),
231
+ ))
232
+
233
+ result = quick_picker(items, prompt=f"Transfer '{task_text}' to")
234
+ if result.value is None:
284
235
  return True
285
236
 
286
- except KeyboardInterrupt:
287
- print("\n✗ Cancelled")
288
- return True
237
+ selected_category, selected_is_global = result.value
238
+
239
+ if selected_is_global:
240
+ dest_tm = TaskManager(
241
+ category=selected_category, is_global=True, project_registry=self.registry
242
+ )
243
+ else:
244
+ dest_tm = TaskManager(
245
+ directory=project_dir,
246
+ category=selected_category,
247
+ is_global=False,
248
+ project_registry=self.registry,
249
+ )
250
+
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')
255
+
256
+ self.task_manager.remove_task(task_id)
257
+
258
+ dest_name = selected_category or 'default'
259
+ if selected_is_global:
260
+ print(f"✓ Transferred task to global:{dest_name}")
261
+ else:
262
+ print(f"✓ Transferred task to {dest_name}")
263
+
264
+ print("\nPress Enter to continue...", end='', flush=True)
265
+ input()
266
+ return True
@@ -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)}")
@@ -61,8 +61,13 @@ class MetadataMixin:
61
61
  self._save_tasks()
62
62
  return True
63
63
 
64
- def toggle_highlight(self, task_id=None):
65
- """Toggle highlight on a task. Multiple tasks can be highlighted."""
64
+ def set_highlight(self, task_id=None, color=None):
65
+ """Set highlight color on a task, or clear it.
66
+
67
+ Args:
68
+ task_id: Task to highlight (defaults to current task).
69
+ color: Color name from HIGHLIGHT_COLORS, or None to clear.
70
+ """
66
71
  if task_id is None:
67
72
  current = self.get_current_task()
68
73
  if not current:
@@ -71,7 +76,7 @@ class MetadataMixin:
71
76
 
72
77
  for task in self.tasks:
73
78
  if task['id'] == task_id:
74
- task['highlight'] = not task.get('highlight', False)
79
+ task['highlight'] = color if color else False
75
80
  self._save_tasks()
76
81
  return True
77
82
  return False