jott-cli 0.6.0__tar.gz → 0.7.1__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 (104) hide show
  1. {jott_cli-0.6.0/jott_cli.egg-info → jott_cli-0.7.1}/PKG-INFO +1 -1
  2. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/__init__.py +1 -1
  3. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/_dispatch_mixin.py +38 -1
  4. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/app.py +9 -1
  5. jott_cli-0.7.1/jot/commands/_claude_mixin.py +400 -0
  6. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_core_mixin.py +19 -15
  7. jott_cli-0.7.1/jot/commands/_github_mixin.py +296 -0
  8. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_notes_mixin.py +24 -2
  9. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/handler.py +4 -0
  10. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_crud_mixin.py +28 -0
  11. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/constants.py +6 -0
  12. jott_cli-0.7.1/jot/integrations/github/issues.py +105 -0
  13. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_footer.py +0 -3
  14. jott_cli-0.7.1/jot/ui/display_help.py +282 -0
  15. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_tasks.py +18 -0
  16. jott_cli-0.7.1/jot/utils/__init__.py +0 -0
  17. {jott_cli-0.6.0 → jott_cli-0.7.1/jott_cli.egg-info}/PKG-INFO +1 -1
  18. {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/SOURCES.txt +5 -0
  19. {jott_cli-0.6.0 → jott_cli-0.7.1}/pyproject.toml +1 -1
  20. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_command_handler.py +5 -3
  21. jott_cli-0.7.1/tests/test_github.py +234 -0
  22. jott_cli-0.6.0/jot/ui/display_help.py +0 -211
  23. {jott_cli-0.6.0 → jott_cli-0.7.1}/LICENSE +0 -0
  24. {jott_cli-0.6.0 → jott_cli-0.7.1}/README.md +0 -0
  25. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/_app_navigation_mixin.py +0 -0
  26. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/__init__.py +0 -0
  27. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/config.py +0 -0
  28. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/manager.py +0 -0
  29. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/templates.py +0 -0
  30. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/__init__.py +0 -0
  31. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/archive.py +0 -0
  32. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/config.py +0 -0
  33. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/views.py +0 -0
  34. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/__init__.py +0 -0
  35. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_ai_analysis_mixin.py +0 -0
  36. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_ai_suggest_mixin.py +0 -0
  37. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_audio_timer_mixin.py +0 -0
  38. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_bulk_mixin.py +0 -0
  39. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_context_mixin.py +0 -0
  40. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_gcal_mixin.py +0 -0
  41. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_metadata_mixin.py +0 -0
  42. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_transfer_mixin.py +0 -0
  43. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_web_clipboard_mixin.py +0 -0
  44. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/__init__.py +0 -0
  45. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_age_backlog_mixin.py +0 -0
  46. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_compress_mixin.py +0 -0
  47. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_delete_mixin.py +0 -0
  48. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_export_mixin.py +0 -0
  49. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_id_migration_mixin.py +0 -0
  50. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_metadata_mixin.py +0 -0
  51. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_navigation_mixin.py +0 -0
  52. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_persistence_mixin.py +0 -0
  53. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_subtask_mixin.py +0 -0
  54. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/archive_manager.py +0 -0
  55. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/id_manager.py +0 -0
  56. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/task_manager.py +0 -0
  57. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/__init__.py +0 -0
  58. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/__init__.py +0 -0
  59. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/account_manager.py +0 -0
  60. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/auth.py +0 -0
  61. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/events.py +0 -0
  62. {jott_cli-0.6.0/jot/integrations/keywords → jott_cli-0.7.1/jot/integrations/github}/__init__.py +0 -0
  63. {jott_cli-0.6.0/jot/projects → jott_cli-0.7.1/jot/integrations/keywords}/__init__.py +0 -0
  64. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/keywords/_config_mixin.py +0 -0
  65. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/keywords/_handlers_mixin.py +0 -0
  66. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/keywords/handler.py +0 -0
  67. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/__init__.py +0 -0
  68. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/handlers.py +0 -0
  69. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/schemas.py +0 -0
  70. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/server.py +0 -0
  71. {jott_cli-0.6.0/jot/utils → jott_cli-0.7.1/jot/projects}/__init__.py +0 -0
  72. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/projects/backup.py +0 -0
  73. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/projects/registry.py +0 -0
  74. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/__init__.py +0 -0
  75. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display.py +0 -0
  76. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_archive.py +0 -0
  77. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_projects.py +0 -0
  78. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/formatting.py +0 -0
  79. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/input.py +0 -0
  80. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/picker.py +0 -0
  81. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/rendering.py +0 -0
  82. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/styles.py +0 -0
  83. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/utils/date_utils.py +0 -0
  84. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/utils/text_utils.py +0 -0
  85. {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/utils/validation.py +0 -0
  86. {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/dependency_links.txt +0 -0
  87. {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/entry_points.txt +0 -0
  88. {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/requires.txt +0 -0
  89. {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/top_level.txt +0 -0
  90. {jott_cli-0.6.0 → jott_cli-0.7.1}/setup.cfg +0 -0
  91. {jott_cli-0.6.0 → jott_cli-0.7.1}/setup.py +0 -0
  92. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_dispatch.py +0 -0
  93. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_edit_edge_cases.py +0 -0
  94. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_fuzzy_search.py +0 -0
  95. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_gcal_notes.py +0 -0
  96. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_highlight.py +0 -0
  97. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_input.py +0 -0
  98. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_jot.py +0 -0
  99. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_picker.py +0 -0
  100. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_styles.py +0 -0
  101. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_subtask_notes.py +0 -0
  102. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_terminal_wrap.py +0 -0
  103. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_today_filter.py +0 -0
  104. {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_transfer_subtasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jott-cli
3
- Version: 0.6.0
3
+ Version: 0.7.1
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.6.0"
10
+ __version__ = "0.7.1"
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.
@@ -276,12 +276,17 @@ class DispatchMixin:
276
276
  self._toggle_collapse()
277
277
  st.input_buffer = ""
278
278
  return
279
+ if second == 'd':
280
+ self.command_handler.duplicate_task()
281
+ st.input_buffer = ""
282
+ return
279
283
  if second == 'c':
280
284
  self.command_handler.copy_to_clipboard()
281
285
  st.input_buffer = ""
282
286
  return
283
287
  if second == '?':
284
- st.show_shortcuts = not st.show_shortcuts
288
+ from jot.ui.display_help import help_modal
289
+ help_modal()
285
290
  st.input_buffer = ""
286
291
  return
287
292
  if second == 'i':
@@ -381,6 +386,38 @@ class DispatchMixin:
381
386
  self._handle_switch_project()
382
387
  st.input_buffer = ""
383
388
  return
389
+ if second == 'A':
390
+ self.command_handler.ask_claude()
391
+ st.input_buffer = ""
392
+ return
393
+ if second == 'C':
394
+ self.command_handler.launch_claude()
395
+ st.input_buffer = ""
396
+ return
397
+ if second == 'v':
398
+ self.command_handler.send_to_local_claude()
399
+ st.input_buffer = ""
400
+ return
401
+ if second == 'V':
402
+ self.command_handler.send_to_claude()
403
+ st.input_buffer = ""
404
+ return
405
+ if second == 'X':
406
+ self.command_handler.execute_claude()
407
+ st.input_buffer = ""
408
+ return
409
+ if second == 'I':
410
+ self.command_handler.import_from_github()
411
+ st.input_buffer = ""
412
+ return
413
+ if second == 'H':
414
+ self.command_handler.export_to_github()
415
+ st.input_buffer = ""
416
+ return
417
+ if second == 'D':
418
+ self.command_handler.close_github_issue()
419
+ st.input_buffer = ""
420
+ return
384
421
  # Not a chord — treat '.' as normal input
385
422
  st.input_buffer += '.'
386
423
  if second and second != '.':
@@ -37,7 +37,7 @@ class AppState:
37
37
  sort_by_day: bool = False
38
38
  show_today_only: bool = False
39
39
  include_archived_in_search: bool = True
40
- show_shortcuts: bool = False
40
+ show_shortcuts: bool = False # deprecated; kept for API compat
41
41
  show_notes_inline: bool = False
42
42
  running: bool = True
43
43
  selected_tasks: set = field(default_factory=set)
@@ -104,6 +104,7 @@ class App(DispatchMixin, AppNavigationMixin):
104
104
  tasks_to_display = self._prepare_tasks()
105
105
  self._render(tasks_to_display)
106
106
  self._needs_render = False
107
+ self._clear_terminal_claude_status()
107
108
  else:
108
109
  tasks_to_display = self._prepare_tasks()
109
110
 
@@ -267,6 +268,7 @@ class App(DispatchMixin, AppNavigationMixin):
267
268
  match_positions=st.match_positions,
268
269
  collapsed_parents=st.collapsed_parents,
269
270
  all_tasks=all_tasks,
271
+ claude_status=self.command_handler._claude_status,
270
272
  )
271
273
 
272
274
  sys.stdout.write('\033[?25h')
@@ -286,6 +288,12 @@ class App(DispatchMixin, AppNavigationMixin):
286
288
  # Auto-refresh & paste
287
289
  # ------------------------------------------------------------------
288
290
 
291
+ def _clear_terminal_claude_status(self):
292
+ """Clear 'done'/'error' statuses after one render cycle."""
293
+ status = self.command_handler._claude_status
294
+ if status and status not in ("thinking...", "editing..."):
295
+ self.command_handler._claude_status = ""
296
+
289
297
  def _handle_auto_refresh(self, key):
290
298
  """Detect external file changes on timeout."""
291
299
  if key is not None:
@@ -0,0 +1,400 @@
1
+ """Claude Code launch mixin — spawn Claude in a tmux window with task context."""
2
+
3
+ import os
4
+ import subprocess
5
+ import threading
6
+ import time
7
+ from datetime import datetime
8
+
9
+ from jot.ui.styles import RESET, CYAN, DIM
10
+
11
+ _ORG_OUTPUT_INSTRUCTION = (
12
+ "\n\nIMPORTANT: Format your entire response as org-mode markup:\n"
13
+ "- Code blocks: #+begin_src <lang>\n...\n#+end_src\n"
14
+ "- Inline code: ~symbol~ or =literal=\n"
15
+ "- File references: [[relative/path/to/file][filename.ext]]\n"
16
+ "- Headings: * ** *** etc.\n"
17
+ "Do NOT use markdown backticks or markdown headings."
18
+ )
19
+
20
+
21
+ class ClaudeMixin:
22
+ """Launch Claude Code in a new tmux window with current task."""
23
+
24
+ _claude_status = "" # shared status: "thinking...", "editing...", etc.
25
+
26
+ def launch_claude(self):
27
+ """Open new tmux window, start Claude Code, send current task."""
28
+ current_task = self.task_manager.get_current_task()
29
+ if not current_task:
30
+ print("\n\u2717 No current task selected")
31
+ time.sleep(0.5)
32
+ return True
33
+
34
+ if not os.environ.get('TMUX'):
35
+ print("\n\u2717 Not in a tmux session")
36
+ time.sleep(0.5)
37
+ return True
38
+
39
+ task_text = current_task['text']
40
+ task_id = current_task['id']
41
+ window_name = f"claude-{task_id}"
42
+
43
+ try:
44
+ subprocess.run(
45
+ ['tmux', 'new-window', '-n', window_name, 'claude'],
46
+ check=True,
47
+ stdout=subprocess.DEVNULL,
48
+ stderr=subprocess.DEVNULL,
49
+ )
50
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
51
+ print(f"\n\u2717 Failed to create tmux window: {e}")
52
+ time.sleep(0.5)
53
+ return True
54
+
55
+ prompt_found = self._wait_for_claude_prompt(window_name)
56
+ self._send_to_tmux_target(window_name, task_text)
57
+
58
+ if prompt_found:
59
+ print(f"\n{CYAN}\u2713 Launched Claude with: "
60
+ f"{task_text[:50]}{'...' if len(task_text) > 50 else ''}"
61
+ f"{RESET}")
62
+ else:
63
+ print(f"\n{CYAN}\u2713 Sent task to Claude "
64
+ f"(prompt not confirmed){RESET}")
65
+
66
+ time.sleep(0.5)
67
+ return True
68
+
69
+ def send_to_claude(self):
70
+ """Send current task text to an existing Claude pane."""
71
+ current_task = self.task_manager.get_current_task()
72
+ if not current_task:
73
+ print("\n\u2717 No current task selected")
74
+ time.sleep(0.5)
75
+ return True
76
+
77
+ if not os.environ.get('TMUX'):
78
+ print("\n\u2717 Not in a tmux session")
79
+ time.sleep(0.5)
80
+ return True
81
+
82
+ panes = self._find_claude_panes()
83
+ if not panes:
84
+ print("\n\u2717 No Claude Code panes found")
85
+ time.sleep(0.5)
86
+ return True
87
+
88
+ if len(panes) == 1:
89
+ target = panes[0]
90
+ else:
91
+ target = self._pick_claude_pane(panes)
92
+ if not target:
93
+ return True
94
+
95
+ task_text = current_task['text']
96
+
97
+ prompt_found = self._wait_for_claude_prompt(target, timeout=3)
98
+ self._send_to_tmux_target(target, task_text)
99
+
100
+ label = target.rsplit(':', 1)[0] # session:window
101
+ if prompt_found:
102
+ print(f"\n{CYAN}\u2713 Sent to {label}: "
103
+ f"{task_text[:50]}{'...' if len(task_text) > 50 else ''}"
104
+ f"{RESET}")
105
+ else:
106
+ print(f"\n{CYAN}\u2713 Sent to {label} "
107
+ f"(prompt not confirmed){RESET}")
108
+
109
+ time.sleep(0.5)
110
+ return True
111
+
112
+ def send_to_local_claude(self):
113
+ """Send current task text to Claude in the current tmux session."""
114
+ current_task = self.task_manager.get_current_task()
115
+ if not current_task:
116
+ print("\n\u2717 No current task selected")
117
+ time.sleep(0.5)
118
+ return True
119
+
120
+ if not os.environ.get('TMUX'):
121
+ print("\n\u2717 Not in a tmux session")
122
+ time.sleep(0.5)
123
+ return True
124
+
125
+ session = self._current_tmux_session()
126
+ if not session:
127
+ print("\n\u2717 Could not detect tmux session")
128
+ time.sleep(0.5)
129
+ return True
130
+
131
+ panes = self._find_claude_panes(session_filter=session)
132
+ if not panes:
133
+ print(f"\n\u2717 No Claude pane in session '{session}'")
134
+ time.sleep(0.5)
135
+ return True
136
+
137
+ target = panes[0]
138
+ task_text = current_task['text']
139
+
140
+ prompt_found = self._wait_for_claude_prompt(target, timeout=3)
141
+ self._send_to_tmux_target(target, task_text)
142
+
143
+ if prompt_found:
144
+ print(f"\n{CYAN}\u2713 Sent to {session}: "
145
+ f"{task_text[:50]}{'...' if len(task_text) > 50 else ''}"
146
+ f"{RESET}")
147
+ else:
148
+ print(f"\n{CYAN}\u2713 Sent to {session} "
149
+ f"(prompt not confirmed){RESET}")
150
+
151
+ time.sleep(0.5)
152
+ return True
153
+
154
+ def ask_claude(self):
155
+ """Send current task to Claude Code in background, write response to notes."""
156
+ current_task = self.task_manager.get_current_task()
157
+ if not current_task:
158
+ print("\n\u2717 No current task selected")
159
+ time.sleep(0.5)
160
+ return True
161
+
162
+ task_text = current_task['text']
163
+ task_id = current_task['id']
164
+ existing_notes = self.task_manager.get_task_notes(task_id) or ''
165
+
166
+ self._claude_status = "thinking..."
167
+ print(f"\n{CYAN}\u2713 Asking Claude about: "
168
+ f"{task_text[:50]}{'...' if len(task_text) > 50 else ''}"
169
+ f"{RESET}")
170
+ print(f" {DIM}Response will be written to task notes{RESET}")
171
+ time.sleep(0.3)
172
+
173
+ thread = threading.Thread(
174
+ target=self._run_claude_background,
175
+ args=(task_id, task_text, existing_notes),
176
+ daemon=True,
177
+ )
178
+ thread.start()
179
+ return True
180
+
181
+ def execute_claude(self):
182
+ """Execute Claude with edit permissions using task notes as context."""
183
+ current_task = self.task_manager.get_current_task()
184
+ if not current_task:
185
+ print("\n\u2717 No current task selected")
186
+ time.sleep(0.5)
187
+ return True
188
+
189
+ task_text = current_task['text']
190
+ task_id = current_task['id']
191
+ existing_notes = self.task_manager.get_task_notes(task_id) or ''
192
+
193
+ if not existing_notes.strip():
194
+ print(f"\n\u2717 No notes on task; run {CYAN}.A{RESET} first")
195
+ time.sleep(0.8)
196
+ return True
197
+
198
+ self._claude_status = "editing..."
199
+ print(f"\n{CYAN}\u2713 Executing Claude with edit permissions{RESET}")
200
+ print(f" {DIM}Task: {task_text[:50]}"
201
+ f"{'...' if len(task_text) > 50 else ''}{RESET}")
202
+ time.sleep(0.3)
203
+
204
+ thread = threading.Thread(
205
+ target=self._run_claude_background,
206
+ args=(task_id, task_text, existing_notes, True),
207
+ daemon=True,
208
+ )
209
+ thread.start()
210
+ return True
211
+
212
+ def _run_claude_background(self, task_id, task_text, existing_notes,
213
+ allow_edits=False):
214
+ """Run claude -p in background and append response to task notes."""
215
+ try:
216
+ if existing_notes.strip():
217
+ prompt = (f"Task: {task_text}\n\n"
218
+ f"Existing notes:\n{existing_notes}")
219
+ else:
220
+ prompt = task_text
221
+ prompt += _ORG_OUTPUT_INSTRUCTION
222
+
223
+ cmd = [
224
+ 'claude', '-p', '--model', 'sonnet',
225
+ '--permission-mode', 'dontAsk',
226
+ '--allowedTools', 'Read',
227
+ ]
228
+ if allow_edits:
229
+ cmd = [
230
+ 'claude', '-p', '--model', 'sonnet',
231
+ '--permission-mode', 'dontAsk',
232
+ '--allowedTools', 'Edit', 'Read', 'Write',
233
+ 'Bash(git diff)', 'Bash(git status)',
234
+ ]
235
+
236
+ result = subprocess.run(
237
+ cmd,
238
+ input=prompt,
239
+ capture_output=True,
240
+ text=True,
241
+ timeout=120,
242
+ )
243
+ response = result.stdout.strip()
244
+ if not response:
245
+ stderr = result.stderr.strip()
246
+ if stderr:
247
+ self._claude_status = "error"
248
+ self._append_claude_note(
249
+ task_id, existing_notes,
250
+ f"[error: {stderr[:200]}]")
251
+ else:
252
+ self._claude_status = "done (empty response)"
253
+ return
254
+
255
+ self._append_claude_note(task_id, existing_notes, response)
256
+ self._claude_status = "done"
257
+
258
+ except subprocess.TimeoutExpired:
259
+ self._claude_status = "timed out"
260
+ self._append_claude_note(
261
+ task_id, existing_notes, "[timed out after 120s]")
262
+ except FileNotFoundError:
263
+ self._claude_status = "error: claude not found"
264
+ except subprocess.CalledProcessError as e:
265
+ self._claude_status = "error"
266
+ self._append_claude_note(
267
+ task_id, existing_notes,
268
+ f"[process error: {e.returncode}]")
269
+
270
+ def _append_claude_note(self, task_id, existing_notes, content):
271
+ """Append a timestamped Claude entry to task notes."""
272
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
273
+ entry = f"* Claude ({timestamp})\n{content}\n"
274
+ if existing_notes.strip():
275
+ updated = existing_notes.rstrip('\n') + '\n\n' + entry
276
+ else:
277
+ updated = entry
278
+ self.task_manager.set_task_notes(task_id, updated)
279
+
280
+ def _current_tmux_session(self):
281
+ """Get the name of the current tmux session."""
282
+ try:
283
+ result = subprocess.run(
284
+ ['tmux', 'display-message', '-p', '#{session_name}'],
285
+ capture_output=True, text=True,
286
+ )
287
+ return result.stdout.strip() or None
288
+ except (subprocess.CalledProcessError, FileNotFoundError):
289
+ return None
290
+
291
+ def _find_claude_panes(self, session_filter=None):
292
+ """Find tmux panes running Claude Code across all sessions.
293
+
294
+ Claude Code runs as node with python3.12 + node + zsh sibling
295
+ panes. We identify it by capturing pane content and looking for
296
+ Claude's prompt indicator.
297
+ """
298
+ try:
299
+ result = subprocess.run(
300
+ ['tmux', 'list-panes', '-a', '-F',
301
+ '#{session_name}:#{window_index}.#{pane_index} '
302
+ '#{pane_current_command}'],
303
+ capture_output=True, text=True,
304
+ )
305
+ except (subprocess.CalledProcessError, FileNotFoundError):
306
+ return []
307
+
308
+ # Candidate panes: node processes (Claude Code runs as node)
309
+ candidates = []
310
+ for line in result.stdout.strip().splitlines():
311
+ parts = line.split(' ', 1)
312
+ if len(parts) == 2 and parts[1] == 'node':
313
+ pane_id = parts[0]
314
+ if session_filter:
315
+ sess = pane_id.split(':')[0]
316
+ if sess != session_filter:
317
+ continue
318
+ candidates.append(pane_id)
319
+
320
+ # Check each candidate for Claude's prompt in pane content
321
+ claude_panes = []
322
+ for pane_id in candidates:
323
+ try:
324
+ capture = subprocess.run(
325
+ ['tmux', 'capture-pane', '-t', pane_id, '-p'],
326
+ capture_output=True, text=True,
327
+ )
328
+ content = capture.stdout
329
+ if self._looks_like_claude(content):
330
+ claude_panes.append(pane_id)
331
+ except (subprocess.CalledProcessError, FileNotFoundError):
332
+ pass
333
+
334
+ return claude_panes
335
+
336
+ def _looks_like_claude(self, content):
337
+ """Check if pane content looks like a Claude Code session."""
338
+ for line in content.splitlines():
339
+ stripped = line.strip()
340
+ # Claude's input prompt
341
+ if stripped.startswith('\u276f') or stripped.startswith('\u2771'):
342
+ return True
343
+ # Status line markers
344
+ if 'Opus' in stripped or 'Sonnet' in stripped:
345
+ return True
346
+ if 'claude' in stripped.lower() and (
347
+ 'context' in stripped.lower()
348
+ or 'token' in stripped.lower()):
349
+ return True
350
+ return False
351
+
352
+ def _pick_claude_pane(self, panes):
353
+ """Show picker for multiple Claude panes, return selection."""
354
+ from jot.ui.picker import quick_picker, PickerItem
355
+
356
+ items = [
357
+ PickerItem(
358
+ value=pane_id,
359
+ label=pane_id.rsplit(':', 1)[0], # session:window
360
+ annotation=pane_id,
361
+ )
362
+ for pane_id in panes
363
+ ]
364
+ result = quick_picker(items, prompt="Send to which Claude session")
365
+ if result and result.value:
366
+ return result.value
367
+ return None
368
+
369
+ def _send_to_tmux_target(self, target, text):
370
+ """Send text + Enter to a tmux target."""
371
+ subprocess.run(
372
+ ['tmux', 'send-keys', '-t', target, '-l', text],
373
+ stdout=subprocess.DEVNULL,
374
+ stderr=subprocess.DEVNULL,
375
+ )
376
+ subprocess.run(
377
+ ['tmux', 'send-keys', '-t', target, 'Enter'],
378
+ stdout=subprocess.DEVNULL,
379
+ stderr=subprocess.DEVNULL,
380
+ )
381
+
382
+ def _wait_for_claude_prompt(self, target, timeout=10, interval=0.3):
383
+ """Poll tmux pane until Claude's input prompt appears."""
384
+ deadline = time.time() + timeout
385
+ while time.time() < deadline:
386
+ try:
387
+ result = subprocess.run(
388
+ ['tmux', 'capture-pane', '-t', target, '-p'],
389
+ capture_output=True, text=True,
390
+ )
391
+ for line in result.stdout.splitlines():
392
+ stripped = line.strip()
393
+ if stripped.startswith('>') or stripped.endswith('>'):
394
+ return True
395
+ if stripped.startswith('\u276f'):
396
+ return True
397
+ except (subprocess.CalledProcessError, FileNotFoundError):
398
+ pass
399
+ time.sleep(interval)
400
+ return False
@@ -61,6 +61,22 @@ class CoreMixin:
61
61
  print(f"\n✗ Failed to mark task")
62
62
  return True
63
63
 
64
+ def duplicate_task(self):
65
+ """Duplicate the current task."""
66
+ current_task = self.task_manager.get_current_task()
67
+ if not current_task:
68
+ print("\n\u2717 No current task selected")
69
+ return True
70
+
71
+ new_id = self.task_manager.duplicate_task(current_task['id'])
72
+ if new_id:
73
+ text = current_task['text']
74
+ display = text if len(text) <= 50 else text[:47] + '...'
75
+ print(f"\n{CYAN}\u2713 Duplicated as #{new_id}: {display}{RESET}")
76
+ else:
77
+ print("\n\u2717 Failed to duplicate task")
78
+ return True
79
+
64
80
  def fix_duplicate_ids_interactive(self):
65
81
  """Check for and fix duplicate task IDs (Shift+1)"""
66
82
  all_tasks = self.task_manager.tasks + self.task_manager.archived
@@ -261,21 +277,9 @@ class CoreMixin:
261
277
 
262
278
  @staticmethod
263
279
  def help():
264
- """Show help message"""
265
- print("""
266
- Commands: a Add c Current d Delete D Delete-current e Edit E Edit-current
267
- M Move r Refresh h Help q Quit
268
-
269
- Navigation: ↑/Ctrl+p Up ↓/Ctrl+n Down Shift+↑ Move up Shift+↓ Move down
270
-
271
- Quick-Add: .? Shortcuts .f Notes .n Edit notes .j Agent task
272
- .z Project .l Categories .w Day .x Status
273
- Ctrl+R Register Ctrl+T Transfer .m Move Ctrl+X Quit
274
-
275
- Command Mode (ESC): r Refresh
276
-
277
- Tip: Press '.' to start a leader chord. Press '.?' for full shortcuts.
278
- Full CLI docs: jott --help""")
280
+ """Show help modal with all keyboard shortcuts."""
281
+ from jot.ui.display_help import help_modal
282
+ help_modal()
279
283
  return True
280
284
 
281
285
  def toggle_caps(self):