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