tasktree-manager 2.13.3__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.13.3 → tasktree_manager-2.15.0}/CHANGELOG.md +12 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/PKG-INFO +1 -1
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/README.md +26 -4
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/configuration.md +3 -1
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/user-guide.md +26 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/pyproject.toml +1 -1
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/app.py +183 -17
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/cli.py +204 -11
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/config.py +2 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/models.py +104 -5
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/task_manager.py +173 -30
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/create_modal.py +156 -6
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/status_panel.py +55 -1
- {tasktree_manager-2.13.3 → 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.15.0/tests/test_epics.py +442 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/.gitignore +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/LICENSE +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/installation.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/mise.toml +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/scripts/bump_version.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/commands.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/agent_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_hooks.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/claude_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/forge.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/themes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/app_header.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tasktree_manager/widgets/worktree_list.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_agent_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_agent_sessions_decode.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_app_review_fixes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_bump_version.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_claude_hooks.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_claude_sessions.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_cli.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_commands.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_forge.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_list_selection.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_medium_fixes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_safety_fixes.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_task_manager.py +0 -0
- {tasktree_manager-2.13.3 → tasktree_manager-2.15.0}/tests/test_worktree_badges.py +0 -0
|
@@ -15,6 +15,18 @@ 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
|
+
|
|
24
|
+
## [2.14.0] - 2026-10-19
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **epic**: Group tasks under epics from the CLI
|
|
28
|
+
|
|
29
|
+
|
|
18
30
|
## [2.13.3] - 2026-10-16
|
|
19
31
|
|
|
20
32
|
### Changed
|
|
@@ -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
|
|
|
@@ -86,6 +89,23 @@ tasktree-manager finish DIC-1901-argocd-tls # safety sweep -> archive diff + t
|
|
|
86
89
|
tasktree-manager delete DIC-1901-argocd-tls # archive diff + task files -> delete; refuses on unfinished work; --force overrides
|
|
87
90
|
```
|
|
88
91
|
|
|
92
|
+
Epics group related tasks, for example a Jira epic and its child tasks. An epic is a task
|
|
93
|
+
folder with a `.tasktree_kind` marker. It can hold its own notes, Claude session and
|
|
94
|
+
worktrees. A child task names its epic in `.tasktree_epic`. Folders never move:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
tasktree-manager epic create DIC-2466-pcaps-epic # --repos is optional for an epic
|
|
98
|
+
tasktree-manager create DIC-2861-pcaps-logs --repos infra --epic DIC-2466-pcaps-epic
|
|
99
|
+
tasktree-manager epic add DIC-2466-pcaps-epic DIC-2862-pcaps-s3 # attach or move existing tasks
|
|
100
|
+
tasktree-manager epic remove DIC-2862-pcaps-s3 # detach
|
|
101
|
+
tasktree-manager epic mark DIC-2466-pcaps-epic # turn an existing task into an epic; unmark reverts
|
|
102
|
+
tasktree-manager epic list # epics and their tasks; also --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Epics don't nest. `list --json` and `status --json` carry `kind` (`task` or `epic`) and
|
|
106
|
+
`epic` (the parent epic or `null`). `delete` and `finish` refuse an epic that still has
|
|
107
|
+
tasks, even with `--force`.
|
|
108
|
+
|
|
89
109
|
Exit code is 0 on success, 1 on failure with the reason on stderr. `delete` and `finish` run
|
|
90
110
|
the same safety check as the TUI (uncommitted, unpushed, or unmerged work blocks deletion);
|
|
91
111
|
with `glab`/`gh` installed, squash/rebase-merged branches are recognized as merged. If the
|
|
@@ -124,7 +144,8 @@ a Claude Code statusline or shell prompt.
|
|
|
124
144
|
| `r` | Refresh | `s` | Sort tasks |
|
|
125
145
|
| `S` | Group worktrees | `m` | Messages |
|
|
126
146
|
| `t` | Cycle theme | `Tab` | Next panel |
|
|
127
|
-
| `R` | Rename task | | |
|
|
147
|
+
| `R` | Rename task | `E` | Set epic |
|
|
148
|
+
| `enter` | Fold epic | | |
|
|
128
149
|
| `?` | Show help | `q` | Quit |
|
|
129
150
|
|
|
130
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.
|
|
@@ -206,10 +227,11 @@ For comprehensive troubleshooting, see the [Troubleshooting Guide](docs/troubles
|
|
|
206
227
|
Recently shipped: agent dashboard (per-worktree Claude session badges + background dispatch),
|
|
207
228
|
forge-aware merged detection (glab/gh, squash/rebase merges), MR/CI status badges,
|
|
208
229
|
guided `finish` flow that archives the diff and the task-folder files, `status` subcommand
|
|
209
|
-
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.
|
|
210
232
|
|
|
211
|
-
Next:
|
|
212
|
-
|
|
233
|
+
Next: an epic context block in each task's CLAUDE.md, and epic support in the `/wtask`
|
|
234
|
+
skill.
|
|
213
235
|
|
|
214
236
|
See [CHANGELOG.md](CHANGELOG.md) for the release history.
|
|
215
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
|
)
|
|
@@ -1300,6 +1366,16 @@ class TaskTreeApp(App):
|
|
|
1300
1366
|
if not self.current_task:
|
|
1301
1367
|
self.notify("No task selected", severity="warning")
|
|
1302
1368
|
return
|
|
1369
|
+
if self.current_task.is_epic:
|
|
1370
|
+
children = self.task_manager.epic_child_names(self.current_task.name)
|
|
1371
|
+
if children:
|
|
1372
|
+
self.notify(
|
|
1373
|
+
escape(
|
|
1374
|
+
f"Epic '{self.current_task.name}' still has tasks: {', '.join(children)}"
|
|
1375
|
+
),
|
|
1376
|
+
severity="warning",
|
|
1377
|
+
)
|
|
1378
|
+
return
|
|
1303
1379
|
|
|
1304
1380
|
# Safety checks hit the network (git fetch per worktree), so run them
|
|
1305
1381
|
# in a background thread and show the dialog when they complete
|
|
@@ -1366,6 +1442,87 @@ class TaskTreeApp(App):
|
|
|
1366
1442
|
|
|
1367
1443
|
self.push_screen(RenameTaskModal(task.name, task.display_name), handle_rename)
|
|
1368
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
|
+
|
|
1369
1526
|
def _finish_task(self, task: Task, force: bool = False) -> None:
|
|
1370
1527
|
"""Kick off task deletion in a background thread."""
|
|
1371
1528
|
if not self._begin_mutation("task-list"):
|
|
@@ -1821,6 +1978,9 @@ class TaskTreeApp(App):
|
|
|
1821
1978
|
return
|
|
1822
1979
|
|
|
1823
1980
|
task = self.current_task
|
|
1981
|
+
if not task.worktrees:
|
|
1982
|
+
self.notify("Task has no worktrees", severity="information")
|
|
1983
|
+
return
|
|
1824
1984
|
|
|
1825
1985
|
# Building the combined diff is local git work, but it scales with
|
|
1826
1986
|
# the size of the trees (one add + diff per worktree), so it runs in
|
|
@@ -2252,6 +2412,9 @@ class TaskTreeApp(App):
|
|
|
2252
2412
|
# that's disappearing under it.
|
|
2253
2413
|
self.notify("Another task operation is still running", severity="warning")
|
|
2254
2414
|
return
|
|
2415
|
+
if not self.current_task.worktrees:
|
|
2416
|
+
self.notify("Task has no worktrees", severity="information")
|
|
2417
|
+
return
|
|
2255
2418
|
|
|
2256
2419
|
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
2257
2420
|
worktree_list.loading = True
|
|
@@ -2275,6 +2438,9 @@ class TaskTreeApp(App):
|
|
|
2275
2438
|
if self._mutation_in_flight():
|
|
2276
2439
|
self.notify("Another task operation is still running", severity="warning")
|
|
2277
2440
|
return
|
|
2441
|
+
if not self.current_task.worktrees:
|
|
2442
|
+
self.notify("Task has no worktrees", severity="information")
|
|
2443
|
+
return
|
|
2278
2444
|
|
|
2279
2445
|
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
2280
2446
|
worktree_list.loading = True
|