tasktree-manager 2.0.1__tar.gz → 2.2.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.2.0}/CHANGELOG.md +16 -0
  2. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/README.md +16 -22
  4. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/docs/configuration.md +40 -5
  5. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/docs/user-guide.md +35 -8
  6. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/app.py +157 -50
  9. tasktree_manager-2.2.0/tasktree_manager/services/claude_hooks.py +50 -0
  10. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/services/config.py +8 -1
  11. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/services/git_ops.py +7 -0
  12. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/status_panel.py +69 -2
  13. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/task_list.py +30 -6
  14. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/worktree_list.py +2 -2
  15. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  16. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  17. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  18. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  19. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  20. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  21. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  22. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  23. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  24. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  25. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  26. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.github/workflows/ci.yml +0 -0
  27. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.github/workflows/release.yml +0 -0
  28. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/.gitignore +0 -0
  29. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/CONTRIBUTING.md +0 -0
  30. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/LICENSE +0 -0
  31. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/OPENSPEC.md +0 -0
  32. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/ROADMAP.md +0 -0
  33. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/docs/claude-code-integration-spec.md +0 -0
  34. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/docs/installation.md +0 -0
  35. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/docs/troubleshooting.md +0 -0
  36. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/mise.toml +0 -0
  37. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/scripts/bump_version.py +0 -0
  38. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/__init__.py +0 -0
  39. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/services/__init__.py +0 -0
  40. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/services/models.py +0 -0
  41. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/services/task_manager.py +0 -0
  42. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/__init__.py +0 -0
  43. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/create_modal.py +0 -0
  44. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  45. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  46. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/__init__.py +0 -0
  47. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/conftest.py +0 -0
  48. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_app.py +0 -0
  49. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_config.py +0 -0
  50. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_git_ops.py +0 -0
  51. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_integration_git.py +0 -0
  52. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_memory.py +0 -0
  53. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_messages_panel.py +0 -0
  54. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_modals.py +0 -0
  55. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_setup_modal.py +0 -0
  56. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_status_panel.py +0 -0
  57. {tasktree_manager-2.0.1 → tasktree_manager-2.2.0}/tests/test_task_manager.py +0 -0
@@ -15,6 +15,22 @@ 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.2.0] - 2026-03-06
19
+
20
+ ### Added
21
+ - Add periodic auto-refresh for git status with selection preserv… (#3)
22
+
23
+
24
+ ## [2.1.0] - 2026-02-10
25
+
26
+ ### Added
27
+ - Add context-aware info panel with task summary and focus-driven updates
28
+ - Add Claude session status monitoring with Ghostty integration
29
+
30
+ ### Changed
31
+ - Update example task names in README and user guide
32
+
33
+
18
34
  ## [2.0.1] - 2026-02-07
19
35
 
20
36
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -19,26 +19,18 @@
19
19
  - 🚀 **Fast & Responsive** - Parallel git operations with loading indicators
20
20
  - 🔧 **Flexible Configuration** - TOML config file, custom keybindings, environment variables
21
21
 
22
- ## 📸 Screenshots
23
-
24
- ```
25
- ┌─ tasktree-manager ──────────────────────────────────────────────────────┐
26
- │ Tasks │ Worktrees │
27
- │ ● DIC-1813 (2) │ gitops-hpc DIC-1813 ✗ 3 │
28
- │ DIC-1770-env │ hpc DIC-1813 ✓ │
29
- │ │ │
30
- │ │ │
31
- ├──────────────────────────────────────────────────────────────────────────┤
32
- │ Status: gitops-hpc │
33
- │ Branch: DIC-1813 │
34
- │ Sync: ↑2 ↓1 │
35
- │ │
36
- │ M src/main.py │
37
- │ ?? README.md │
38
- │ M tests/test_app.py │
39
- └──────────────────────────────────────────────────────────────────────────┘
40
- n New task d Delete g Lazygit p Push r Refresh ? Help q Quit
41
- ```
22
+ ## 📊 Status Indicators
23
+
24
+ | Indicator | Meaning |
25
+ |-----------|---------|
26
+ | `●` red | Uncommitted changes |
27
+ | `◆` blue | Has CLAUDE.md file |
28
+ | `⟳` magenta | Claude Code session running |
29
+ | `!` yellow | Claude Code session waiting for input |
30
+ | `✓` green | Clean worktree / Claude session ended |
31
+ | `✗ N` red | N files changed |
32
+ | `↑N` green | N commits ahead of remote |
33
+ | `↓N` yellow | N commits behind remote |
42
34
 
43
35
  ## 🚀 Quick Start
44
36
 
@@ -85,9 +77,11 @@ For detailed workflows and examples, see the [User Guide](docs/user-guide.md).
85
77
  | `n` | New task | `g` | Open lazygit |
86
78
  | `a` | Add repo | `e` | Open editor |
87
79
  | `d` | Delete task | `o` | Open folder |
88
- | `p` | Push all | `Enter` | Open shell |
80
+ | `p` | Push all | `P` | Pull all |
81
+ | `c` | Claude (resume) | `C` | Claude (new) |
89
82
  | `r` | Refresh | `s` | Sort tasks |
90
- | `S` | Group worktrees | `Tab` | Next panel |
83
+ | `S` | Group worktrees | `m` | Messages |
84
+ | `Enter` | Open shell | `Tab` | Next panel |
91
85
  | `?` | Show help | `q` | Quit |
92
86
 
93
87
  **Themes:** Press `Ctrl+P` to switch between 7 built-in themes (textual-dark, nord, gruvbox, tokyo-night, monokai, dracula, textual-light).
@@ -93,6 +93,11 @@ theme = "textual-dark"
93
93
  # Default: false
94
94
  show_hidden_files = false
95
95
 
96
+ # Auto-refresh interval in seconds (0 = disabled)
97
+ # Periodically refreshes git status in the background
98
+ # Default: 30
99
+ refresh_interval = 30
100
+
96
101
  # ============================================================================
97
102
  # Git Settings
98
103
  # ============================================================================
@@ -160,6 +165,13 @@ push_all = "p" # Push all worktrees in current task
160
165
  pull_all = "P" # Pull all worktrees in current task (shift+p)
161
166
  refresh = "r" # Refresh git status
162
167
 
168
+ # Claude Code integration
169
+ open_claude_resume = "c" # Resume Claude Code session in task directory
170
+ open_claude_new = "C" # Start new Claude Code session (shift+c)
171
+
172
+ # Messages panel
173
+ toggle_messages = "m" # Show/hide the activity messages panel
174
+
163
175
  # Navigation
164
176
  focus_next = "tab" # Switch to next panel
165
177
  focus_previous = "shift+tab" # Switch to previous panel
@@ -224,10 +236,11 @@ tasks_dir = "/Volumes/FastSSD/worktrees"
224
236
 
225
237
  ## UI Settings
226
238
 
227
- | Option | Type | Default | Environment Variable | Description |
228
- |--------------------|---------|-----------------|-----------------------|---------------------------------|
229
- | `theme` | string | `textual-dark` | `TASKTREE_THEME` | Color theme for the interface |
230
- | `show_hidden_files` | boolean | `false` | - | Show hidden files in listings |
239
+ | Option | Type | Default | Environment Variable | Description |
240
+ |--------------------|---------|-----------------|-----------------------|------------------------------------------|
241
+ | `theme` | string | `textual-dark` | `TASKTREE_THEME` | Color theme for the interface |
242
+ | `show_hidden_files` | boolean | `false` | - | Show hidden files in listings |
243
+ | `refresh_interval` | integer | `30` | - | Auto-refresh interval in seconds (0 = disabled) |
231
244
 
232
245
  ### `theme`
233
246
 
@@ -245,6 +258,20 @@ Available themes (press `Ctrl+P` in app to switch):
245
258
 
246
259
  Currently unused (reserved for future file browser features).
247
260
 
261
+ ### `refresh_interval`
262
+
263
+ Automatically refreshes git status for all tasks and worktrees in the background:
264
+
265
+ - Default: `30` (every 30 seconds)
266
+ - Set to `0` to disable auto-refresh
267
+ - Runs in a background thread — does not block the UI
268
+ - Preserves current task and worktree selection during refresh
269
+
270
+ ```toml
271
+ [ui]
272
+ refresh_interval = 15 # Refresh every 15 seconds
273
+ ```
274
+
248
275
  ## Git Settings
249
276
 
250
277
  | Option | Type | Default | Environment Variable | Description |
@@ -390,7 +417,7 @@ blocklist = ["*"]
390
417
 
391
418
  ## Keybindings Reference
392
419
 
393
- All 18 customizable keybindings with descriptions:
420
+ All 21 customizable keybindings with descriptions:
394
421
 
395
422
  ### Application Control
396
423
 
@@ -419,6 +446,14 @@ All 18 customizable keybindings with descriptions:
419
446
  | `pull_all` | `P` | Pull all worktrees in current task | `pull_all = "ctrl+shift+p"` |
420
447
  | `refresh` | `r` | Refresh git status for all worktrees | `refresh = "F5"` |
421
448
 
449
+ ### Claude Code Integration
450
+
451
+ | Action | Default | Description | Customization Example |
452
+ |----------------------|---------|------------------------------------------|--------------------------------------|
453
+ | `open_claude_resume` | `c` | Resume Claude Code session in task dir | `open_claude_resume = "ctrl+c"` |
454
+ | `open_claude_new` | `C` | Start new Claude Code session | `open_claude_new = "ctrl+shift+c"` |
455
+ | `toggle_messages` | `m` | Show/hide activity messages panel | `toggle_messages = "ctrl+m"` |
456
+
422
457
  ### Navigation
423
458
 
424
459
  | Action | Default | Description | Customization Example |
@@ -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
@@ -177,7 +177,9 @@ Shows all your tasks:
177
177
 
178
178
  - **Name**: Task identifier (e.g., `FEAT-123`)
179
179
  - **Count**: Number of worktrees in parentheses (e.g., `(3)`)
180
- - **Dirty indicator** `●`: Task has uncommitted changes
180
+ - **Dirty indicator** `●`: Red dot — task has uncommitted changes
181
+ - **CLAUDE.md indicator** `◆`: Blue diamond — task has a CLAUDE.md file
182
+ - **Claude session status**: `⟳` running (magenta), `!` waiting (yellow), `✓` ended (green)
181
183
  - **Sort mode**: Press `s` to cycle through sort modes (name ↑/↓, date ↑/↓, dirty/clean first)
182
184
 
183
185
  **Navigation:**
@@ -221,13 +223,24 @@ Shows detailed git status for the selected worktree:
221
223
 
222
224
  ### Visual Indicators
223
225
 
226
+ **Task & Worktree Status:**
227
+
224
228
  | Indicator | Meaning |
225
229
  |-----------|--------------------------------------------|
226
- | `●` | Task has uncommitted changes |
227
- | `✗ N` | N files staged or modified |
228
- | `↑N` | N commits ahead of remote (unpushed) |
229
- | `↓N` | N commits behind remote (need to pull) |
230
- | `✓` | Clean worktree, in sync |
230
+ | `●` | Task/worktree has uncommitted changes |
231
+ | `✗ N` | N files staged or modified |
232
+ | `↑N` | N commits ahead of remote (unpushed) |
233
+ | `↓N` | N commits behind remote (need to pull) |
234
+ | `✓` | Clean worktree, in sync |
235
+
236
+ **Claude Code Integration:**
237
+
238
+ | Indicator | Color | Meaning |
239
+ |-----------|---------|------------------------------------------|
240
+ | `◆` | Blue | Task/worktree has a CLAUDE.md file |
241
+ | `⟳` | Magenta | Claude Code session is running |
242
+ | `!` | Yellow | Claude Code session is waiting for input |
243
+ | `✓` | Green | Claude Code session has ended |
231
244
 
232
245
  ## Complete Keyboard Reference
233
246
 
@@ -318,12 +331,26 @@ All keybindings can be customized in [`config.toml`](configuration.md).
318
331
  - When focused on task list: Opens the task folder (`~/tasks/TASK-NAME`)
319
332
  - When focused on worktree list: Opens the selected worktree folder
320
333
 
334
+ ### Claude Code Integration
335
+
336
+ | Key | Action | Description |
337
+ |---------|-------------------------|----------------------------------------------|
338
+ | `c` | Open Claude (resume) | Resume Claude Code session in task directory |
339
+ | `C` | Open Claude (new) | Start new Claude Code session in task directory |
340
+
341
+ **How it works:**
342
+ - Opens Claude Code in a new Ghostty terminal tab at the task directory
343
+ - Generates `CLAUDE.md` files with task/worktree context
344
+ - Installs hooks that report session status back to tasktree-manager
345
+ - Session status (`⟳` running, `!` waiting, `✓` ended) appears next to the task name
346
+
321
347
  ### General
322
348
 
323
349
  | Key | Action | Description |
324
350
  |---------|-------------------------|----------------------------------------------|
325
351
  | `Ctrl+P`| Open Command Palette | Switch themes and run commands |
326
352
  | `o` | Open folder | Open current folder in new terminal tab |
353
+ | `m` | Toggle messages | Show/hide the activity messages panel |
327
354
  | `?` | Show help | Display help modal with keybindings |
328
355
  | `q` | Quit | Exit tasktree-manager |
329
356
 
@@ -696,7 +723,7 @@ gh pr create --head FEAT-123
696
723
  ### Naming Conventions
697
724
 
698
725
  **Task names:**
699
- - Use issue tracker IDs: `JIRA-1234`, `DIC-1813`
726
+ - Use issue tracker IDs: `JIRA-1234`, `PROJ-567`
700
727
  - Or descriptive names: `feat-auth`, `bugfix-login`, `refactor-api`
701
728
  - Avoid spaces: Use `-` or `_`
702
729
  - 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.2.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.2.0'
32
+ __version_tuple__ = version_tuple = (2, 2, 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,13 @@ 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
+
341
+ # Periodic auto-refresh of git status
342
+ if self.config.refresh_interval > 0:
343
+ self.set_interval(self.config.refresh_interval, self._periodic_git_refresh)
344
+
333
345
  def watch_theme(self, theme: str) -> None:
334
346
  """Save theme to config when changed."""
335
347
  if hasattr(self, "config") and self.config.theme != theme:
@@ -420,6 +432,62 @@ class TaskTreeApp(App):
420
432
  finally:
421
433
  task_list.loading = False
422
434
 
435
+ def _poll_claude_statuses(self) -> None:
436
+ """Check .claude_status files and update task indicators."""
437
+ try:
438
+ task_list = self.query_one("#task-list", TaskList)
439
+ except Exception:
440
+ return
441
+
442
+ statuses: dict[str, str] = {}
443
+ for task in task_list.tasks:
444
+ status_file = task.path / ".claude_status"
445
+ if status_file.exists():
446
+ try:
447
+ data = json.loads(status_file.read_text())
448
+ statuses[task.name] = data.get("status", "unknown")
449
+ except (json.JSONDecodeError, OSError):
450
+ pass
451
+
452
+ if statuses != self._claude_statuses:
453
+ self._claude_statuses = statuses
454
+ task_list.refresh_claude_indicators(statuses)
455
+
456
+ def _periodic_git_refresh(self) -> None:
457
+ """Trigger a background git status refresh."""
458
+ self._run_periodic_refresh()
459
+
460
+ @work(thread=True, exclusive=True, group="auto_refresh")
461
+ def _run_periodic_refresh(self) -> None:
462
+ """Refresh git status in background thread."""
463
+ tasks = self.task_manager.list_tasks()
464
+ all_worktrees = [wt for task in tasks for wt in task.worktrees]
465
+ GitOps.update_all_worktree_statuses(all_worktrees)
466
+
467
+ current_task_name = self.current_task.name if self.current_task else None
468
+ current_worktree_name = self.current_worktree.name if self.current_worktree else None
469
+
470
+ def _apply():
471
+ try:
472
+ task_list = self.query_one("#task-list", TaskList)
473
+ if current_worktree_name:
474
+ self._preserved_worktree_name = current_worktree_name
475
+ task_list.load_tasks(tasks, preserve_selection=current_task_name)
476
+
477
+ if self.current_task:
478
+ for t in tasks:
479
+ if t.name == self.current_task.name:
480
+ self.current_task = t
481
+ worktree_list = self.query_one("#worktree-list", WorktreeList)
482
+ worktree_list.load_worktrees(
483
+ t.worktrees, preserve_selection=current_worktree_name
484
+ )
485
+ break
486
+ except Exception:
487
+ pass
488
+
489
+ self.call_from_thread(_apply)
490
+
423
491
  def _refresh_current_task(self, preserve_selection: bool = False) -> None:
424
492
  """Refresh the current task's worktrees and status.
425
493
 
@@ -469,6 +537,26 @@ class TaskTreeApp(App):
469
537
  self.notify(f"Failed to run {name}: {e}", severity="error")
470
538
  return False
471
539
 
540
+ def on_descendant_focus(self, event) -> None:
541
+ """Handle focus changes to update info panel mode."""
542
+ try:
543
+ status_panel = self.query_one("#status-display", StatusPanel)
544
+ except Exception:
545
+ return
546
+
547
+ if isinstance(event.widget, TaskList):
548
+ # Task list focused → show task changed files
549
+ if self.current_task:
550
+ status_panel.set_loading(True)
551
+ self._update_task_summary(self.current_task)
552
+ elif isinstance(event.widget, WorktreeList):
553
+ # Worktree list focused → show worktree details
554
+ if self.current_worktree and self.current_status:
555
+ status_panel.update_status(self.current_worktree, self.current_status)
556
+ elif self.current_worktree:
557
+ status_panel.set_loading(True)
558
+ self._update_worktree_status(self.current_worktree)
559
+
472
560
  def on_task_list_task_highlighted(self, event: TaskList.TaskHighlighted) -> None:
473
561
  """Handle task highlight in task list."""
474
562
  self.current_task = event.task
@@ -484,6 +572,12 @@ class TaskTreeApp(App):
484
572
  preserved = self._preserved_worktree_name
485
573
  self._preserved_worktree_name = None # Clear after use
486
574
  worktree_list.load_worktrees(event.task.worktrees, preserve_selection=preserved)
575
+
576
+ # Show task summary if task list is focused
577
+ task_list = self.query_one("#task-list", TaskList)
578
+ if task_list.has_focus:
579
+ status_panel.set_loading(True)
580
+ self._update_task_summary(event.task)
487
581
  else:
488
582
  worktree_list.clear_worktrees()
489
583
  status_panel.clear_status()
@@ -499,12 +593,19 @@ class TaskTreeApp(App):
499
593
  # Widget not mounted yet during app startup
500
594
  return
501
595
 
596
+ # Only update status panel if worktree list is focused
597
+ worktree_list = self.query_one("#worktree-list", WorktreeList)
502
598
  if event.worktree:
503
- status_panel.set_loading(True)
504
- self._update_worktree_status(event.worktree)
599
+ if worktree_list.has_focus:
600
+ status_panel.set_loading(True)
601
+ self._update_worktree_status(event.worktree)
602
+ else:
603
+ # Still fetch status in background for when user switches focus
604
+ self._update_worktree_status(event.worktree)
505
605
  else:
506
606
  self.current_status = None
507
- status_panel.clear_status()
607
+ if worktree_list.has_focus:
608
+ status_panel.clear_status()
508
609
 
509
610
  @work(thread=True, exclusive=True, group="status_update")
510
611
  def _update_worktree_status(self, worktree: Worktree) -> None:
@@ -520,7 +621,31 @@ class TaskTreeApp(App):
520
621
  self.current_status = status
521
622
  try:
522
623
  status_panel = self.query_one("#status-display", StatusPanel)
523
- status_panel.update_status(worktree, status)
624
+ # Only show worktree status if worktree list is focused
625
+ worktree_list = self.query_one("#worktree-list", WorktreeList)
626
+ if worktree_list.has_focus:
627
+ status_panel.update_status(worktree, status)
628
+ except Exception:
629
+ pass
630
+
631
+ @work(thread=True, exclusive=True, group="task_summary_update")
632
+ def _update_task_summary(self, task: Task) -> None:
633
+ """Fetch git status for all dirty worktrees in background thread."""
634
+ statuses: dict[str, GitStatus] = {}
635
+ for wt in task.worktrees:
636
+ if wt.is_dirty:
637
+ statuses[wt.name] = GitOps.get_status(wt)
638
+ self.call_from_thread(self._apply_task_summary, task, statuses)
639
+
640
+ def _apply_task_summary(self, task: Task, statuses: dict[str, GitStatus]) -> None:
641
+ """Apply fetched task summary to the UI (called on main thread)."""
642
+ # Only update if this task is still selected and task list is focused
643
+ if self.current_task and self.current_task.name == task.name:
644
+ try:
645
+ status_panel = self.query_one("#status-display", StatusPanel)
646
+ task_list = self.query_one("#task-list", TaskList)
647
+ if task_list.has_focus:
648
+ status_panel.update_task_summary(task, statuses)
524
649
  except Exception:
525
650
  pass
526
651
 
@@ -607,8 +732,8 @@ class TaskTreeApp(App):
607
732
  except Exception as e:
608
733
  failed.append((repo, e))
609
734
 
610
- self._load_tasks()
611
- self._refresh_current_task()
735
+ self._load_tasks(preserve_selection=True)
736
+ self._refresh_current_task(preserve_selection=True)
612
737
 
613
738
  if added:
614
739
  self.notify(f"Added {len(added)} repo(s) to task")
@@ -918,7 +1043,7 @@ class TaskTreeApp(App):
918
1043
  self.query_one("#worktree-list", WorktreeList).focus()
919
1044
 
920
1045
  def action_open_claude_resume(self) -> None:
921
- """Open Claude Code with session list (claude -r) in the current task folder."""
1046
+ """Open Claude Code in a new Ghostty tab (resume session)."""
922
1047
  if not self.current_task:
923
1048
  self.notify("No task selected", severity="warning")
924
1049
  return
@@ -928,30 +1053,17 @@ class TaskTreeApp(App):
928
1053
  self.notify("Task directory not found", severity="error")
929
1054
  return
930
1055
 
931
- saved_task_name = self.current_task.name
932
- saved_worktree_name = self.current_worktree.name if self.current_worktree else None
933
-
934
1056
  # Fetch fresh task data to ensure worktrees are up-to-date
935
- fresh_task = self.task_manager.get_task(saved_task_name)
1057
+ fresh_task = self.task_manager.get_task(self.current_task.name)
936
1058
  if fresh_task:
937
- # Create claude.md files if they don't exist
938
1059
  self.task_manager.ensure_claude_md_files(fresh_task)
939
1060
 
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()
1061
+ ensure_claude_hooks(task_path)
1062
+ self._open_ghostty_tab(task_path, command=f"{self.config.claude_path} -r")
1063
+ self.notify("Opened Claude Code in new tab (resume)")
952
1064
 
953
1065
  def action_open_claude_new(self) -> None:
954
- """Open a new Claude Code session in the current task folder."""
1066
+ """Open a new Claude Code session in a new Ghostty tab."""
955
1067
  if not self.current_task:
956
1068
  self.notify("No task selected", severity="warning")
957
1069
  return
@@ -961,27 +1073,14 @@ class TaskTreeApp(App):
961
1073
  self.notify("Task directory not found", severity="error")
962
1074
  return
963
1075
 
964
- saved_task_name = self.current_task.name
965
- saved_worktree_name = self.current_worktree.name if self.current_worktree else None
966
-
967
1076
  # Fetch fresh task data to ensure worktrees are up-to-date
968
- fresh_task = self.task_manager.get_task(saved_task_name)
1077
+ fresh_task = self.task_manager.get_task(self.current_task.name)
969
1078
  if fresh_task:
970
- # Create claude.md files if they don't exist
971
1079
  self.task_manager.ensure_claude_md_files(fresh_task)
972
1080
 
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()
1081
+ ensure_claude_hooks(task_path)
1082
+ self._open_ghostty_tab(task_path, command=self.config.claude_path)
1083
+ self.notify("Opened new Claude Code session in new tab")
985
1084
 
986
1085
  def action_open_folder(self) -> None:
987
1086
  """Open current folder in a new terminal tab."""
@@ -1006,15 +1105,23 @@ class TaskTreeApp(App):
1006
1105
  # Open in new terminal tab
1007
1106
  self._open_ghostty_tab(folder_path)
1008
1107
 
1009
- def _open_ghostty_tab(self, path) -> None:
1010
- """Open a new terminal tab at the given path.
1108
+ def _open_ghostty_tab(self, path, command: str | None = None) -> None:
1109
+ """Open a new terminal tab at the given path, optionally running a command.
1011
1110
 
1012
1111
  NOTE: This implementation is specific to the Ghostty terminal emulator
1013
1112
  on macOS, using AppleScript. It will silently fail on other terminals.
1113
+
1114
+ Args:
1115
+ path: Directory to cd into in the new tab
1116
+ command: Optional command to run after cd (e.g., "claude -r")
1014
1117
  """
1015
1118
  # Ghostty: Use AppleScript to activate and open new tab, then cd
1016
1119
  # Escape single quotes for AppleScript string interpolation
1017
1120
  safe_path = str(path).replace("'", "'\\''")
1121
+ if command:
1122
+ shell_cmd = f"cd '{safe_path}' && clear && {command}"
1123
+ else:
1124
+ shell_cmd = f"cd '{safe_path}' && clear"
1018
1125
  script = f"""
1019
1126
  tell application "Ghostty"
1020
1127
  activate
@@ -1023,7 +1130,7 @@ class TaskTreeApp(App):
1023
1130
  tell process "Ghostty"
1024
1131
  keystroke "t" using command down
1025
1132
  delay 0.1
1026
- keystroke "cd '{safe_path}' && clear"
1133
+ keystroke "{shell_cmd}"
1027
1134
  key code 36
1028
1135
  end tell
1029
1136
  end tell
@@ -1061,7 +1168,7 @@ class TaskTreeApp(App):
1061
1168
  task_name,
1062
1169
  )
1063
1170
 
1064
- self._refresh_current_task()
1171
+ self._refresh_current_task(preserve_selection=True)
1065
1172
  finally:
1066
1173
  worktree_list.loading = False
1067
1174
 
@@ -1096,14 +1203,14 @@ class TaskTreeApp(App):
1096
1203
  task_name,
1097
1204
  )
1098
1205
 
1099
- self._refresh_current_task()
1206
+ self._refresh_current_task(preserve_selection=True)
1100
1207
  finally:
1101
1208
  worktree_list.loading = False
1102
1209
 
1103
1210
  def action_refresh(self) -> None:
1104
1211
  """Refresh all status."""
1105
- self._load_tasks()
1106
- self._refresh_current_task()
1212
+ self._load_tasks(preserve_selection=True)
1213
+ self._refresh_current_task(preserve_selection=True)
1107
1214
  self.notify("Refreshed")
1108
1215
 
1109
1216
  def action_toggle_messages(self) -> None:
@@ -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")
@@ -59,6 +59,7 @@ class Config:
59
59
  # UI settings
60
60
  theme: str = "textual-dark"
61
61
  show_hidden_files: bool = False
62
+ refresh_interval: int = 30 # Auto-refresh interval in seconds (0 = disabled)
62
63
 
63
64
  # Git settings
64
65
  default_base_branch: str = "main"
@@ -103,7 +104,8 @@ class Config:
103
104
  Priority: Environment variables > Config file > Defaults
104
105
  """
105
106
  config_dir = (
106
- Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "tasktree-manager"
107
+ Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
108
+ / "tasktree-manager"
107
109
  )
108
110
  config_file = config_dir / "config.toml"
109
111
 
@@ -122,6 +124,7 @@ class Config:
122
124
  ui_config = config_data.get("ui", {})
123
125
  theme = ui_config.get("theme", "textual-dark")
124
126
  show_hidden_files = ui_config.get("show_hidden_files", False)
127
+ refresh_interval = ui_config.get("refresh_interval", 30)
125
128
 
126
129
  # Git settings
127
130
  git_config = config_data.get("git", {})
@@ -185,6 +188,7 @@ class Config:
185
188
  config_dir=config_dir,
186
189
  theme=theme,
187
190
  show_hidden_files=show_hidden_files,
191
+ refresh_interval=refresh_interval,
188
192
  default_base_branch=default_base_branch,
189
193
  auto_push=auto_push,
190
194
  git_timeout=git_timeout,
@@ -286,6 +290,9 @@ theme = "{self._toml_escape(self.theme)}"
286
290
  # Show hidden files in file listings
287
291
  show_hidden_files = {str(self.show_hidden_files).lower()}
288
292
 
293
+ # Auto-refresh interval in seconds (0 = disabled)
294
+ refresh_interval = {self.refresh_interval}
295
+
289
296
  # ============================================================================
290
297
  # Git Settings
291
298
  # ============================================================================
@@ -190,6 +190,13 @@ class GitOps:
190
190
  True if current branch is merged into base_branch, False otherwise.
191
191
  """
192
192
  try:
193
+ # Fetch latest remote refs so we detect merges done via GitLab/GitHub UI
194
+ subprocess.run(
195
+ ["git", "fetch", "origin", base_branch],
196
+ cwd=worktree.path,
197
+ capture_output=True,
198
+ timeout=10,
199
+ )
193
200
  # Use git merge-base --is-ancestor to check if HEAD is reachable from base
194
201
  # This checks if the current branch has been merged
195
202
  result = subprocess.run(
@@ -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: