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.
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/PKG-INFO +1 -1
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/README.md +9 -7
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/configuration.md +29 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/user-guide.md +39 -4
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/pyproject.toml +1 -1
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/app.py +101 -1
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/commands.py +5 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/claude_hooks.py +9 -1
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/config.py +16 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/git_ops.py +70 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/create_modal.py +62 -62
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_app.py +54 -2
- tasktree_manager-2.6.0/tests/test_claude_hooks.py +106 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_config.py +16 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_git_ops.py +75 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_modals.py +103 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/.gitignore +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/LICENSE +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/OPENSPEC.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/installation.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/mise.toml +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/services/task_manager.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/status_panel.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/task_list.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.5.0 → tasktree_manager-2.6.0}/tests/test_status_panel.py +0 -0
- {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
|
|
@@ -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. **
|
|
61
|
-
4. **
|
|
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 | `
|
|
79
|
-
| `d` | Delete task | `
|
|
80
|
-
| `p` | Push 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
|
-
| `
|
|
85
|
-
|
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
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.
|