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.
- {jott_cli-0.5.2/jott_cli.egg-info → jott_cli-0.5.3}/PKG-INFO +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/_app_navigation_mixin.py +4 -1
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/_dispatch_mixin.py +89 -2
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/app.py +75 -25
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_transfer_mixin.py +4 -2
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_delete_mixin.py +10 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_footer.py +2 -2
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_tasks.py +19 -6
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/picker.py +59 -56
- jott_cli-0.5.3/jot/ui/rendering.py +24 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/SOURCES.txt +1 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/pyproject.toml +1 -1
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_subtask_notes.py +340 -1
- {jott_cli-0.5.2 → jott_cli-0.5.3}/LICENSE +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/README.md +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/config.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/categories/templates.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/archive.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/config.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/cli/views.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_core_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_metadata_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_notes_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/commands/handler.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_crud_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_metadata_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/_subtask_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/constants.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/id_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/core/task_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/mcp/server.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/projects/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/projects/backup.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/projects/registry.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_help.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/formatting.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/input.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/ui/styles.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/__init__.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jot/utils/validation.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/setup.cfg +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/setup.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_command_handler.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_dispatch.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_highlight.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_input.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_jot.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_picker.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_styles.py +0 -0
- {jott_cli-0.5.2 → jott_cli-0.5.3}/tests/test_terminal_wrap.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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=''
|
|
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=''
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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"
|
|
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
|
-
|
|
121
|
-
text += f" {DIM}{item.annotation}{RESET}"
|
|
105
|
+
print()
|
|
122
106
|
|
|
123
|
-
|
|
124
|
-
|
|
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"
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
if i == cursor:
|
|
211
|
+
print(f" {GREEN}\u2192{RESET} {box} {text}")
|
|
212
|
+
else:
|
|
213
|
+
print(f" {box} {text}")
|
|
211
214
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
if len(items) > max_display:
|
|
216
|
+
remaining = len(items) - max_display
|
|
217
|
+
print(f"\n {DIM}... and {remaining} more{RESET}")
|
|
215
218
|
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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 @@ include = ["jot", "jot.*"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "jott-cli"
|
|
10
|
-
version = "0.5.
|
|
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"
|