tasktree-manager 2.5.0__tar.gz → 2.6.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 (60) hide show
  1. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/README.md +9 -7
  4. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/configuration.md +29 -0
  5. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/user-guide.md +39 -4
  6. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/app.py +101 -1
  9. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/commands.py +5 -0
  10. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/claude_hooks.py +9 -1
  11. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/config.py +16 -0
  12. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/git_ops.py +70 -0
  13. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/create_modal.py +62 -62
  14. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_app.py +54 -2
  15. tasktree_manager-2.6.0/tests/test_claude_hooks.py +106 -0
  16. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_config.py +16 -0
  17. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_git_ops.py +75 -0
  18. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_modals.py +103 -0
  19. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  20. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  21. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  22. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  23. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  24. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  25. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  26. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  27. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  28. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  29. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  30. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.github/workflows/ci.yml +0 -0
  31. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.github/workflows/release.yml +0 -0
  32. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.gitignore +0 -0
  33. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/CONTRIBUTING.md +0 -0
  34. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/LICENSE +0 -0
  35. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/OPENSPEC.md +0 -0
  36. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/ROADMAP.md +0 -0
  37. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/claude-code-integration-spec.md +0 -0
  38. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/installation.md +0 -0
  39. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/troubleshooting.md +0 -0
  40. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/mise.toml +0 -0
  41. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/scripts/bump_version.py +0 -0
  42. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/__init__.py +0 -0
  43. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/__init__.py +0 -0
  44. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/models.py +0 -0
  45. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/task_manager.py +0 -0
  46. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/__init__.py +0 -0
  47. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  48. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  49. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/status_panel.py +0 -0
  50. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/task_list.py +0 -0
  51. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  52. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/__init__.py +0 -0
  53. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/conftest.py +0 -0
  54. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_commands.py +0 -0
  55. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_integration_git.py +0 -0
  56. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_memory.py +0 -0
  57. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_messages_panel.py +0 -0
  58. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_setup_modal.py +0 -0
  59. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_status_panel.py +0 -0
  60. {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_task_manager.py +0 -0
@@ -15,6 +15,12 @@ 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.6.0] - 2026-07-13
19
+
20
+ ### Changed
21
+ - Hunk diff viewer, shared Claude memory pool, repo filter fix (#8)
22
+
23
+
18
24
  ## [2.5.0] - 2026-06-10
19
25
 
20
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -57,8 +57,9 @@ On first run, you'll configure:
57
57
 
58
58
  1. **Create a task** - Press `n`, enter task name, select repositories
59
59
  2. **Work on code** - Press `e` for editor, `g` for lazygit, or `Enter` for shell
60
- 3. **Push changes** - Press `p` to push all worktrees
61
- 4. **Delete task** - Press `d` when finished
60
+ 3. **Review changes** - Press `h` to view the diff in [hunk](https://github.com/modem-dev/hunk) (all repos from the task panel, one repo from the worktree panel)
61
+ 4. **Push changes** - Press `p` to push all worktrees
62
+ 5. **Delete task** - Press `d` when finished
62
63
 
63
64
  For detailed workflows and examples, see the [User Guide](docs/user-guide.md).
64
65
 
@@ -75,14 +76,15 @@ For detailed workflows and examples, see the [User Guide](docs/user-guide.md).
75
76
  | Key | Action | Key | Action |
76
77
  |-----|--------|-----|--------|
77
78
  | `n` | New task | `g` | Open lazygit |
78
- | `a` | Add repo | `e` | Open editor |
79
- | `d` | Delete task | `o` | Open folder |
80
- | `p` | Push all | `P` | Pull all |
79
+ | `a` | Add repo | `h` | Show diff (hunk) |
80
+ | `d` | Delete task | `e` | Open editor |
81
+ | `p` | Push all | `o` | Open folder |
82
+ | `P` | Pull all | `Enter` | Open shell |
81
83
  | `c` | Claude (resume) | `C` | Claude (new) |
82
84
  | `r` | Refresh | `s` | Sort tasks |
83
85
  | `S` | Group worktrees | `m` | Messages |
84
- | `Enter` | Open shell | `Tab` | Next panel |
85
- | `?` | Show help | `q` | Quit |
86
+ | `Tab` | Next panel | `?` | Show help |
87
+ | `q` | Quit | | |
86
88
 
87
89
  **Themes:** Press `Ctrl+P` to switch between 7 built-in themes (textual-dark, nord, gruvbox, tokyo-night, monokai, dracula, textual-light).
88
90
 
@@ -133,6 +133,25 @@ editor = ""
133
133
  # Default: "lazygit" (searches PATH)
134
134
  lazygit_path = "lazygit"
135
135
 
136
+ # Path to hunk executable (terminal diff viewer)
137
+ # https://github.com/modem-dev/hunk
138
+ # Default: "hunk" (searches PATH)
139
+ hunk_path = "hunk"
140
+
141
+ # Path to claude CLI executable
142
+ # Default: "claude" (searches PATH)
143
+ claude_path = "claude"
144
+
145
+ # Shared Claude Code auto-memory directory for task sessions.
146
+ # Task folders are not git repositories, so by default Claude Code would key
147
+ # its auto memory to the task path — memory that is orphaned when the task is
148
+ # deleted. tasktree-manager writes this path as "autoMemoryDirectory" into each
149
+ # task's .claude/settings.local.json so every task session reads and writes one
150
+ # shared memory pool that persists across tasks.
151
+ # Set to "" to disable (each task keeps its own throwaway memory).
152
+ # Default: "~/.claude/tasktree-memory"
153
+ claude_memory_dir = "~/.claude/tasktree-memory"
154
+
136
155
  # Preferred shell
137
156
  # Leave empty to use $SHELL environment variable
138
157
  # Default: "" (uses $SHELL, falls back to /bin/bash)
@@ -158,6 +177,7 @@ delete_task = "d" # Delete/finish task
158
177
 
159
178
  # Git operations
160
179
  open_lazygit = "g" # Open lazygit in selected worktree
180
+ show_diff = "h" # Open hunk diff (task panel = all repos, worktree panel = one repo)
161
181
  open_editor = "e" # Open editor in selected task/worktree
162
182
  open_folder = "o" # Open folder in new terminal tab
163
183
  open_shell = "enter" # Open shell in selected worktree
@@ -305,6 +325,7 @@ refresh_interval = 15 # Refresh every 15 seconds
305
325
  |---------------|--------|-------------|---------------------|--------------------------------------|
306
326
  | `editor` | string | `""` | `EDITOR` | Command to launch your editor |
307
327
  | `lazygit_path` | string | `"lazygit"` | - | Path to lazygit executable |
328
+ | `hunk_path` | string | `"hunk"` | - | Path to hunk diff-viewer executable |
308
329
  | `shell` | string | `""` | `SHELL` | Shell to use when opening terminals |
309
330
 
310
331
  ### Details
@@ -320,6 +341,13 @@ refresh_interval = 15 # Refresh every 15 seconds
320
341
  - Use absolute path for custom location: `"/opt/homebrew/bin/lazygit"`
321
342
  - Install lazygit: `brew install lazygit` (macOS), `apt install lazygit` (Ubuntu)
322
343
 
344
+ **`hunk_path`:**
345
+ - [hunk](https://github.com/modem-dev/hunk) is a review-first terminal diff viewer
346
+ - Default `"hunk"` searches PATH
347
+ - Use absolute path for custom location: `"/opt/homebrew/bin/hunk"`
348
+ - Install hunk: `brew install hunk` (macOS) or `npm i -g hunkdiff`
349
+ - Used when pressing `h`: from the task panel it opens a combined diff across all the task's repos; from the worktree panel it opens the selected repo's diff
350
+
323
351
  **`shell`:**
324
352
  - Leave empty (`""`) to use `$SHELL` environment variable
325
353
  - Fallback: `/bin/bash`
@@ -441,6 +469,7 @@ All 22 customizable keybindings with descriptions:
441
469
  | Action | Default | Description | Customization Example |
442
470
  |----------------|----------|---------------------------------------|-----------------------------|
443
471
  | `open_lazygit` | `g` | Open lazygit in selected worktree | `open_lazygit = "ctrl+g"` |
472
+ | `show_diff` | `h` | Open hunk diff (task = all repos, worktree = one) | `show_diff = "ctrl+h"` |
444
473
  | `open_editor` | `e` | Open editor in task/worktree folder | `open_editor = "ctrl+e"` |
445
474
  | `open_folder` | `o` | Open folder in new terminal tab | `open_folder = "ctrl+o"` |
446
475
  | `open_shell` | `enter` | Open shell in selected worktree | `open_shell = "ctrl+t"` |
@@ -134,17 +134,22 @@ A **worktree** is a git working directory for a specific repository within a tas
134
134
  - Stage files, commit changes, resolve conflicts
135
135
  - Exit lazygit (press `q` in lazygit)
136
136
 
137
- 3. **Or open a shell:**
137
+ 3. **Review the diff:**
138
+ - Press `h` from the worktree panel to review that repo's changes in [hunk](https://github.com/modem-dev/hunk)
139
+ - Press `h` from the task panel to review a combined diff across **all** the task's repos at once
140
+ - Exit hunk (press `q`)
141
+
142
+ 4. **Or open a shell:**
138
143
  - Press `Enter` to open a shell in the worktree
139
144
  - Make changes with your editor/IDE
140
145
  - Run build/test commands
141
146
  - Exit shell (type `exit`)
142
147
 
143
- 4. **Push changes:**
148
+ 5. **Push changes:**
144
149
  - Press `p` to push all worktrees in the task
145
150
  - Or use lazygit (`g`) to push individually
146
151
 
147
- 5. **Delete the task:**
152
+ 6. **Delete the task:**
148
153
  - Press `d` to delete/finish the task
149
154
  - Follow prompts if there are uncommitted or unpushed changes
150
155
  - Worktrees are removed (branches remain on remote)
@@ -168,7 +173,7 @@ tasktree-manager uses a **3-panel layout**:
168
173
  │ M src/auth.py │
169
174
  │ ?? README.md │
170
175
  └──────────────────────────────────────────────────────────────────────────┘
171
- n New d Delete g Lazygit p Push r Refresh ? Help q Quit
176
+ n New d Delete g Lazygit h Diff p Push r Refresh ? Help q Quit
172
177
  ```
173
178
 
174
179
  ### Left Panel: Task List
@@ -325,6 +330,7 @@ Useful when you're spinning up several similar tasks that share the same set of
325
330
  | Key | Action | Description |
326
331
  |---------|--------------------|------------------------------------------------------|
327
332
  | `g` | Open lazygit | Open lazygit in the selected worktree |
333
+ | `h` | Show diff | Open hunk: all repos from the task panel, the selected repo from the worktree panel |
328
334
  | `e` | Open editor | Open editor in task/worktree folder (runs `$EDITOR .`) |
329
335
  | `Enter` | Open shell | Open a shell in the selected worktree |
330
336
  | `p` | Push all | Push all worktrees in the current task (parallel) |
@@ -359,6 +365,7 @@ Useful when you're spinning up several similar tasks that share the same set of
359
365
  - `C` opens the Claude desktop app via the `claude://code/new?folder=<task-folder>` URL scheme.
360
366
  - The CLI variant (`c`) generates `CLAUDE.md` files with task/worktree context and installs hooks that report session status back to tasktree-manager.
361
367
  - Session status (`⟳` running, `!` waiting, `✓` ended) appears next to the task name
368
+ - Both variants point the session's Claude Code auto-memory at a shared pool (`claude_memory_dir`, default `~/.claude/tasktree-memory`). Task folders are not git repos, so without this each task would get its own memory directory that is orphaned when the task is deleted; with the shared pool, what Claude learns in one task carries over to future tasks.
362
369
 
363
370
  ### General
364
371
 
@@ -666,6 +673,34 @@ apt install lazygit
666
673
  # See: https://github.com/jesseduffield/lazygit
667
674
  ```
668
675
 
676
+ ### Hunk
677
+
678
+ [hunk](https://github.com/modem-dev/hunk) is a review-first terminal diff viewer, well suited to reviewing agent-authored changesets across a multi-repo task.
679
+
680
+ **Why hunk:**
681
+ - Fast, keyboard-driven diff review with a desktop-inspired layout
682
+ - Reviews an entire task's changes across all its repos in one view
683
+ - Complements lazygit (which stages/commits per repo)
684
+
685
+ **Using hunk:**
686
+ 1. From the **worktree panel**, press `h` to review the selected repo's uncommitted changes
687
+ 2. From the **task panel**, press `h` to review a combined diff across **all** the task's repos at once (each file is grouped under its repo name)
688
+ 3. tasktree-manager suspends, hunk opens
689
+ 4. Press `q` in hunk to return; status auto-refreshes and selection is preserved
690
+
691
+ The combined task view includes both tracked (staged and unstaged) changes and untracked files.
692
+
693
+ **Install hunk:**
694
+ ```bash
695
+ # macOS
696
+ brew install hunk
697
+
698
+ # npm
699
+ npm i -g hunkdiff
700
+
701
+ # See: https://github.com/modem-dev/hunk
702
+ ```
703
+
669
704
  ### Editors
670
705
 
671
706
  **Direct editor integration:**
@@ -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.5.0"
73
+ version = "2.6.0"
74
74
  tag_format = "v{version}"
75
75
  branch = "main"
76
76
  commit_message = "chore(release): {version} [skip ci]"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.5.0'
22
- __version_tuple__ = version_tuple = (2, 5, 0)
21
+ __version__ = version = '2.6.0'
22
+ __version_tuple__ = version_tuple = (2, 6, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -226,6 +226,7 @@ class TaskTreeApp(App):
226
226
  Binding("a", "add_repo", "Add Repo", show=False),
227
227
  Binding("d", "delete_task", "Delete", show=False),
228
228
  Binding("g", "open_lazygit", "Lazygit", show=False),
229
+ Binding("h", "show_diff", "Diff", show=False),
229
230
  Binding("e", "open_editor", "Editor", show=False),
230
231
  Binding("o", "open_folder", "Open", show=False),
231
232
  Binding("c", "open_claude_resume", "Claude", show=False),
@@ -287,6 +288,7 @@ class TaskTreeApp(App):
287
288
  Binding(kb.get("add_repo", "a"), "add_repo", "Add Repo", show=False),
288
289
  Binding(kb.get("delete_task", "d"), "delete_task", "Delete", show=False),
289
290
  Binding(kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit", show=False),
291
+ Binding(kb.get("show_diff", "h"), "show_diff", "Diff", show=False),
290
292
  Binding(kb.get("open_editor", "e"), "open_editor", "Editor", show=False),
291
293
  Binding(kb.get("open_folder", "o"), "open_folder", "Open", show=False),
292
294
  Binding(kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude", show=False),
@@ -330,11 +332,13 @@ class TaskTreeApp(App):
330
332
  (kb.get("clone_task", "y"), "clone_task", "Clone"),
331
333
  (kb.get("add_repo", "a"), "add_repo", "Add Repo"),
332
334
  (kb.get("delete_task", "d"), "delete_task", "Delete"),
335
+ (kb.get("show_diff", "h"), "show_diff", "Diff"),
333
336
  (kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
334
337
  (kb.get("cycle_sort", "s"), "cycle_sort", "Sort"),
335
338
  ]
336
339
  worktree_panel_bindings = [
337
340
  (kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit"),
341
+ (kb.get("show_diff", "h"), "show_diff", "Diff"),
338
342
  (kb.get("open_editor", "e"), "open_editor", "Editor"),
339
343
  (kb.get("open_folder", "o"), "open_folder", "Open"),
340
344
  (kb.get("open_shell", "enter"), "open_shell", "Shell"),
@@ -1111,6 +1115,101 @@ class TaskTreeApp(App):
1111
1115
  # Restore focus to worktree list
1112
1116
  self.query_one("#worktree-list", WorktreeList).focus()
1113
1117
 
1118
+ def action_show_diff(self) -> None:
1119
+ """Open the hunk diff viewer, scoped by which panel is focused.
1120
+
1121
+ From the task panel, shows a combined diff across all the task's repos.
1122
+ From the worktree panel, shows the diff for the selected worktree only.
1123
+ """
1124
+ focused = self.focused
1125
+ if isinstance(focused, TaskList):
1126
+ self._show_task_diff()
1127
+ elif isinstance(focused, WorktreeList):
1128
+ self._show_worktree_diff()
1129
+ else:
1130
+ self.notify("Select a task or worktree first", severity="warning")
1131
+
1132
+ def _show_worktree_diff(self) -> None:
1133
+ """Open hunk on the selected worktree's working-tree changes."""
1134
+ if not self.current_worktree:
1135
+ self.notify("No worktree selected", severity="warning")
1136
+ return
1137
+
1138
+ worktree_path = self.current_worktree.path
1139
+ if not worktree_path.exists():
1140
+ self.notify("Worktree directory not found", severity="error")
1141
+ return
1142
+
1143
+ # Save selection state BEFORE suspend (as local variables)
1144
+ saved_task_name = self.current_task.name if self.current_task else None
1145
+ saved_worktree_name = self.current_worktree.name
1146
+
1147
+ self.notify("Opening hunk...")
1148
+
1149
+ # Suspend app and run hunk on this worktree's changes
1150
+ with self.suspend():
1151
+ self._run_external_command(
1152
+ [self.config.hunk_path, "diff"],
1153
+ cwd=worktree_path,
1154
+ name="hunk",
1155
+ install_hint="brew install hunk",
1156
+ )
1157
+
1158
+ # Refresh status after hunk exits, restoring saved selection
1159
+ self._load_tasks_with_selection(saved_task_name, saved_worktree_name)
1160
+ # Restore focus to worktree list
1161
+ self.query_one("#worktree-list", WorktreeList).focus()
1162
+
1163
+ def _show_task_diff(self) -> None:
1164
+ """Open hunk on a combined diff across all of the task's repos."""
1165
+ import os
1166
+ import tempfile
1167
+
1168
+ if not self.current_task:
1169
+ self.notify("No task selected", severity="warning")
1170
+ return
1171
+
1172
+ task = self.current_task
1173
+
1174
+ # Building the diff runs local git commands only (no network), so a
1175
+ # brief synchronous call before suspend is fine. The fresh git read
1176
+ # also makes the "no changes" check accurate regardless of stale UI.
1177
+ diff = GitOps.build_task_diff(task)
1178
+ if not diff.strip():
1179
+ self.notify("No changes to show", severity="information")
1180
+ return
1181
+
1182
+ # Save selection state BEFORE suspend (as local variables)
1183
+ saved_task_name = task.name
1184
+ saved_worktree_name = self.current_worktree.name if self.current_worktree else None
1185
+
1186
+ # hunk reads the combined patch from a file, so keyboard input stays on
1187
+ # the terminal (a stdin pipe would fight the interactive TUI for it).
1188
+ with tempfile.NamedTemporaryFile("w", suffix=".patch", delete=False, encoding="utf-8") as f:
1189
+ f.write(diff)
1190
+ patch_path = f.name
1191
+
1192
+ self.notify("Opening hunk...")
1193
+
1194
+ try:
1195
+ with self.suspend():
1196
+ self._run_external_command(
1197
+ [self.config.hunk_path, "patch", patch_path],
1198
+ cwd=task.path,
1199
+ name="hunk",
1200
+ install_hint="brew install hunk",
1201
+ )
1202
+ finally:
1203
+ try:
1204
+ os.unlink(patch_path)
1205
+ except OSError:
1206
+ pass
1207
+
1208
+ # Refresh status after hunk exits, restoring saved selection
1209
+ self._load_tasks_with_selection(saved_task_name, saved_worktree_name)
1210
+ # Restore focus to task list
1211
+ self.query_one("#task-list", TaskList).focus()
1212
+
1114
1213
  def action_open_shell(self) -> None:
1115
1214
  """Open a shell in the current worktree."""
1116
1215
  if not self.current_worktree:
@@ -1199,7 +1298,7 @@ class TaskTreeApp(App):
1199
1298
  if fresh_task:
1200
1299
  self.task_manager.ensure_claude_md_files(fresh_task)
1201
1300
 
1202
- ensure_claude_hooks(task_path)
1301
+ ensure_claude_hooks(task_path, self.config.claude_memory_dir)
1203
1302
  if has_claude_session(task_path):
1204
1303
  self._open_ghostty_tab(task_path, command=f"{self.config.claude_path} -r")
1205
1304
  self.notify("Opened Claude Code in new tab (resume)")
@@ -1220,6 +1319,7 @@ class TaskTreeApp(App):
1220
1319
  self.notify("Task directory not found", severity="error")
1221
1320
  return
1222
1321
 
1322
+ ensure_claude_hooks(task_path, self.config.claude_memory_dir)
1223
1323
  encoded_path = quote(str(task_path), safe="")
1224
1324
  url = f"claude://code/new?folder={encoded_path}"
1225
1325
  try:
@@ -14,6 +14,11 @@ APP_COMMANDS: list[tuple[str, str, str]] = [
14
14
  ("delete_task", "Delete Task", "Delete the current task (with safety checks)"),
15
15
  ("delete_worktree", "Delete Worktree", "Delete the selected worktree from the task"),
16
16
  ("open_lazygit", "Open Lazygit", "Open lazygit in the selected worktree"),
17
+ (
18
+ "show_diff",
19
+ "Show Diff",
20
+ "Open hunk: all repos from the task panel, the selected repo from the worktree panel",
21
+ ),
17
22
  ("open_editor", "Open Editor", "Open your editor in the focused task/worktree folder"),
18
23
  ("open_folder", "Open Folder", "Open the focused folder in a new terminal tab"),
19
24
  ("open_shell", "Open Shell", "Open a shell in the selected worktree"),
@@ -38,11 +38,16 @@ def _build_hooks_config(task_path: Path) -> dict:
38
38
  }
39
39
 
40
40
 
41
- def ensure_claude_hooks(task_path: Path) -> None:
41
+ def ensure_claude_hooks(task_path: Path, memory_dir: str = "") -> None:
42
42
  """Create .claude/settings.local.json with status-reporting hooks.
43
43
 
44
44
  Merges with existing settings if the file already exists, preserving
45
45
  user's own configuration while adding/updating the status hooks.
46
+
47
+ Task folders are not git repositories, so Claude Code keys its auto
48
+ memory to the task path — memory that becomes orphaned when the task
49
+ is deleted. When memory_dir is set, autoMemoryDirectory redirects all
50
+ task sessions to one shared pool that persists across tasks.
46
51
  """
47
52
  claude_dir = task_path / ".claude"
48
53
  claude_dir.mkdir(exist_ok=True)
@@ -60,4 +65,7 @@ def ensure_claude_hooks(task_path: Path) -> None:
60
65
  # Merge hooks into existing settings
61
66
  existing["hooks"] = _build_hooks_config(task_path)
62
67
 
68
+ if memory_dir:
69
+ existing["autoMemoryDirectory"] = str(Path(memory_dir).expanduser())
70
+
63
71
  settings_file.write_text(json.dumps(existing, indent=2) + "\n")
@@ -24,6 +24,7 @@ DEFAULT_KEYBINDINGS: dict[str, str] = {
24
24
  "add_repo": "a",
25
25
  "delete_task": "d",
26
26
  "open_lazygit": "g",
27
+ "show_diff": "h",
27
28
  "open_folder": "o",
28
29
  "open_shell": "enter",
29
30
  "open_editor": "e",
@@ -90,7 +91,9 @@ class Config:
90
91
  # External tools
91
92
  editor: str = ""
92
93
  lazygit_path: str = "lazygit"
94
+ hunk_path: str = "hunk"
93
95
  claude_path: str = "claude"
96
+ claude_memory_dir: str = "~/.claude/tasktree-memory"
94
97
  shell: str = ""
95
98
 
96
99
  # Keybindings (action -> key mapping)
@@ -144,7 +147,9 @@ class Config:
144
147
  tools_config = config_data.get("tools", {})
145
148
  editor = tools_config.get("editor", "")
146
149
  lazygit_path = tools_config.get("lazygit_path", "lazygit")
150
+ hunk_path = tools_config.get("hunk_path", "hunk")
147
151
  claude_path = tools_config.get("claude_path", "claude")
152
+ claude_memory_dir = tools_config.get("claude_memory_dir", "~/.claude/tasktree-memory")
148
153
  shell = tools_config.get("shell", "")
149
154
 
150
155
  # Keybindings - start with defaults and override with config
@@ -184,7 +189,9 @@ class Config:
184
189
  git_timeout=git_timeout,
185
190
  editor=editor,
186
191
  lazygit_path=lazygit_path,
192
+ hunk_path=hunk_path,
187
193
  claude_path=claude_path,
194
+ claude_memory_dir=claude_memory_dir,
188
195
  shell=shell,
189
196
  keybindings=keybindings,
190
197
  symlink_blocklist=symlink_blocklist,
@@ -308,9 +315,17 @@ editor = "{self._toml_escape(self.editor)}"
308
315
  # Path to lazygit executable
309
316
  lazygit_path = "{self._toml_escape(self.lazygit_path)}"
310
317
 
318
+ # Path to hunk executable (terminal diff viewer, see https://github.com/modem-dev/hunk)
319
+ hunk_path = "{self._toml_escape(self.hunk_path)}"
320
+
311
321
  # Path to claude CLI executable
312
322
  claude_path = "{self._toml_escape(self.claude_path)}"
313
323
 
324
+ # Shared Claude Code auto-memory directory for task sessions.
325
+ # Task folders are not git repos, so without this each task gets its own
326
+ # memory that is orphaned when the task is deleted. Set to "" to disable.
327
+ claude_memory_dir = "{self._toml_escape(self.claude_memory_dir)}"
328
+
314
329
  # Preferred shell (leave empty to use $SHELL)
315
330
  shell = "{self._toml_escape(self.shell)}"
316
331
 
@@ -328,6 +343,7 @@ clone_task = "{self._toml_escape(self.keybindings.get("clone_task", "y"))}"
328
343
  add_repo = "{self._toml_escape(self.keybindings.get("add_repo", "a"))}"
329
344
  delete_task = "{self._toml_escape(self.keybindings.get("delete_task", "d"))}"
330
345
  open_lazygit = "{self._toml_escape(self.keybindings.get("open_lazygit", "g"))}"
346
+ show_diff = "{self._toml_escape(self.keybindings.get("show_diff", "h"))}"
331
347
  open_folder = "{self._toml_escape(self.keybindings.get("open_folder", "o"))}"
332
348
  open_shell = "{self._toml_escape(self.keybindings.get("open_shell", "enter"))}"
333
349
  open_editor = "{self._toml_escape(self.keybindings.get("open_editor", "e"))}"
@@ -159,6 +159,76 @@ class GitOps:
159
159
  except (subprocess.SubprocessError, OSError) as e:
160
160
  return False, str(e)
161
161
 
162
+ @staticmethod
163
+ def _git_stdout(worktree: Worktree, args: list[str]) -> str:
164
+ """Run a local read-only git command in the worktree, returning stdout.
165
+
166
+ Returns "" on any failure. The exit code is intentionally ignored:
167
+ ``git diff --no-index`` returns non-zero whenever files differ, and a
168
+ failed command leaves stdout empty anyway.
169
+ """
170
+ try:
171
+ result = subprocess.run(
172
+ ["git", *args],
173
+ cwd=worktree.path,
174
+ capture_output=True,
175
+ text=True,
176
+ timeout=GitOps.LOCAL_TIMEOUT,
177
+ )
178
+ except (subprocess.SubprocessError, OSError):
179
+ return ""
180
+ return result.stdout
181
+
182
+ @staticmethod
183
+ def _list_untracked(worktree: Worktree) -> list[str]:
184
+ """List untracked (but not ignored) files in a worktree."""
185
+ out = GitOps._git_stdout(worktree, ["ls-files", "--others", "--exclude-standard", "-z"])
186
+ return [f for f in out.split("\0") if f]
187
+
188
+ @staticmethod
189
+ def get_worktree_diff(worktree: Worktree, label: str | None = None) -> str:
190
+ """Return a unified diff of all uncommitted changes in a worktree.
191
+
192
+ Combines staged and unstaged tracked changes (``git diff HEAD``) with
193
+ untracked files, so the result mirrors what a working-tree review shows.
194
+ When *label* is given, every file path is prefixed with ``<label>/`` so
195
+ diffs from several repos can be concatenated into one view without
196
+ colliding on identical relative paths.
197
+
198
+ Returns an empty string when the worktree is clean or missing.
199
+ """
200
+ if not worktree.path.exists():
201
+ return ""
202
+
203
+ # Without a label, git's default a/ b/ prefixes are used.
204
+ prefixes = [f"--src-prefix=a/{label}/", f"--dst-prefix=b/{label}/"] if label else []
205
+ parts: list[str] = []
206
+
207
+ # Tracked changes (staged + unstaged) relative to HEAD.
208
+ parts.append(GitOps._git_stdout(worktree, ["diff", "HEAD", "--no-color", *prefixes]))
209
+
210
+ # Untracked files, rendered as additions against /dev/null.
211
+ for untracked in GitOps._list_untracked(worktree):
212
+ parts.append(
213
+ GitOps._git_stdout(
214
+ worktree,
215
+ ["diff", "--no-index", "--no-color", *prefixes, "--", "/dev/null", untracked],
216
+ )
217
+ )
218
+
219
+ return "".join(parts)
220
+
221
+ @staticmethod
222
+ def build_task_diff(task: Task) -> str:
223
+ """Build a combined diff across all of a task's worktrees.
224
+
225
+ Each worktree's changes are labelled with its repo name so a single
226
+ review shows every repo without path collisions. Returns an empty
227
+ string when nothing in the task has changed.
228
+ """
229
+ parts = [GitOps.get_worktree_diff(wt, label=wt.name) for wt in task.worktrees]
230
+ return "".join(p for p in parts if p)
231
+
162
232
  @staticmethod
163
233
  def get_default_branch(worktree: Worktree) -> str:
164
234
  """Get the default branch (main/master) for a worktree's repo.