tasktree-manager 2.0.1__tar.gz → 2.1.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 (57) hide show
  1. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/CHANGELOG.md +10 -0
  2. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/README.md +4 -4
  4. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/docs/user-guide.md +2 -2
  5. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/pyproject.toml +1 -1
  6. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/_version.py +2 -2
  7. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/app.py +112 -44
  8. tasktree_manager-2.1.0/tasktree_manager/services/claude_hooks.py +50 -0
  9. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/status_panel.py +69 -2
  10. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/task_list.py +30 -6
  11. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/worktree_list.py +2 -2
  12. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  13. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  14. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  15. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  16. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  17. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  18. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  19. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  20. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  21. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  22. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  23. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.github/workflows/ci.yml +0 -0
  24. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.github/workflows/release.yml +0 -0
  25. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/.gitignore +0 -0
  26. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/CONTRIBUTING.md +0 -0
  27. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/LICENSE +0 -0
  28. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/OPENSPEC.md +0 -0
  29. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/ROADMAP.md +0 -0
  30. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/docs/claude-code-integration-spec.md +0 -0
  31. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/docs/configuration.md +0 -0
  32. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/docs/installation.md +0 -0
  33. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/docs/troubleshooting.md +0 -0
  34. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/mise.toml +0 -0
  35. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/scripts/bump_version.py +0 -0
  36. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/__init__.py +0 -0
  37. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/services/__init__.py +0 -0
  38. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/services/config.py +0 -0
  39. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/services/git_ops.py +0 -0
  40. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/services/models.py +0 -0
  41. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/services/task_manager.py +0 -0
  42. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/__init__.py +0 -0
  43. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/create_modal.py +0 -0
  44. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  45. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  46. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/__init__.py +0 -0
  47. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/conftest.py +0 -0
  48. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_app.py +0 -0
  49. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_config.py +0 -0
  50. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_git_ops.py +0 -0
  51. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_integration_git.py +0 -0
  52. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_memory.py +0 -0
  53. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_messages_panel.py +0 -0
  54. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_modals.py +0 -0
  55. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_setup_modal.py +0 -0
  56. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_status_panel.py +0 -0
  57. {tasktree_manager-2.0.1 → tasktree_manager-2.1.0}/tests/test_task_manager.py +0 -0
@@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  - Git hooks support
16
16
  - Custom task metadata
17
17
 
18
+ ## [2.1.0] - 2026-02-10
19
+
20
+ ### Added
21
+ - Add context-aware info panel with task summary and focus-driven updates
22
+ - Add Claude session status monitoring with Ghostty integration
23
+
24
+ ### Changed
25
+ - Update example task names in README and user guide
26
+
27
+
18
28
  ## [2.0.1] - 2026-02-07
19
29
 
20
30
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -24,13 +24,13 @@
24
24
  ```
25
25
  ┌─ tasktree-manager ──────────────────────────────────────────────────────┐
26
26
  │ Tasks │ Worktrees │
27
- │ ● DIC-1813 (2) gitops-hpc DIC-1813 ✗ 3 │
28
- DIC-1770-envhpc DIC-1813 ✓ │
27
+ │ ● FEAT-42 (2) web-api FEAT-42 ✗ 3 │
28
+ FEAT-38-authfrontend FEAT-42 ✓ │
29
29
  │ │ │
30
30
  │ │ │
31
31
  ├──────────────────────────────────────────────────────────────────────────┤
32
- │ Status: gitops-hpc
33
- │ Branch: DIC-1813
32
+ │ Status: web-api
33
+ │ Branch: FEAT-42
34
34
  │ Sync: ↑2 ↓1 │
35
35
  │ │
36
36
  │ M src/main.py │
@@ -60,7 +60,7 @@ Task: FEAT-123
60
60
  ```
61
61
 
62
62
  **Characteristics:**
63
- - Created with a unique name (e.g., `FEAT-123`, `DIC-1813-auth`)
63
+ - Created with a unique name (e.g., `FEAT-123`, `BUG-456-auth`)
64
64
  - Contains one or more worktrees
65
65
  - Stored in `TASKS_DIR/task-name/`
66
66
  - Each task gets its own set of branches across selected repos
@@ -696,7 +696,7 @@ gh pr create --head FEAT-123
696
696
  ### Naming Conventions
697
697
 
698
698
  **Task names:**
699
- - Use issue tracker IDs: `JIRA-1234`, `DIC-1813`
699
+ - Use issue tracker IDs: `JIRA-1234`, `PROJ-567`
700
700
  - Or descriptive names: `feat-auth`, `bugfix-login`, `refactor-api`
701
701
  - Avoid spaces: Use `-` or `_`
702
702
  - Keep short but meaningful
@@ -70,7 +70,7 @@ version-file = "tasktree_manager/_version.py"
70
70
  # Semantic Release Configuration
71
71
  [tool.semantic_release]
72
72
  version_toml = ["pyproject.toml:tool.semantic_release.version"]
73
- version = "2.0.1"
73
+ version = "2.1.0"
74
74
  tag_format = "v{version}"
75
75
  branch = "main"
76
76
  commit_message = "chore(release): {version} [skip ci]"
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '2.0.1'
32
- __version_tuple__ = version_tuple = (2, 0, 1)
31
+ __version__ = version = '2.1.0'
32
+ __version_tuple__ = version_tuple = (2, 1, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -1,5 +1,6 @@
1
1
  """Main application for tasktree-manager."""
2
2
 
3
+ import json
3
4
  import subprocess
4
5
 
5
6
  from textual import work
@@ -8,6 +9,7 @@ from textual.binding import Binding
8
9
  from textual.containers import Horizontal, Vertical
9
10
  from textual.widgets import Footer, Header, Static
10
11
 
12
+ from .services.claude_hooks import ensure_claude_hooks
11
13
  from .services.config import Config
12
14
  from .services.git_ops import GitOps
13
15
  from .services.models import GitStatus, Task, Worktree
@@ -141,7 +143,7 @@ class TaskTreeApp(App):
141
143
  /* Status panel styling */
142
144
  #status-display {
143
145
  height: 100%;
144
- padding: 0 1;
146
+ padding: 1 1 0 1;
145
147
  background: $background;
146
148
  color: $text;
147
149
  overflow-y: auto;
@@ -250,6 +252,8 @@ class TaskTreeApp(App):
250
252
  self._show_messages_panel: bool = False
251
253
  # Used to preserve worktree selection during task list reload
252
254
  self._preserved_worktree_name: str | None = None
255
+ # Claude session statuses: task_name -> status string
256
+ self._claude_statuses: dict[str, str] = {}
253
257
 
254
258
  def _build_bindings_from_config(self) -> list[Binding]:
255
259
  """Build keybindings list from config.
@@ -306,6 +310,7 @@ class TaskTreeApp(App):
306
310
  # Right column: Info/Status panel
307
311
  with Vertical(id="right-column"):
308
312
  with Vertical(id="status-panel"):
313
+ yield Static("Info", classes="panel-title", id="status-panel-title")
309
314
  yield StatusPanel(id="status-display")
310
315
  with Vertical(id="messages-panel"):
311
316
  yield MessagesPanel(id="messages-display")
@@ -330,6 +335,9 @@ class TaskTreeApp(App):
330
335
  task_list = self.query_one("#task-list", TaskList)
331
336
  task_list.focus()
332
337
 
338
+ # Poll Claude session status files every 5 seconds
339
+ self.set_interval(5, self._poll_claude_statuses)
340
+
333
341
  def watch_theme(self, theme: str) -> None:
334
342
  """Save theme to config when changed."""
335
343
  if hasattr(self, "config") and self.config.theme != theme:
@@ -420,6 +428,27 @@ class TaskTreeApp(App):
420
428
  finally:
421
429
  task_list.loading = False
422
430
 
431
+ def _poll_claude_statuses(self) -> None:
432
+ """Check .claude_status files and update task indicators."""
433
+ try:
434
+ task_list = self.query_one("#task-list", TaskList)
435
+ except Exception:
436
+ return
437
+
438
+ statuses: dict[str, str] = {}
439
+ for task in task_list.tasks:
440
+ status_file = task.path / ".claude_status"
441
+ if status_file.exists():
442
+ try:
443
+ data = json.loads(status_file.read_text())
444
+ statuses[task.name] = data.get("status", "unknown")
445
+ except (json.JSONDecodeError, OSError):
446
+ pass
447
+
448
+ if statuses != self._claude_statuses:
449
+ self._claude_statuses = statuses
450
+ task_list.refresh_claude_indicators(statuses)
451
+
423
452
  def _refresh_current_task(self, preserve_selection: bool = False) -> None:
424
453
  """Refresh the current task's worktrees and status.
425
454
 
@@ -469,6 +498,26 @@ class TaskTreeApp(App):
469
498
  self.notify(f"Failed to run {name}: {e}", severity="error")
470
499
  return False
471
500
 
501
+ def on_descendant_focus(self, event) -> None:
502
+ """Handle focus changes to update info panel mode."""
503
+ try:
504
+ status_panel = self.query_one("#status-display", StatusPanel)
505
+ except Exception:
506
+ return
507
+
508
+ if isinstance(event.widget, TaskList):
509
+ # Task list focused → show task changed files
510
+ if self.current_task:
511
+ status_panel.set_loading(True)
512
+ self._update_task_summary(self.current_task)
513
+ elif isinstance(event.widget, WorktreeList):
514
+ # Worktree list focused → show worktree details
515
+ if self.current_worktree and self.current_status:
516
+ status_panel.update_status(self.current_worktree, self.current_status)
517
+ elif self.current_worktree:
518
+ status_panel.set_loading(True)
519
+ self._update_worktree_status(self.current_worktree)
520
+
472
521
  def on_task_list_task_highlighted(self, event: TaskList.TaskHighlighted) -> None:
473
522
  """Handle task highlight in task list."""
474
523
  self.current_task = event.task
@@ -484,6 +533,12 @@ class TaskTreeApp(App):
484
533
  preserved = self._preserved_worktree_name
485
534
  self._preserved_worktree_name = None # Clear after use
486
535
  worktree_list.load_worktrees(event.task.worktrees, preserve_selection=preserved)
536
+
537
+ # Show task summary if task list is focused
538
+ task_list = self.query_one("#task-list", TaskList)
539
+ if task_list.has_focus:
540
+ status_panel.set_loading(True)
541
+ self._update_task_summary(event.task)
487
542
  else:
488
543
  worktree_list.clear_worktrees()
489
544
  status_panel.clear_status()
@@ -499,12 +554,19 @@ class TaskTreeApp(App):
499
554
  # Widget not mounted yet during app startup
500
555
  return
501
556
 
557
+ # Only update status panel if worktree list is focused
558
+ worktree_list = self.query_one("#worktree-list", WorktreeList)
502
559
  if event.worktree:
503
- status_panel.set_loading(True)
504
- self._update_worktree_status(event.worktree)
560
+ if worktree_list.has_focus:
561
+ status_panel.set_loading(True)
562
+ self._update_worktree_status(event.worktree)
563
+ else:
564
+ # Still fetch status in background for when user switches focus
565
+ self._update_worktree_status(event.worktree)
505
566
  else:
506
567
  self.current_status = None
507
- status_panel.clear_status()
568
+ if worktree_list.has_focus:
569
+ status_panel.clear_status()
508
570
 
509
571
  @work(thread=True, exclusive=True, group="status_update")
510
572
  def _update_worktree_status(self, worktree: Worktree) -> None:
@@ -520,7 +582,31 @@ class TaskTreeApp(App):
520
582
  self.current_status = status
521
583
  try:
522
584
  status_panel = self.query_one("#status-display", StatusPanel)
523
- status_panel.update_status(worktree, status)
585
+ # Only show worktree status if worktree list is focused
586
+ worktree_list = self.query_one("#worktree-list", WorktreeList)
587
+ if worktree_list.has_focus:
588
+ status_panel.update_status(worktree, status)
589
+ except Exception:
590
+ pass
591
+
592
+ @work(thread=True, exclusive=True, group="task_summary_update")
593
+ def _update_task_summary(self, task: Task) -> None:
594
+ """Fetch git status for all dirty worktrees in background thread."""
595
+ statuses: dict[str, GitStatus] = {}
596
+ for wt in task.worktrees:
597
+ if wt.is_dirty:
598
+ statuses[wt.name] = GitOps.get_status(wt)
599
+ self.call_from_thread(self._apply_task_summary, task, statuses)
600
+
601
+ def _apply_task_summary(self, task: Task, statuses: dict[str, GitStatus]) -> None:
602
+ """Apply fetched task summary to the UI (called on main thread)."""
603
+ # Only update if this task is still selected and task list is focused
604
+ if self.current_task and self.current_task.name == task.name:
605
+ try:
606
+ status_panel = self.query_one("#status-display", StatusPanel)
607
+ task_list = self.query_one("#task-list", TaskList)
608
+ if task_list.has_focus:
609
+ status_panel.update_task_summary(task, statuses)
524
610
  except Exception:
525
611
  pass
526
612
 
@@ -918,7 +1004,7 @@ class TaskTreeApp(App):
918
1004
  self.query_one("#worktree-list", WorktreeList).focus()
919
1005
 
920
1006
  def action_open_claude_resume(self) -> None:
921
- """Open Claude Code with session list (claude -r) in the current task folder."""
1007
+ """Open Claude Code in a new Ghostty tab (resume session)."""
922
1008
  if not self.current_task:
923
1009
  self.notify("No task selected", severity="warning")
924
1010
  return
@@ -928,30 +1014,17 @@ class TaskTreeApp(App):
928
1014
  self.notify("Task directory not found", severity="error")
929
1015
  return
930
1016
 
931
- saved_task_name = self.current_task.name
932
- saved_worktree_name = self.current_worktree.name if self.current_worktree else None
933
-
934
1017
  # Fetch fresh task data to ensure worktrees are up-to-date
935
- fresh_task = self.task_manager.get_task(saved_task_name)
1018
+ fresh_task = self.task_manager.get_task(self.current_task.name)
936
1019
  if fresh_task:
937
- # Create claude.md files if they don't exist
938
1020
  self.task_manager.ensure_claude_md_files(fresh_task)
939
1021
 
940
- self.notify("Opening Claude Code (resume)...")
941
-
942
- with self.suspend():
943
- self._run_external_command(
944
- [self.config.claude_path, "-r"],
945
- cwd=task_path,
946
- name="Claude Code",
947
- install_hint="npm install -g @anthropic-ai/claude-code",
948
- )
949
-
950
- self._load_tasks_with_selection(saved_task_name, saved_worktree_name)
951
- self.query_one("#task-list", TaskList).focus()
1022
+ ensure_claude_hooks(task_path)
1023
+ self._open_ghostty_tab(task_path, command=f"{self.config.claude_path} -r")
1024
+ self.notify("Opened Claude Code in new tab (resume)")
952
1025
 
953
1026
  def action_open_claude_new(self) -> None:
954
- """Open a new Claude Code session in the current task folder."""
1027
+ """Open a new Claude Code session in a new Ghostty tab."""
955
1028
  if not self.current_task:
956
1029
  self.notify("No task selected", severity="warning")
957
1030
  return
@@ -961,27 +1034,14 @@ class TaskTreeApp(App):
961
1034
  self.notify("Task directory not found", severity="error")
962
1035
  return
963
1036
 
964
- saved_task_name = self.current_task.name
965
- saved_worktree_name = self.current_worktree.name if self.current_worktree else None
966
-
967
1037
  # Fetch fresh task data to ensure worktrees are up-to-date
968
- fresh_task = self.task_manager.get_task(saved_task_name)
1038
+ fresh_task = self.task_manager.get_task(self.current_task.name)
969
1039
  if fresh_task:
970
- # Create claude.md files if they don't exist
971
1040
  self.task_manager.ensure_claude_md_files(fresh_task)
972
1041
 
973
- self.notify("Opening new Claude Code session...")
974
-
975
- with self.suspend():
976
- self._run_external_command(
977
- [self.config.claude_path],
978
- cwd=task_path,
979
- name="Claude Code",
980
- install_hint="npm install -g @anthropic-ai/claude-code",
981
- )
982
-
983
- self._load_tasks_with_selection(saved_task_name, saved_worktree_name)
984
- self.query_one("#task-list", TaskList).focus()
1042
+ ensure_claude_hooks(task_path)
1043
+ self._open_ghostty_tab(task_path, command=self.config.claude_path)
1044
+ self.notify("Opened new Claude Code session in new tab")
985
1045
 
986
1046
  def action_open_folder(self) -> None:
987
1047
  """Open current folder in a new terminal tab."""
@@ -1006,15 +1066,23 @@ class TaskTreeApp(App):
1006
1066
  # Open in new terminal tab
1007
1067
  self._open_ghostty_tab(folder_path)
1008
1068
 
1009
- def _open_ghostty_tab(self, path) -> None:
1010
- """Open a new terminal tab at the given path.
1069
+ def _open_ghostty_tab(self, path, command: str | None = None) -> None:
1070
+ """Open a new terminal tab at the given path, optionally running a command.
1011
1071
 
1012
1072
  NOTE: This implementation is specific to the Ghostty terminal emulator
1013
1073
  on macOS, using AppleScript. It will silently fail on other terminals.
1074
+
1075
+ Args:
1076
+ path: Directory to cd into in the new tab
1077
+ command: Optional command to run after cd (e.g., "claude -r")
1014
1078
  """
1015
1079
  # Ghostty: Use AppleScript to activate and open new tab, then cd
1016
1080
  # Escape single quotes for AppleScript string interpolation
1017
1081
  safe_path = str(path).replace("'", "'\\''")
1082
+ if command:
1083
+ shell_cmd = f"cd '{safe_path}' && clear && {command}"
1084
+ else:
1085
+ shell_cmd = f"cd '{safe_path}' && clear"
1018
1086
  script = f"""
1019
1087
  tell application "Ghostty"
1020
1088
  activate
@@ -1023,7 +1091,7 @@ class TaskTreeApp(App):
1023
1091
  tell process "Ghostty"
1024
1092
  keystroke "t" using command down
1025
1093
  delay 0.1
1026
- keystroke "cd '{safe_path}' && clear"
1094
+ keystroke "{shell_cmd}"
1027
1095
  key code 36
1028
1096
  end tell
1029
1097
  end tell
@@ -0,0 +1,50 @@
1
+ """Claude Code hook configuration for status monitoring."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def _make_hook(status: str, status_file: str) -> dict:
8
+ """Create a single hook entry that writes status to the given file path."""
9
+ safe_path = status_file.replace("'", "'\\''")
10
+ return {
11
+ "type": "command",
12
+ "command": f"printf '{{\"status\":\"{status}\",\"ts\":%d}}' $(date +%s) > '{safe_path}'",
13
+ "async": True,
14
+ }
15
+
16
+
17
+ def _build_hooks_config(task_path: Path) -> dict:
18
+ """Build hooks config with absolute path to status file."""
19
+ status_file = str(task_path / ".claude_status")
20
+ return {
21
+ "SessionStart": [{"hooks": [_make_hook("running", status_file)]}],
22
+ "UserPromptSubmit": [{"hooks": [_make_hook("running", status_file)]}],
23
+ "Stop": [{"hooks": [_make_hook("waiting", status_file)]}],
24
+ "SessionEnd": [{"hooks": [_make_hook("ended", status_file)]}],
25
+ }
26
+
27
+
28
+ def ensure_claude_hooks(task_path: Path) -> None:
29
+ """Create .claude/settings.local.json with status-reporting hooks.
30
+
31
+ Merges with existing settings if the file already exists, preserving
32
+ user's own configuration while adding/updating the status hooks.
33
+ """
34
+ claude_dir = task_path / ".claude"
35
+ claude_dir.mkdir(exist_ok=True)
36
+
37
+ settings_file = claude_dir / "settings.local.json"
38
+
39
+ if settings_file.exists():
40
+ try:
41
+ existing = json.loads(settings_file.read_text())
42
+ except (json.JSONDecodeError, OSError):
43
+ existing = {}
44
+ else:
45
+ existing = {}
46
+
47
+ # Merge hooks into existing settings
48
+ existing["hooks"] = _build_hooks_config(task_path)
49
+
50
+ settings_file.write_text(json.dumps(existing, indent=2) + "\n")
@@ -3,19 +3,23 @@
3
3
  from rich.text import Text
4
4
  from textual.widgets import Static
5
5
 
6
- from ..services.models import GitStatus, Worktree
6
+ from ..services.models import GitStatus, Task, Worktree
7
7
 
8
8
 
9
9
  class StatusPanel(Static):
10
- """Panel displaying git status for selected worktree."""
10
+ """Panel displaying git status for selected worktree or task summary."""
11
11
 
12
12
  def __init__(self, *args, **kwargs):
13
13
  super().__init__(*args, **kwargs)
14
14
  self._worktree_name: str = ""
15
15
  self._status: GitStatus | None = None
16
+ self._current_task: Task | None = None
17
+ self._task_statuses: dict[str, GitStatus] = {}
18
+ self._mode: str = "worktree" # "worktree" or "task"
16
19
 
17
20
  def update_status(self, worktree: Worktree | None, status: GitStatus | None) -> None:
18
21
  """Update the status display for a worktree."""
22
+ self._mode = "worktree"
19
23
  if worktree is None or status is None:
20
24
  self._worktree_name = ""
21
25
  self._status = None
@@ -26,8 +30,69 @@ class StatusPanel(Static):
26
30
  self._status = status
27
31
  self._update_display()
28
32
 
33
+ def update_task_summary(
34
+ self, task: Task | None, statuses: dict[str, GitStatus] | None = None
35
+ ) -> None:
36
+ """Update the display with a task summary.
37
+
38
+ Args:
39
+ task: The task to display
40
+ statuses: Optional dict of worktree_name -> GitStatus with file details
41
+ """
42
+ self._mode = "task"
43
+ self._current_task = task
44
+ self._task_statuses = statuses or {}
45
+ self._update_display()
46
+
29
47
  def _update_display(self) -> None:
30
48
  """Update the display content."""
49
+ if self._mode == "task":
50
+ self._render_task_summary()
51
+ else:
52
+ self._render_worktree_status()
53
+
54
+ def _render_task_summary(self) -> None:
55
+ """Render the task summary view — changed files grouped by repo."""
56
+ if self._current_task is None:
57
+ self.update(Text("No task selected", style="dim"))
58
+ return
59
+
60
+ task = self._current_task
61
+ text = Text()
62
+
63
+ dirty_worktrees = [wt for wt in task.worktrees if wt.is_dirty]
64
+
65
+ if not dirty_worktrees:
66
+ text.append("All repos clean", style="green")
67
+ self.update(text)
68
+ return
69
+
70
+ for wt in dirty_worktrees:
71
+ # Repo header
72
+ text.append(f"{wt.name}", style="bold")
73
+ text.append("\n")
74
+
75
+ status = self._task_statuses.get(wt.name)
76
+ if status:
77
+ for status_code, filename in status.all_changes:
78
+ if status_code.strip().startswith("?"):
79
+ text.append(f" {status_code} ", style="red")
80
+ text.append(f"{filename}\n", style="red")
81
+ elif status_code.strip().startswith("M") or status_code.strip() == "M":
82
+ text.append(f" {status_code} ", style="yellow")
83
+ text.append(f"{filename}\n")
84
+ else:
85
+ text.append(f" {status_code} ", style="green")
86
+ text.append(f"{filename}\n")
87
+ else:
88
+ text.append(f" {wt.changed_files} files changed\n", style="dim italic")
89
+
90
+ text.append("\n")
91
+
92
+ self.update(text)
93
+
94
+ def _render_worktree_status(self) -> None:
95
+ """Render the worktree-specific status view."""
31
96
  if not self._worktree_name or self._status is None:
32
97
  self.update(Text("No worktree selected", style="dim"))
33
98
  return
@@ -81,6 +146,8 @@ class StatusPanel(Static):
81
146
  """Clear the status display."""
82
147
  self._worktree_name = ""
83
148
  self._status = None
149
+ self._current_task = None
150
+ self._mode = "worktree"
84
151
  self.update(Text("No worktree selected", style="dim"))
85
152
 
86
153
  def set_loading(self, loading: bool = True) -> None:
@@ -60,27 +60,42 @@ class TaskList(OptionList):
60
60
  """Compatibility property - sets highlighted index."""
61
61
  self.highlighted = value
62
62
 
63
- def _format_task_option(self, task: Task) -> Option:
63
+ def _format_task_option(self, task: Task, claude_status: str | None = None) -> Option:
64
64
  """Format a task as an Option for display."""
65
- claude_indicator = "[blue]◆[/]" if task.has_claude_md else " "
65
+ claude_md = "[blue]◆[/]" if task.has_claude_md else " "
66
+ if claude_status == "running":
67
+ hook_indicator = " [magenta]⟳[/]"
68
+ elif claude_status == "waiting":
69
+ hook_indicator = " [yellow]![/]"
70
+ elif claude_status == "ended":
71
+ hook_indicator = " [green]✓[/]"
72
+ else:
73
+ hook_indicator = ""
66
74
  if task.is_dirty:
67
- prompt = f"[red]●[/]{claude_indicator}{task.name} [red]({task.dirty_count})[/]"
75
+ prompt = f"[red]●[/]{claude_md}{task.name} [red]({task.dirty_count})[/]{hook_indicator}"
68
76
  else:
69
- prompt = f" {claude_indicator}{task.name}"
77
+ prompt = f" {claude_md}{task.name}{hook_indicator}"
70
78
  return Option(prompt, id=task.name)
71
79
 
72
- def load_tasks(self, tasks: list[Task], preserve_selection: str | None = None) -> None:
80
+ def load_tasks(
81
+ self,
82
+ tasks: list[Task],
83
+ preserve_selection: str | None = None,
84
+ claude_statuses: dict[str, str] | None = None,
85
+ ) -> None:
73
86
  """Load tasks into the list.
74
87
 
75
88
  Args:
76
89
  tasks: List of tasks to load
77
90
  preserve_selection: Optional task name to preserve selection for
91
+ claude_statuses: Optional dict of task_name -> claude status string
78
92
  """
79
93
  sorted_tasks = self._sort_tasks(tasks)
80
94
  self.tasks = sorted_tasks
81
95
  self.clear_options()
96
+ statuses = claude_statuses or {}
82
97
  for task in sorted_tasks:
83
- self.add_option(self._format_task_option(task))
98
+ self.add_option(self._format_task_option(task, statuses.get(task.name)))
84
99
 
85
100
  # Select item - preserve previous selection if specified
86
101
  if self.tasks and self.option_count > 0:
@@ -131,6 +146,15 @@ class TaskList(OptionList):
131
146
  self.highlighted = current_index
132
147
  self._emit_highlighted()
133
148
 
149
+ def refresh_claude_indicators(self, statuses: dict[str, str]) -> None:
150
+ """Update Claude status indicators without full reload.
151
+
152
+ Re-renders all option labels with updated claude status indicators.
153
+ """
154
+ for i, task in enumerate(self.tasks):
155
+ new_option = self._format_task_option(task, statuses.get(task.name))
156
+ self.replace_option_prompt_at_index(i, new_option.prompt)
157
+
134
158
  def cycle_sort_mode(self) -> None:
135
159
  """Cycle through sort modes and reload tasks."""
136
160
  modes = list(SortMode)
@@ -140,9 +140,9 @@ class WorktreeList(OptionList):
140
140
  claude_indicator = "[blue]◆[/]" if worktree.has_claude_md else " "
141
141
 
142
142
  if worktree.is_dirty:
143
- prompt = f" {claude_indicator}{name_col} [cyan]{branch_col}[/] [red]✗ {worktree.changed_files} files[/]"
143
+ prompt = f" {claude_indicator}{name_col} {branch_col} [red]✗ {worktree.changed_files} files[/]"
144
144
  else:
145
- prompt = f" {claude_indicator}{name_col} [cyan]{branch_col}[/] [green]✓[/]"
145
+ prompt = f" {claude_indicator}{name_col} {branch_col} [green]✓[/]"
146
146
  self.add_option(Option(prompt, id=worktree.name))
147
147
 
148
148
  def _emit_highlighted(self) -> None: