tasktree-manager 2.14.0__tar.gz → 2.15.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.14.0 → tasktree_manager-2.15.0}/CHANGELOG.md +6 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/PKG-INFO +1 -1
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/README.md +9 -4
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/configuration.md +3 -1
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/user-guide.md +26 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/pyproject.toml +1 -1
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/app.py +173 -17
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/config.py +2 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/models.py +39 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/task_manager.py +6 -3
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/create_modal.py +156 -6
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/status_panel.py +55 -1
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/task_list.py +139 -38
- tasktree_manager-2.15.0/tests/test_epic_tui.py +470 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_epics.py +12 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/.gitignore +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/LICENSE +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/installation.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/mise.toml +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/cli.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tests/test_worktree_badges.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.15.0] - 2026-10-19
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **epic**: Show epics as foldable groups in the task list
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
## [2.14.0] - 2026-10-19
|
|
19
25
|
|
|
20
26
|
### Added
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
- 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
|
|
23
23
|
- ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
|
|
24
24
|
- 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
|
|
25
|
+
- 🗂️ **Epics** - Group related tasks under an epic: a foldable row with rolled-up dirty and Claude state, `E` to attach tasks
|
|
25
26
|
- 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) and the task-folder files outside the worktrees (plans, ADRs, notes) to an archive before deleting
|
|
26
27
|
|
|
27
28
|
## 📊 Status Indicators
|
|
@@ -39,6 +40,8 @@
|
|
|
39
40
|
| `↓N` yellow | N commits behind remote |
|
|
40
41
|
| `○` green / `●` magenta / `×` red | MR open / merged / closed |
|
|
41
42
|
| `◐` yellow / `✔` green / `✘` red | CI running / passed / failed |
|
|
43
|
+
| `▾` / `▸` | Epic, unfolded / folded (`· N` = its task count) |
|
|
44
|
+
| `├` / `└` | Task inside the epic above |
|
|
42
45
|
|
|
43
46
|
## 🚀 Quick Start
|
|
44
47
|
|
|
@@ -141,7 +144,8 @@ a Claude Code statusline or shell prompt.
|
|
|
141
144
|
| `r` | Refresh | `s` | Sort tasks |
|
|
142
145
|
| `S` | Group worktrees | `m` | Messages |
|
|
143
146
|
| `t` | Cycle theme | `Tab` | Next panel |
|
|
144
|
-
| `R` | Rename task | | |
|
|
147
|
+
| `R` | Rename task | `E` | Set epic |
|
|
148
|
+
| `enter` | Fold epic | | |
|
|
145
149
|
| `?` | Show help | `q` | Quit |
|
|
146
150
|
|
|
147
151
|
**Themes:** The default `tasktree` theme pairs a lazygit-classic ANSI palette with layered panels, numbered title bars, green focus borders, and a keycap footer. Five more design-system palettes ship tuned to the same shape — tokyo-night, catppuccin-mocha, gruvbox, dracula, nord — and every other Textual built-in theme works too (`Ctrl+P` to switch); the whole UI is styled with Textual design tokens, so any theme restyles it consistently.
|
|
@@ -223,10 +227,11 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
|
|
|
223
227
|
Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
|
|
224
228
|
forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
|
|
225
229
|
guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
|
|
226
|
-
for scripts and statuslines, Claude session recap in the Info panel
|
|
230
|
+
for scripts and statuslines, Claude session recap in the Info panel, epics in the CLI and
|
|
231
|
+
the task list.
|
|
227
232
|
|
|
228
|
-
Next:
|
|
229
|
-
|
|
233
|
+
Next: an epic context block in each task's CLAUDE.md, and epic support in the `/wtask`
|
|
234
|
+
skill.
|
|
230
235
|
|
|
231
236
|
See [CHANGELOG.md](CHANGELOG.md) for the release history.
|
|
232
237
|
|
|
@@ -246,6 +246,7 @@ new_task = "n" # Create new task
|
|
|
246
246
|
add_repo = "a" # Add repository to current task
|
|
247
247
|
delete_task = "d" # Delete/finish task
|
|
248
248
|
rename_task = "R" # Rename task display label (folder/branch unchanged, shift+r)
|
|
249
|
+
set_epic = "E" # Set the task's epic, or make the task an epic (shift+e)
|
|
249
250
|
delete_worktree = "D" # Remove single worktree from task (shift+d)
|
|
250
251
|
|
|
251
252
|
# Git operations
|
|
@@ -592,7 +593,7 @@ blocklist = ["*"]
|
|
|
592
593
|
|
|
593
594
|
## Keybindings Reference
|
|
594
595
|
|
|
595
|
-
All
|
|
596
|
+
All 27 customizable keybindings with descriptions:
|
|
596
597
|
|
|
597
598
|
### Application Control
|
|
598
599
|
|
|
@@ -610,6 +611,7 @@ All 22 customizable keybindings with descriptions:
|
|
|
610
611
|
| `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
|
|
611
612
|
| `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
|
|
612
613
|
| `rename_task` | `R` | Rename task display label (alias only) | `rename_task = "ctrl+r"` |
|
|
614
|
+
| `set_epic` | `E` | Set the task's epic, or make it an epic | `set_epic = "ctrl+e"` |
|
|
613
615
|
|
|
614
616
|
### Git Operations
|
|
615
617
|
|
|
@@ -65,6 +65,28 @@ Task: FEAT-123
|
|
|
65
65
|
- Stored in `TASKS_DIR/task-name/`
|
|
66
66
|
- Each task gets its own set of branches across selected repos
|
|
67
67
|
|
|
68
|
+
### Epics
|
|
69
|
+
|
|
70
|
+
An **epic** groups related tasks, for example a Jira epic and its child tasks.
|
|
71
|
+
An epic is a task folder too: it can hold notes, a Claude session and its own
|
|
72
|
+
worktrees, or none. Child tasks keep their own folders; only a marker file
|
|
73
|
+
names their epic, so nothing moves on disk.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
▾ ● DIC-2466-pcaps-epic · 3
|
|
77
|
+
├ DIC-2861-pcaps-logs
|
|
78
|
+
├ ● DIC-2862-pcaps-s3
|
|
79
|
+
└ DIC-2865-pcaps-docs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- Press `E` on a task to add it to an epic, move it, remove it, create a new
|
|
83
|
+
empty epic for it, or turn the task itself into an epic.
|
|
84
|
+
- `n` and `y` preselect the epic of the highlighted row.
|
|
85
|
+
- `enter` or `space` folds an epic. A folded epic row still shows the dirty
|
|
86
|
+
count and the most urgent Claude state of its tasks.
|
|
87
|
+
- Epics don't nest. An epic that still has tasks can't be deleted.
|
|
88
|
+
- From scripts: `tasktree-manager epic create/add/remove/mark/unmark/list`.
|
|
89
|
+
|
|
68
90
|
### Worktrees
|
|
69
91
|
|
|
70
92
|
A **worktree** is a git working directory for a specific repository within a task.
|
|
@@ -186,6 +208,8 @@ Shows all your tasks:
|
|
|
186
208
|
- **CLAUDE.md indicator** `◆`: Blue diamond — task has a CLAUDE.md file
|
|
187
209
|
- **Claude session status**: `⟳` running (magenta), `!` waiting (yellow), `✓` ended (green)
|
|
188
210
|
- **Sort mode**: Press `s` to cycle through sort modes (name ↑/↓, date ↑/↓, dirty/clean first)
|
|
211
|
+
- **Epics**: `▾`/`▸` marks an epic with its task count; its tasks follow with `├`/`└`.
|
|
212
|
+
An epic sorts by its newest task and counts as dirty when any task is dirty
|
|
189
213
|
|
|
190
214
|
**Navigation:**
|
|
191
215
|
- `j/k` or arrow keys: Move up/down
|
|
@@ -318,6 +342,8 @@ what is displayed. Press `?` for the full reference at any time.
|
|
|
318
342
|
| `a` | Add repository | Add additional repositories to the current task |
|
|
319
343
|
| `d` | Delete/finish | Delete current task and remove worktrees |
|
|
320
344
|
| `R` | Rename task | Set a display alias shown in the task list (folder/branch unchanged; empty input reverts) |
|
|
345
|
+
| `E` | Set epic | Add the task to an epic, move or remove it, or make it an epic |
|
|
346
|
+
| `enter`/`space` | Fold epic | Fold or unfold the highlighted epic (on a task, folds its epic) |
|
|
321
347
|
|
|
322
348
|
**Task creation workflow:**
|
|
323
349
|
1. Press `n`
|
|
@@ -71,7 +71,7 @@ version-file = "tasktree_manager/_version.py"
|
|
|
71
71
|
# Semantic Release Configuration
|
|
72
72
|
[tool.semantic_release]
|
|
73
73
|
version_toml = ["pyproject.toml:tool.semantic_release.version"]
|
|
74
|
-
version = "2.
|
|
74
|
+
version = "2.15.0"
|
|
75
75
|
tag_format = "v{version}"
|
|
76
76
|
branch = "main"
|
|
77
77
|
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.15.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 15, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -24,7 +24,7 @@ from .services.claude_sessions import ClaudeRecap, read_claude_recap, transcript
|
|
|
24
24
|
from .services.config import Config, ConfigError
|
|
25
25
|
from .services.forge import Forge, ForgeStatus, get_forge_status
|
|
26
26
|
from .services.git_ops import GitOps
|
|
27
|
-
from .services.models import ArchiveResult, GitStatus, Task, TaskSafetyReport, Worktree
|
|
27
|
+
from .services.models import EPIC_KIND, ArchiveResult, GitStatus, Task, TaskSafetyReport, Worktree
|
|
28
28
|
from .services.task_manager import ARCHIVE_NOTES_SHOWN, TaskManager
|
|
29
29
|
from .themes import ALL_THEMES, THEME_CYCLE
|
|
30
30
|
from .widgets.app_header import AppHeader
|
|
@@ -33,7 +33,9 @@ from .widgets.create_modal import (
|
|
|
33
33
|
ConfirmModal,
|
|
34
34
|
CreateTaskModal,
|
|
35
35
|
DispatchAgentModal,
|
|
36
|
+
EpicPickerModal,
|
|
36
37
|
HelpModal,
|
|
38
|
+
NewEpicModal,
|
|
37
39
|
PushResultModal,
|
|
38
40
|
RenameTaskModal,
|
|
39
41
|
SafeDeleteModal,
|
|
@@ -199,6 +201,7 @@ class TaskTreeApp(App):
|
|
|
199
201
|
Binding("y", "clone_task", "Clone", show=False),
|
|
200
202
|
Binding("a", "add_repo", "Add Repo", show=False),
|
|
201
203
|
Binding("d", "delete_task", "Delete", show=False),
|
|
204
|
+
Binding("E", "set_epic", "Epic", show=False),
|
|
202
205
|
Binding("g", "open_lazygit", "Lazygit", show=False),
|
|
203
206
|
Binding("h", "show_diff", "Diff", show=False),
|
|
204
207
|
Binding("e", "open_editor", "Editor", show=False),
|
|
@@ -281,6 +284,7 @@ class TaskTreeApp(App):
|
|
|
281
284
|
Binding(kb.get("add_repo", "a"), "add_repo", "Add Repo", show=False),
|
|
282
285
|
Binding(kb.get("delete_task", "d"), "delete_task", "Delete", show=False),
|
|
283
286
|
Binding(kb.get("rename_task", "R"), "rename_task", "Rename", show=False),
|
|
287
|
+
Binding(kb.get("set_epic", "E"), "set_epic", "Epic", show=False),
|
|
284
288
|
Binding(kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit", show=False),
|
|
285
289
|
Binding(kb.get("show_diff", "h"), "show_diff", "Diff", show=False),
|
|
286
290
|
Binding(kb.get("open_editor", "e"), "open_editor", "Editor", show=False),
|
|
@@ -347,6 +351,7 @@ class TaskTreeApp(App):
|
|
|
347
351
|
(kb.get("add_repo", "a"), "add_repo", "Add Repo"),
|
|
348
352
|
(kb.get("delete_task", "d"), "delete_task", "Delete"),
|
|
349
353
|
(kb.get("rename_task", "R"), "rename_task", "Rename"),
|
|
354
|
+
(kb.get("set_epic", "E"), "set_epic", "Epic"),
|
|
350
355
|
(kb.get("show_diff", "h"), "show_diff", "Diff"),
|
|
351
356
|
(kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
|
|
352
357
|
(kb.get("cycle_sort", "s"), "cycle_sort", "Sort"),
|
|
@@ -373,7 +378,12 @@ class TaskTreeApp(App):
|
|
|
373
378
|
id="task-panel-title",
|
|
374
379
|
)
|
|
375
380
|
yield Static("", classes="panel-counter", id="task-panel-counter")
|
|
376
|
-
yield TaskList(
|
|
381
|
+
yield TaskList(
|
|
382
|
+
id="task-list",
|
|
383
|
+
context_bindings=task_panel_bindings,
|
|
384
|
+
# A fold key bound to any action in config stays that action
|
|
385
|
+
fold_keys=tuple(k for k in ("enter", "space") if k not in kb.values()),
|
|
386
|
+
)
|
|
377
387
|
with Vertical(id="worktree-panel"):
|
|
378
388
|
with Horizontal(classes="panel-title-bar"):
|
|
379
389
|
yield Static(
|
|
@@ -591,7 +601,7 @@ class TaskTreeApp(App):
|
|
|
591
601
|
follow-up scan can skip its UI reload when nothing actually changed.
|
|
592
602
|
"""
|
|
593
603
|
try:
|
|
594
|
-
previous = self.query_one("#task-list", TaskList).
|
|
604
|
+
previous = self.query_one("#task-list", TaskList).all_tasks
|
|
595
605
|
except Exception:
|
|
596
606
|
return
|
|
597
607
|
known = {(task.name, wt.name): wt for task in previous for wt in task.worktrees}
|
|
@@ -645,7 +655,8 @@ class TaskTreeApp(App):
|
|
|
645
655
|
return
|
|
646
656
|
|
|
647
657
|
statuses: dict[str, str] = {}
|
|
648
|
-
|
|
658
|
+
# all_tasks: a folded child still feeds its epic row rollup
|
|
659
|
+
for task in task_list.all_tasks:
|
|
649
660
|
status_file = task.path / ".claude_status"
|
|
650
661
|
if not status_file.exists():
|
|
651
662
|
continue
|
|
@@ -659,7 +670,9 @@ class TaskTreeApp(App):
|
|
|
659
670
|
# Valid JSON but not an object (e.g. "[]") - .get() below
|
|
660
671
|
# would raise AttributeError and kill the poll interval
|
|
661
672
|
continue
|
|
662
|
-
|
|
673
|
+
status = data.get("status", "unknown")
|
|
674
|
+
if isinstance(status, str):
|
|
675
|
+
statuses[task.name] = status
|
|
663
676
|
|
|
664
677
|
if statuses != self._claude_statuses:
|
|
665
678
|
self._claude_statuses = statuses
|
|
@@ -670,6 +683,9 @@ class TaskTreeApp(App):
|
|
|
670
683
|
except Exception:
|
|
671
684
|
return
|
|
672
685
|
status_panel.set_claude_live_status(statuses.get(self.current_task.name))
|
|
686
|
+
if self.current_task.is_epic:
|
|
687
|
+
children, _ = self._epic_context(self.current_task, task_list)
|
|
688
|
+
status_panel.set_epic_children(self.current_task.name, children or [])
|
|
673
689
|
|
|
674
690
|
# Same tick keeps the recap fresh: a stat per poll, a read only on change
|
|
675
691
|
if self.config.claude_recap and self.current_task and task_list.has_focus:
|
|
@@ -719,7 +735,7 @@ class TaskTreeApp(App):
|
|
|
719
735
|
task_list = self.query_one("#task-list", TaskList)
|
|
720
736
|
except Exception:
|
|
721
737
|
return
|
|
722
|
-
paths = [str(wt.path) for task in task_list.
|
|
738
|
+
paths = [str(wt.path) for task in task_list.all_tasks for wt in task.worktrees]
|
|
723
739
|
if paths:
|
|
724
740
|
self._run_agent_poll(paths)
|
|
725
741
|
|
|
@@ -850,6 +866,8 @@ class TaskTreeApp(App):
|
|
|
850
866
|
return tuple(
|
|
851
867
|
(
|
|
852
868
|
task.name,
|
|
869
|
+
task.kind,
|
|
870
|
+
task.epic_name,
|
|
853
871
|
tuple((wt.name, wt.branch, wt.is_dirty, wt.changed_files) for wt in task.worktrees),
|
|
854
872
|
)
|
|
855
873
|
for task in tasks
|
|
@@ -1131,10 +1149,46 @@ class TaskTreeApp(App):
|
|
|
1131
1149
|
status_panel = self.query_one("#status-display", StatusPanel)
|
|
1132
1150
|
task_list = self.query_one("#task-list", TaskList)
|
|
1133
1151
|
if task_list.has_focus:
|
|
1134
|
-
|
|
1152
|
+
children, parent = self._epic_context(task, task_list)
|
|
1153
|
+
status_panel.update_task_summary(
|
|
1154
|
+
task, statuses, epic_children=children, epic_label=parent
|
|
1155
|
+
)
|
|
1135
1156
|
except Exception:
|
|
1136
1157
|
pass
|
|
1137
1158
|
|
|
1159
|
+
def _epic_context(
|
|
1160
|
+
self, task: Task, task_list: TaskList
|
|
1161
|
+
) -> tuple[list[tuple[str, bool, str | None]] | None, str | None]:
|
|
1162
|
+
"""The Info panel epic block (children) or parent line for a task."""
|
|
1163
|
+
if task.is_epic:
|
|
1164
|
+
children = [
|
|
1165
|
+
(child.display_label, child.is_dirty, self._claude_statuses.get(child.name))
|
|
1166
|
+
for child in task_list.epic_children(task.name)
|
|
1167
|
+
]
|
|
1168
|
+
return children, None
|
|
1169
|
+
epic_name = task_list.epic_of(task.name)
|
|
1170
|
+
if epic_name is None:
|
|
1171
|
+
return None, None
|
|
1172
|
+
parent = next(t for t in task_list.all_tasks if t.name == epic_name)
|
|
1173
|
+
return None, parent.display_label
|
|
1174
|
+
|
|
1175
|
+
def _epic_choices(self) -> list[tuple[str, str]]:
|
|
1176
|
+
"""(label, name) of every loaded epic, sorted by label."""
|
|
1177
|
+
try:
|
|
1178
|
+
tasks = self.query_one("#task-list", TaskList).all_tasks
|
|
1179
|
+
except Exception:
|
|
1180
|
+
return []
|
|
1181
|
+
epics = [(task.display_label, task.name) for task in tasks if task.is_epic]
|
|
1182
|
+
return sorted(epics, key=lambda pair: pair[0].lower())
|
|
1183
|
+
|
|
1184
|
+
def _epic_of(self, task: Task | None) -> str | None:
|
|
1185
|
+
"""The epic a new task next to `task` should join: itself or its parent."""
|
|
1186
|
+
if task is None:
|
|
1187
|
+
return None
|
|
1188
|
+
if task.is_epic:
|
|
1189
|
+
return task.name
|
|
1190
|
+
return self.query_one("#task-list", TaskList).epic_of(task.name)
|
|
1191
|
+
|
|
1138
1192
|
def action_quit(self) -> None:
|
|
1139
1193
|
"""Quit the application."""
|
|
1140
1194
|
self.exit()
|
|
@@ -1168,16 +1222,23 @@ class TaskTreeApp(App):
|
|
|
1168
1222
|
|
|
1169
1223
|
def handle_result(result):
|
|
1170
1224
|
if result and self._begin_mutation("task-list"):
|
|
1171
|
-
name, repos, base_branch = result
|
|
1172
|
-
self._create_task_worker(name, repos, base_branch, verb="created")
|
|
1225
|
+
name, repos, base_branch, epic = result
|
|
1226
|
+
self._create_task_worker(name, repos, base_branch, verb="created", epic=epic)
|
|
1173
1227
|
|
|
1174
1228
|
self.push_screen(
|
|
1175
|
-
CreateTaskModal(
|
|
1229
|
+
CreateTaskModal(
|
|
1230
|
+
available_repos,
|
|
1231
|
+
initial_base_branch=self.config.default_base_branch,
|
|
1232
|
+
epics=self._epic_choices(),
|
|
1233
|
+
initial_epic=self._epic_of(self.current_task),
|
|
1234
|
+
),
|
|
1176
1235
|
handle_result,
|
|
1177
1236
|
)
|
|
1178
1237
|
|
|
1179
1238
|
@work(thread=True, group="task_mutation")
|
|
1180
|
-
def _create_task_worker(
|
|
1239
|
+
def _create_task_worker(
|
|
1240
|
+
self, name: str, repos: list[str], base_branch: str, verb: str, epic: str | None = None
|
|
1241
|
+
) -> None:
|
|
1181
1242
|
"""Create a task in a background thread.
|
|
1182
1243
|
|
|
1183
1244
|
Creation fetches the base branch from origin for every repo, so it
|
|
@@ -1186,7 +1247,7 @@ class TaskTreeApp(App):
|
|
|
1186
1247
|
"""
|
|
1187
1248
|
error: str | None = None
|
|
1188
1249
|
try:
|
|
1189
|
-
self.task_manager.create_task(name, repos, base_branch)
|
|
1250
|
+
self.task_manager.create_task(name, repos, base_branch, epic=epic)
|
|
1190
1251
|
except FileNotFoundError as e:
|
|
1191
1252
|
error = f"Repository not found: {e}"
|
|
1192
1253
|
except PermissionError:
|
|
@@ -1196,10 +1257,10 @@ class TaskTreeApp(App):
|
|
|
1196
1257
|
error = str(e)
|
|
1197
1258
|
except Exception as e:
|
|
1198
1259
|
error = f"{type(e).__name__}: {e}"
|
|
1199
|
-
self.call_from_thread(self._apply_create_task_result, name, len(repos), verb, error)
|
|
1260
|
+
self.call_from_thread(self._apply_create_task_result, name, len(repos), verb, error, epic)
|
|
1200
1261
|
|
|
1201
1262
|
def _apply_create_task_result(
|
|
1202
|
-
self, name: str, repo_count: int, verb: str, error: str | None
|
|
1263
|
+
self, name: str, repo_count: int, verb: str, error: str | None, epic: str | None = None
|
|
1203
1264
|
) -> None:
|
|
1204
1265
|
"""Report the create/clone outcome and reload (runs on the main thread)."""
|
|
1205
1266
|
if error:
|
|
@@ -1208,8 +1269,11 @@ class TaskTreeApp(App):
|
|
|
1208
1269
|
self._load_tasks()
|
|
1209
1270
|
else:
|
|
1210
1271
|
self.notify(escape(f"Created task: {name}"))
|
|
1272
|
+
in_epic = f" in epic '{epic}'" if epic else ""
|
|
1211
1273
|
self._log_activity(
|
|
1212
|
-
f"Task '{name}' {verb} with {repo_count} repo(s)",
|
|
1274
|
+
f"Task '{name}' {verb} with {repo_count} repo(s){in_epic}",
|
|
1275
|
+
MessageLevel.SUCCESS,
|
|
1276
|
+
name,
|
|
1213
1277
|
)
|
|
1214
1278
|
self._load_tasks(select_task=name)
|
|
1215
1279
|
|
|
@@ -1228,8 +1292,8 @@ class TaskTreeApp(App):
|
|
|
1228
1292
|
|
|
1229
1293
|
def handle_result(result):
|
|
1230
1294
|
if result and self._begin_mutation("task-list"):
|
|
1231
|
-
name, repos, base_branch = result
|
|
1232
|
-
self._create_task_worker(name, repos, base_branch, verb="cloned")
|
|
1295
|
+
name, repos, base_branch, epic = result
|
|
1296
|
+
self._create_task_worker(name, repos, base_branch, verb="cloned", epic=epic)
|
|
1233
1297
|
|
|
1234
1298
|
self.push_screen(
|
|
1235
1299
|
CreateTaskModal(
|
|
@@ -1237,6 +1301,8 @@ class TaskTreeApp(App):
|
|
|
1237
1301
|
initial_repos=source_repos,
|
|
1238
1302
|
initial_base_branch=self.config.default_base_branch,
|
|
1239
1303
|
title=f"Clone Task: {self.current_task.name}",
|
|
1304
|
+
epics=self._epic_choices(),
|
|
1305
|
+
initial_epic=self._epic_of(self.current_task),
|
|
1240
1306
|
),
|
|
1241
1307
|
handle_result,
|
|
1242
1308
|
)
|
|
@@ -1376,6 +1442,87 @@ class TaskTreeApp(App):
|
|
|
1376
1442
|
|
|
1377
1443
|
self.push_screen(RenameTaskModal(task.name, task.display_name), handle_rename)
|
|
1378
1444
|
|
|
1445
|
+
def action_set_epic(self) -> None:
|
|
1446
|
+
"""Attach the current task to an epic, detach it, or make it an epic."""
|
|
1447
|
+
task = self.current_task
|
|
1448
|
+
if not task:
|
|
1449
|
+
self.notify("No task selected", severity="warning")
|
|
1450
|
+
return
|
|
1451
|
+
if task.is_epic:
|
|
1452
|
+
self.notify("Epics don't nest: an epic can't join another epic", severity="warning")
|
|
1453
|
+
return
|
|
1454
|
+
epics = [(label, name) for label, name in self._epic_choices() if name != task.name]
|
|
1455
|
+
|
|
1456
|
+
def handle_choice(choice: tuple[str, str | None] | None) -> None:
|
|
1457
|
+
if choice is None:
|
|
1458
|
+
return
|
|
1459
|
+
action, epic = choice
|
|
1460
|
+
if action == "new":
|
|
1461
|
+
self.push_screen(
|
|
1462
|
+
NewEpicModal(task.name),
|
|
1463
|
+
lambda name: self._apply_epic_choice(task, "new", name) if name else None,
|
|
1464
|
+
)
|
|
1465
|
+
else:
|
|
1466
|
+
self._apply_epic_choice(task, action, epic)
|
|
1467
|
+
|
|
1468
|
+
self.push_screen(
|
|
1469
|
+
EpicPickerModal(
|
|
1470
|
+
task.display_label,
|
|
1471
|
+
epics,
|
|
1472
|
+
self.query_one("#task-list", TaskList).epic_of(task.name),
|
|
1473
|
+
task.epic_name is None,
|
|
1474
|
+
),
|
|
1475
|
+
handle_choice,
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
def _apply_epic_choice(self, task: Task, action: str, epic: str | None) -> None:
|
|
1479
|
+
"""Write the picked epic change; marker writes are local and fast."""
|
|
1480
|
+
if self._mutation_in_flight():
|
|
1481
|
+
# A delete worker may be rmtree-ing the very folders written here
|
|
1482
|
+
self.notify("Another task operation is still running", severity="warning")
|
|
1483
|
+
return
|
|
1484
|
+
previous = task.epic_name
|
|
1485
|
+
try:
|
|
1486
|
+
if action == "new" and epic:
|
|
1487
|
+
self.task_manager.create_task(epic, [], kind=EPIC_KIND)
|
|
1488
|
+
try:
|
|
1489
|
+
epic = self.task_manager.set_task_epic(task, epic)
|
|
1490
|
+
except (ValueError, OSError) as e:
|
|
1491
|
+
# The epic exists now; show it and say what did not happen
|
|
1492
|
+
self.notify(
|
|
1493
|
+
escape(f"Created epic '{epic}' but could not add '{task.name}': {e}"),
|
|
1494
|
+
severity="warning",
|
|
1495
|
+
)
|
|
1496
|
+
self._load_tasks(select_task=epic)
|
|
1497
|
+
return
|
|
1498
|
+
message = f"Created epic '{epic}' and added '{task.name}' to it"
|
|
1499
|
+
elif action == "set" and epic:
|
|
1500
|
+
epic = self.task_manager.set_task_epic(task, epic)
|
|
1501
|
+
if previous == epic:
|
|
1502
|
+
message = f"Task '{task.name}' already in epic '{epic}'"
|
|
1503
|
+
elif previous:
|
|
1504
|
+
message = f"Moved '{task.name}' from epic '{previous}' to '{epic}'"
|
|
1505
|
+
else:
|
|
1506
|
+
message = f"Added '{task.name}' to epic '{epic}'"
|
|
1507
|
+
elif action == "none":
|
|
1508
|
+
self.task_manager.set_task_epic(task, None)
|
|
1509
|
+
message = (
|
|
1510
|
+
f"Removed '{task.name}' from epic '{previous}'"
|
|
1511
|
+
if previous
|
|
1512
|
+
else f"Task '{task.name}' is not in an epic"
|
|
1513
|
+
)
|
|
1514
|
+
elif action == "mark":
|
|
1515
|
+
self.task_manager.set_task_kind(task, EPIC_KIND)
|
|
1516
|
+
message = f"Task '{task.name}' is an epic now"
|
|
1517
|
+
else:
|
|
1518
|
+
return
|
|
1519
|
+
except (ValueError, OSError) as e:
|
|
1520
|
+
self.notify(escape(f"Epic change failed: {e}"), severity="error")
|
|
1521
|
+
return
|
|
1522
|
+
self.notify(escape(message))
|
|
1523
|
+
self._log_activity(message, MessageLevel.INFO, task.name)
|
|
1524
|
+
self._load_tasks(select_task=task.name)
|
|
1525
|
+
|
|
1379
1526
|
def _finish_task(self, task: Task, force: bool = False) -> None:
|
|
1380
1527
|
"""Kick off task deletion in a background thread."""
|
|
1381
1528
|
if not self._begin_mutation("task-list"):
|
|
@@ -1831,6 +1978,9 @@ class TaskTreeApp(App):
|
|
|
1831
1978
|
return
|
|
1832
1979
|
|
|
1833
1980
|
task = self.current_task
|
|
1981
|
+
if not task.worktrees:
|
|
1982
|
+
self.notify("Task has no worktrees", severity="information")
|
|
1983
|
+
return
|
|
1834
1984
|
|
|
1835
1985
|
# Building the combined diff is local git work, but it scales with
|
|
1836
1986
|
# the size of the trees (one add + diff per worktree), so it runs in
|
|
@@ -2262,6 +2412,9 @@ class TaskTreeApp(App):
|
|
|
2262
2412
|
# that's disappearing under it.
|
|
2263
2413
|
self.notify("Another task operation is still running", severity="warning")
|
|
2264
2414
|
return
|
|
2415
|
+
if not self.current_task.worktrees:
|
|
2416
|
+
self.notify("Task has no worktrees", severity="information")
|
|
2417
|
+
return
|
|
2265
2418
|
|
|
2266
2419
|
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
2267
2420
|
worktree_list.loading = True
|
|
@@ -2285,6 +2438,9 @@ class TaskTreeApp(App):
|
|
|
2285
2438
|
if self._mutation_in_flight():
|
|
2286
2439
|
self.notify("Another task operation is still running", severity="warning")
|
|
2287
2440
|
return
|
|
2441
|
+
if not self.current_task.worktrees:
|
|
2442
|
+
self.notify("Task has no worktrees", severity="information")
|
|
2443
|
+
return
|
|
2288
2444
|
|
|
2289
2445
|
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
2290
2446
|
worktree_list.loading = True
|
|
@@ -26,6 +26,7 @@ DEFAULT_KEYBINDINGS: dict[str, str] = {
|
|
|
26
26
|
"add_repo": "a",
|
|
27
27
|
"delete_task": "d",
|
|
28
28
|
"rename_task": "R",
|
|
29
|
+
"set_epic": "E",
|
|
29
30
|
"open_lazygit": "g",
|
|
30
31
|
"show_diff": "h",
|
|
31
32
|
"open_folder": "o",
|
|
@@ -509,6 +510,7 @@ clone_task = "{self._toml_escape(self.keybindings.get("clone_task", "y"))}"
|
|
|
509
510
|
add_repo = "{self._toml_escape(self.keybindings.get("add_repo", "a"))}"
|
|
510
511
|
delete_task = "{self._toml_escape(self.keybindings.get("delete_task", "d"))}"
|
|
511
512
|
rename_task = "{self._toml_escape(self.keybindings.get("rename_task", "R"))}"
|
|
513
|
+
set_epic = "{self._toml_escape(self.keybindings.get("set_epic", "E"))}"
|
|
512
514
|
open_lazygit = "{self._toml_escape(self.keybindings.get("open_lazygit", "g"))}"
|
|
513
515
|
show_diff = "{self._toml_escape(self.keybindings.get("show_diff", "h"))}"
|
|
514
516
|
open_folder = "{self._toml_escape(self.keybindings.get("open_folder", "o"))}"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Data models for tasktree-manager."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
6
|
from pathlib import Path
|
|
@@ -127,6 +128,44 @@ class Task:
|
|
|
127
128
|
return value
|
|
128
129
|
|
|
129
130
|
|
|
131
|
+
def resolve_epic(task: Task, epics: dict[str, Task]) -> str | None:
|
|
132
|
+
"""The epic in `epics` that the task's marker names, or None.
|
|
133
|
+
|
|
134
|
+
A marker spelled in another case matches only when it opens the same
|
|
135
|
+
folder, as on a case-insensitive disk.
|
|
136
|
+
"""
|
|
137
|
+
parent = task.epic_name
|
|
138
|
+
if parent is None or parent in epics:
|
|
139
|
+
return parent
|
|
140
|
+
for name, epic in epics.items():
|
|
141
|
+
if name.casefold() == parent.casefold():
|
|
142
|
+
try:
|
|
143
|
+
if os.path.samefile(task.path.parent / parent, epic.path):
|
|
144
|
+
return name
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def group_by_epic(tasks: list[Task]) -> tuple[list[Task], dict[str, list[Task]]]:
|
|
151
|
+
"""Split tasks into top-level rows and the children of each epic.
|
|
152
|
+
|
|
153
|
+
A task is a child when its marker names an epic in `tasks`, by the same
|
|
154
|
+
rule as TaskManager.epic_child_names (the delete guard). Anything else, an
|
|
155
|
+
orphan included, is top-level. Order within both is kept.
|
|
156
|
+
"""
|
|
157
|
+
epics = {task.name: task for task in tasks if task.is_epic}
|
|
158
|
+
children: dict[str, list[Task]] = {name: [] for name in epics}
|
|
159
|
+
top: list[Task] = []
|
|
160
|
+
for task in tasks:
|
|
161
|
+
parent = None if task.name in epics else resolve_epic(task, epics)
|
|
162
|
+
if parent is None:
|
|
163
|
+
top.append(task)
|
|
164
|
+
else:
|
|
165
|
+
children[parent].append(task)
|
|
166
|
+
return top, children
|
|
167
|
+
|
|
168
|
+
|
|
130
169
|
@dataclass
|
|
131
170
|
class GitStatus:
|
|
132
171
|
"""Represents the git status of a worktree."""
|
{tasktree_manager-2.14.0 → tasktree_manager-2.15.0}/tasktree_manager/services/task_manager.py
RENAMED
|
@@ -275,9 +275,7 @@ class TaskManager:
|
|
|
275
275
|
created_dir = not task_path.exists()
|
|
276
276
|
if not created_dir and (epic is not None or kind == EPIC_KIND):
|
|
277
277
|
# The hierarchy rules for an existing task live in the two setters
|
|
278
|
-
raise ValueError(
|
|
279
|
-
f"Task '{name}' already exists; use tasktree-manager epic add or epic mark"
|
|
280
|
-
)
|
|
278
|
+
raise ValueError(f"Task '{name}' already exists")
|
|
281
279
|
task_path.mkdir(parents=True, exist_ok=True)
|
|
282
280
|
|
|
283
281
|
task = Task(name=name, path=task_path)
|
|
@@ -640,6 +638,8 @@ class TaskManager:
|
|
|
640
638
|
real name — clears the file.
|
|
641
639
|
"""
|
|
642
640
|
marker = task.path / ".tasktree_name"
|
|
641
|
+
if marker.is_symlink():
|
|
642
|
+
marker.unlink()
|
|
643
643
|
name = (display_name or "").strip()
|
|
644
644
|
if name == task.name:
|
|
645
645
|
name = ""
|
|
@@ -652,6 +652,9 @@ class TaskManager:
|
|
|
652
652
|
def _write_marker(task: Task, marker: str, value: str | None) -> None:
|
|
653
653
|
"""Write <task>/<marker>, or remove it when value is None."""
|
|
654
654
|
path = task.path / marker
|
|
655
|
+
if path.is_symlink():
|
|
656
|
+
# Never write through a planted link to a file outside the task
|
|
657
|
+
path.unlink()
|
|
655
658
|
if value is not None:
|
|
656
659
|
path.write_text(value + "\n", encoding="utf-8")
|
|
657
660
|
else:
|