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.
- {jott_cli-0.6.0/jott_cli.egg-info → jott_cli-0.7.1}/PKG-INFO +1 -1
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/__init__.py +1 -1
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/_dispatch_mixin.py +38 -1
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/app.py +9 -1
- jott_cli-0.7.1/jot/commands/_claude_mixin.py +400 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_core_mixin.py +19 -15
- jott_cli-0.7.1/jot/commands/_github_mixin.py +296 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_notes_mixin.py +24 -2
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/handler.py +4 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_crud_mixin.py +28 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/constants.py +6 -0
- jott_cli-0.7.1/jot/integrations/github/issues.py +105 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_footer.py +0 -3
- jott_cli-0.7.1/jot/ui/display_help.py +282 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_tasks.py +18 -0
- jott_cli-0.7.1/jot/utils/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1/jott_cli.egg-info}/PKG-INFO +1 -1
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/SOURCES.txt +5 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/pyproject.toml +1 -1
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_command_handler.py +5 -3
- jott_cli-0.7.1/tests/test_github.py +234 -0
- jott_cli-0.6.0/jot/ui/display_help.py +0 -211
- {jott_cli-0.6.0 → jott_cli-0.7.1}/LICENSE +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/README.md +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/_app_navigation_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/config.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/manager.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/categories/templates.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/archive.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/config.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/cli/views.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_ai_analysis_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_ai_suggest_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_audio_timer_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_bulk_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_context_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_gcal_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_metadata_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_transfer_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/commands/_web_clipboard_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_age_backlog_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_compress_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_delete_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_export_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_id_migration_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_metadata_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_navigation_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_persistence_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/_subtask_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/archive_manager.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/id_manager.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/core/task_manager.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/account_manager.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/auth.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/gcal/events.py +0 -0
- {jott_cli-0.6.0/jot/integrations/keywords → jott_cli-0.7.1/jot/integrations/github}/__init__.py +0 -0
- {jott_cli-0.6.0/jot/projects → jott_cli-0.7.1/jot/integrations/keywords}/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/keywords/_config_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/keywords/_handlers_mixin.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/integrations/keywords/handler.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/handlers.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/schemas.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/mcp/server.py +0 -0
- {jott_cli-0.6.0/jot/utils → jott_cli-0.7.1/jot/projects}/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/projects/backup.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/projects/registry.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/__init__.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_archive.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/display_projects.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/formatting.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/input.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/picker.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/rendering.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/ui/styles.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/utils/date_utils.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/utils/text_utils.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jot/utils/validation.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/dependency_links.txt +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/entry_points.txt +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/requires.txt +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/jott_cli.egg-info/top_level.txt +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/setup.cfg +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/setup.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_dispatch.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_edit_edge_cases.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_fuzzy_search.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_gcal_notes.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_highlight.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_input.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_jot.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_picker.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_styles.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_subtask_notes.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_terminal_wrap.py +0 -0
- {jott_cli-0.6.0 → jott_cli-0.7.1}/tests/test_today_filter.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
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):
|