tasktree-manager 2.11.1__tar.gz → 2.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/README.md +1 -0
  4. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/docs/configuration.md +2 -0
  5. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/docs/user-guide.md +1 -0
  6. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/app.py +25 -0
  9. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/cli.py +1 -0
  10. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/commands.py +5 -0
  11. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/config.py +2 -0
  12. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/models.py +18 -0
  13. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/task_manager.py +16 -0
  14. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/create_modal.py +53 -1
  15. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/task_list.py +5 -5
  16. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_app.py +66 -0
  17. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_task_manager.py +42 -0
  18. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  19. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  20. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  21. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  22. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  23. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  24. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  25. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  26. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  27. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  28. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  29. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.github/workflows/ci.yml +0 -0
  30. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.github/workflows/release.yml +0 -0
  31. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/.gitignore +0 -0
  32. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/CONTRIBUTING.md +0 -0
  33. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/LICENSE +0 -0
  34. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/OPENSPEC.md +0 -0
  35. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/ROADMAP.md +0 -0
  36. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/docs/claude-code-integration-spec.md +0 -0
  37. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/docs/installation.md +0 -0
  38. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/docs/troubleshooting.md +0 -0
  39. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/mise.toml +0 -0
  40. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/scripts/bump_version.py +0 -0
  41. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/__init__.py +0 -0
  42. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/__init__.py +0 -0
  43. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/agent_sessions.py +0 -0
  44. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/claude_hooks.py +0 -0
  45. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/forge.py +0 -0
  46. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/services/git_ops.py +0 -0
  47. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/themes.py +0 -0
  48. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/__init__.py +0 -0
  49. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/app_header.py +0 -0
  50. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  51. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  52. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/status_panel.py +0 -0
  53. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  54. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/__init__.py +0 -0
  55. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/conftest.py +0 -0
  56. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_agent_sessions.py +0 -0
  57. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_claude_hooks.py +0 -0
  58. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_cli.py +0 -0
  59. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_commands.py +0 -0
  60. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_config.py +0 -0
  61. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_forge.py +0 -0
  62. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_git_ops.py +0 -0
  63. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_integration_git.py +0 -0
  64. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_memory.py +0 -0
  65. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_messages_panel.py +0 -0
  66. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_modals.py +0 -0
  67. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_setup_modal.py +0 -0
  68. {tasktree_manager-2.11.1 → tasktree_manager-2.12.0}/tests/test_status_panel.py +0 -0
  69. {tasktree_manager-2.11.1 → tasktree_manager-2.12.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.12.0] - 2026-07-31
19
+
20
+ ### Added
21
+ - **tui**: Rename task display label with R
22
+
23
+
18
24
  ## [2.11.1] - 2026-07-31
19
25
 
20
26
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.11.1
3
+ Version: 2.12.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -113,6 +113,7 @@ a Claude Code statusline or shell prompt.
113
113
  | `r` | Refresh | `s` | Sort tasks |
114
114
  | `S` | Group worktrees | `m` | Messages |
115
115
  | `t` | Cycle theme | `Tab` | Next panel |
116
+ | `R` | Rename task | | |
116
117
  | `?` | Show help | `q` | Quit |
117
118
 
118
119
  **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,6 +223,7 @@ help = "?" # Show help modal
223
223
  new_task = "n" # Create new task
224
224
  add_repo = "a" # Add repository to current task
225
225
  delete_task = "d" # Delete/finish task
226
+ rename_task = "R" # Rename task display label (folder/branch unchanged, shift+r)
226
227
  delete_worktree = "D" # Remove single worktree from task (shift+d)
227
228
 
228
229
  # Git operations
@@ -583,6 +584,7 @@ All 22 customizable keybindings with descriptions:
583
584
  | `clone_task` | `y` | Clone current task (same repos, new name) | `clone_task = "ctrl+y"` |
584
585
  | `add_repo` | `a` | Add repository to current task | `add_repo = "ctrl+a"` |
585
586
  | `delete_task` | `d` | Delete/finish current task | `delete_task = "delete"` |
587
+ | `rename_task` | `R` | Rename task display label (alias only) | `rename_task = "ctrl+r"` |
586
588
 
587
589
  ### Git Operations
588
590
 
@@ -300,6 +300,7 @@ what is displayed. Press `?` for the full reference at any time.
300
300
  | `y` | Clone task | Create a new task pre-populated with the current task's repos |
301
301
  | `a` | Add repository | Add additional repositories to the current task |
302
302
  | `d` | Delete/finish | Delete current task and remove worktrees |
303
+ | `R` | Rename task | Set a display alias shown in the task list (folder/branch unchanged; empty input reverts) |
303
304
 
304
305
  **Task creation workflow:**
305
306
  1. Press `n`
@@ -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.11.1"
73
+ version = "2.12.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.11.1'
22
- __version_tuple__ = version_tuple = (2, 11, 1)
21
+ __version__ = version = '2.12.0'
22
+ __version_tuple__ = version_tuple = (2, 12, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -34,6 +34,7 @@ from .widgets.create_modal import (
34
34
  DispatchAgentModal,
35
35
  HelpModal,
36
36
  PushResultModal,
37
+ RenameTaskModal,
37
38
  SafeDeleteModal,
38
39
  )
39
40
  from .widgets.messages_panel import MessageLevel, MessagesPanel
@@ -274,6 +275,7 @@ class TaskTreeApp(App):
274
275
  Binding(kb.get("clone_task", "y"), "clone_task", "Clone", show=False),
275
276
  Binding(kb.get("add_repo", "a"), "add_repo", "Add Repo", show=False),
276
277
  Binding(kb.get("delete_task", "d"), "delete_task", "Delete", show=False),
278
+ Binding(kb.get("rename_task", "R"), "rename_task", "Rename", show=False),
277
279
  Binding(kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit", show=False),
278
280
  Binding(kb.get("show_diff", "h"), "show_diff", "Diff", show=False),
279
281
  Binding(kb.get("open_editor", "e"), "open_editor", "Editor", show=False),
@@ -320,6 +322,7 @@ class TaskTreeApp(App):
320
322
  (kb.get("clone_task", "y"), "clone_task", "Clone"),
321
323
  (kb.get("add_repo", "a"), "add_repo", "Add Repo"),
322
324
  (kb.get("delete_task", "d"), "delete_task", "Delete"),
325
+ (kb.get("rename_task", "R"), "rename_task", "Rename"),
323
326
  (kb.get("show_diff", "h"), "show_diff", "Diff"),
324
327
  (kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
325
328
  (kb.get("cycle_sort", "s"), "cycle_sort", "Sort"),
@@ -1190,6 +1193,28 @@ class TaskTreeApp(App):
1190
1193
 
1191
1194
  self.push_screen(ConfirmModal(title, message), handle_confirm)
1192
1195
 
1196
+ def action_rename_task(self) -> None:
1197
+ """Set a display alias for the current task (folder/branch unchanged)."""
1198
+ if not self.current_task:
1199
+ self.notify("No task selected", severity="warning")
1200
+ return
1201
+ task = self.current_task
1202
+
1203
+ def handle_rename(new_name: str | None) -> None:
1204
+ if new_name is None:
1205
+ return
1206
+ try:
1207
+ self.task_manager.set_task_display_name(task, new_name)
1208
+ except OSError as e:
1209
+ self.notify(escape(f"Rename failed: {e}"), severity="error")
1210
+ return
1211
+ label = task.display_label
1212
+ self.notify(escape(f"Task '{task.name}' shown as '{label}'"))
1213
+ self._log_activity(f"Task '{task.name}' renamed to '{label}'", MessageLevel.INFO)
1214
+ self._run_periodic_refresh(force_ui=True)
1215
+
1216
+ self.push_screen(RenameTaskModal(task.name, task.display_name), handle_rename)
1217
+
1193
1218
  def _finish_task(self, task: Task, force: bool = False) -> None:
1194
1219
  """Kick off task deletion in a background thread."""
1195
1220
  if not self._begin_mutation("task-list"):
@@ -155,6 +155,7 @@ def cmd_list(manager: TaskManager, config: Config, args: argparse.Namespace) ->
155
155
  payload = [
156
156
  {
157
157
  "name": task.name,
158
+ "display_name": task.display_name,
158
159
  "path": str(task.path),
159
160
  "repos": [wt.name for wt in task.worktrees],
160
161
  "dirty": dirty[task.name],
@@ -12,6 +12,11 @@ APP_COMMANDS: list[tuple[str, str, str]] = [
12
12
  ("clone_task", "Clone Task", "Create a new task with the same repos as the current one"),
13
13
  ("add_repo", "Add Repo", "Add repositories to the current task"),
14
14
  ("delete_task", "Delete Task", "Delete the current task (with safety checks)"),
15
+ (
16
+ "rename_task",
17
+ "Rename Task",
18
+ "Set a display alias for the current task (folder/branch unchanged)",
19
+ ),
15
20
  ("delete_worktree", "Delete Worktree", "Delete the selected worktree from the task"),
16
21
  ("open_lazygit", "Open Lazygit", "Open lazygit in the selected worktree"),
17
22
  (
@@ -23,6 +23,7 @@ DEFAULT_KEYBINDINGS: dict[str, str] = {
23
23
  "clone_task": "y",
24
24
  "add_repo": "a",
25
25
  "delete_task": "d",
26
+ "rename_task": "R",
26
27
  "open_lazygit": "g",
27
28
  "show_diff": "h",
28
29
  "open_folder": "o",
@@ -424,6 +425,7 @@ new_task = "{self._toml_escape(self.keybindings.get("new_task", "n"))}"
424
425
  clone_task = "{self._toml_escape(self.keybindings.get("clone_task", "y"))}"
425
426
  add_repo = "{self._toml_escape(self.keybindings.get("add_repo", "a"))}"
426
427
  delete_task = "{self._toml_escape(self.keybindings.get("delete_task", "d"))}"
428
+ rename_task = "{self._toml_escape(self.keybindings.get("rename_task", "R"))}"
427
429
  open_lazygit = "{self._toml_escape(self.keybindings.get("open_lazygit", "g"))}"
428
430
  show_diff = "{self._toml_escape(self.keybindings.get("show_diff", "h"))}"
429
431
  open_folder = "{self._toml_escape(self.keybindings.get("open_folder", "o"))}"
@@ -48,6 +48,24 @@ class Task:
48
48
  """Check if the task has a CLAUDE.md file."""
49
49
  return (self.path / "CLAUDE.md").exists()
50
50
 
51
+ @property
52
+ def display_name(self) -> str | None:
53
+ """Display alias from <task>/.tasktree_name, or None when unset.
54
+
55
+ Read lazily from disk so every Task construction site picks it up
56
+ and a rename is visible on the next render without reloading.
57
+ """
58
+ try:
59
+ value = (self.path / ".tasktree_name").read_text(encoding="utf-8").strip()
60
+ except OSError:
61
+ return None
62
+ return value or None
63
+
64
+ @property
65
+ def display_label(self) -> str:
66
+ """What the task list shows: the alias when set, else the real name."""
67
+ return self.display_name or self.name
68
+
51
69
 
52
70
  @dataclass
53
71
  class GitStatus:
@@ -337,6 +337,22 @@ class TaskManager:
337
337
  if task.path.exists():
338
338
  shutil.rmtree(task.path)
339
339
 
340
+ def set_task_display_name(self, task: Task, display_name: str | None) -> None:
341
+ """Set or clear the task's display alias (TUI label only).
342
+
343
+ The folder, branches and worktrees keep the real task name; the alias
344
+ lives in <task>/.tasktree_name. Empty input — or an alias equal to the
345
+ real name — clears the file.
346
+ """
347
+ marker = task.path / ".tasktree_name"
348
+ name = (display_name or "").strip()
349
+ if name == task.name:
350
+ name = ""
351
+ if name:
352
+ marker.write_text(name + "\n", encoding="utf-8")
353
+ elif marker.exists():
354
+ marker.unlink()
355
+
340
356
  def archive_task(self, task: Task, notes: list[str] | None = None) -> Path | None:
341
357
  """Write the task's combined diff to the archive directory.
342
358
 
@@ -462,6 +462,53 @@ class SafeDeleteModal(ThemedModalScreen[str | None]):
462
462
  self.dismiss("force")
463
463
 
464
464
 
465
+ class RenameTaskModal(ThemedModalScreen[str | None]):
466
+ """Prompt for a task's display alias (TUI label only).
467
+
468
+ Dismisses with the new alias, "" to revert to the real name, or None
469
+ when cancelled.
470
+ """
471
+
472
+ CANCEL_RESULT: ClassVar[None] = None
473
+
474
+ def __init__(self, task_name: str, current_alias: str | None, *args, **kwargs):
475
+ super().__init__(*args, **kwargs)
476
+ self.task_name = task_name
477
+ self.current_alias = current_alias
478
+
479
+ def compose(self) -> ComposeResult:
480
+ with Container():
481
+ yield Label("Rename Task (display only)", classes="modal-title")
482
+ yield Static(
483
+ escape(f"Folder/branch stay '{self.task_name}'; empty input reverts."),
484
+ classes="modal-message",
485
+ )
486
+ yield Input(
487
+ value=self.current_alias or "",
488
+ placeholder=self.task_name,
489
+ id="rename-input",
490
+ )
491
+ with Horizontal(classes="button-row"):
492
+ yield Button("Rename", variant="primary", id="rename-btn")
493
+ yield Button("Cancel", variant="default", id="cancel-btn")
494
+
495
+ def on_mount(self) -> None:
496
+ super().on_mount()
497
+ self.query_one("#rename-input", Input).focus()
498
+
499
+ def _submit(self) -> None:
500
+ self.dismiss(self.query_one("#rename-input", Input).value.strip())
501
+
502
+ def on_input_submitted(self, event: Input.Submitted) -> None:
503
+ self._submit()
504
+
505
+ def on_button_pressed(self, event: Button.Pressed) -> None:
506
+ if event.button.id == "rename-btn":
507
+ self._submit()
508
+ elif event.button.id == "cancel-btn":
509
+ self.dismiss(None)
510
+
511
+
465
512
  class DispatchAgentModal(ThemedModalScreen[str | None]):
466
513
  """Prompt for dispatching a background Claude agent into a worktree.
467
514
 
@@ -669,7 +716,12 @@ class HelpModal(ThemedModalScreen[None]):
669
716
  task_section += (
670
717
  self._format_binding("add_repo", "a", "Add repository to current task") + "\n"
671
718
  )
672
- task_section += self._format_binding("delete_task", "d", "Delete/finish current task")
719
+ task_section += (
720
+ self._format_binding("delete_task", "d", "Delete/finish current task") + "\n"
721
+ )
722
+ task_section += self._format_binding(
723
+ "rename_task", "R", "Rename task display label (folder/branch unchanged)"
724
+ )
673
725
  sections.append(task_section)
674
726
 
675
727
  # Tools section
@@ -88,10 +88,10 @@ class TaskList(OptionList):
88
88
  hook_indicator = " [green]✓[/]"
89
89
  else:
90
90
  hook_indicator = ""
91
- # Escape the name: task directories can be created outside tasktree,
92
- # and markup-significant brackets in a name must not style (or crash)
91
+ # Escape the label: task directories (and aliases) can hold
92
+ # markup-significant brackets which must not style (or crash)
93
93
  # the list rendering
94
- name = escape(task.name)
94
+ name = escape(task.display_label)
95
95
  if task.is_dirty:
96
96
  prompt = f"[red]●[/]{claude_md}{name} [red]({task.dirty_count})[/]{hook_indicator}"
97
97
  else:
@@ -220,9 +220,9 @@ class TaskList(OptionList):
220
220
  """Sort tasks according to current sort mode."""
221
221
  match self._sort_mode:
222
222
  case SortMode.NAME_ASC:
223
- return sorted(tasks, key=lambda t: t.name.lower())
223
+ return sorted(tasks, key=lambda t: t.display_label.lower())
224
224
  case SortMode.NAME_DESC:
225
- return sorted(tasks, key=lambda t: t.name.lower(), reverse=True)
225
+ return sorted(tasks, key=lambda t: t.display_label.lower(), reverse=True)
226
226
  case SortMode.DATE_NEWEST:
227
227
  return sorted(
228
228
  tasks,
@@ -392,6 +392,72 @@ class TestOpenExternalCommands:
392
392
  assert app.is_running
393
393
 
394
394
 
395
+ class TestRenameTaskAction:
396
+ """Tests for the rename (display alias) action."""
397
+
398
+ async def test_rename_via_keybinding(self, app, sample_repo, task_manager):
399
+ """R -> modal -> alias shows in the task list; folder unchanged."""
400
+ from tasktree_manager.widgets.create_modal import RenameTaskModal
401
+
402
+ repo_path, branch = sample_repo
403
+ task = task_manager.create_task("RENAME-TASK", ["sample-repo"], branch)
404
+
405
+ async with app.run_test() as pilot:
406
+ await pilot.pause()
407
+ await pilot.press("R")
408
+ await pilot.pause()
409
+ assert isinstance(app.screen_stack[-1], RenameTaskModal)
410
+
411
+ await pilot.press(*"nice label")
412
+ await pilot.press("enter")
413
+ await app.workers.wait_for_complete()
414
+ await pilot.pause()
415
+
416
+ assert (task.path / ".tasktree_name").read_text().strip() == "nice label"
417
+ assert task.path.exists() # folder untouched
418
+ task_list = app.query_one("#task-list", TaskList)
419
+ prompt = str(task_list.get_option("RENAME-TASK").prompt)
420
+ assert "nice label" in prompt
421
+ assert "RENAME-TASK" not in prompt
422
+
423
+ async def test_rename_cancel_changes_nothing(self, app, sample_repo, task_manager):
424
+ repo_path, branch = sample_repo
425
+ task = task_manager.create_task("RENAME-CANCEL", ["sample-repo"], branch)
426
+
427
+ async with app.run_test() as pilot:
428
+ await pilot.pause()
429
+ await pilot.press("R")
430
+ await pilot.pause()
431
+ await pilot.press("escape")
432
+ await pilot.pause()
433
+ assert not (task.path / ".tasktree_name").exists()
434
+
435
+ async def test_rename_empty_reverts(self, app, sample_repo, task_manager):
436
+ repo_path, branch = sample_repo
437
+ task = task_manager.create_task("RENAME-REVERT", ["sample-repo"], branch)
438
+ task_manager.set_task_display_name(task, "old label")
439
+
440
+ async with app.run_test() as pilot:
441
+ await pilot.pause()
442
+ await pilot.press("R")
443
+ await pilot.pause()
444
+ # Prefilled with the current alias - clear it
445
+ from textual.widgets import Input
446
+
447
+ from tasktree_manager.widgets.create_modal import RenameTaskModal
448
+
449
+ modal = app.screen_stack[-1]
450
+ assert isinstance(modal, RenameTaskModal)
451
+ modal.query_one("#rename-input", Input).value = ""
452
+ await pilot.press("enter")
453
+ await app.workers.wait_for_complete()
454
+ await pilot.pause()
455
+
456
+ assert not (task.path / ".tasktree_name").exists()
457
+ task_list = app.query_one("#task-list", TaskList)
458
+ assert "RENAME-REVERT" in str(task_list.get_option("RENAME-REVERT").prompt)
459
+
460
+
395
461
  class TestShowDiffAction:
396
462
  """Tests for the hunk diff action (h)."""
397
463
 
@@ -433,6 +433,48 @@ class TestTaskBaseRecording:
433
433
  assert GitOps.get_task_base(worktree_from_repo, "no-such-branch") is None
434
434
 
435
435
 
436
+ class TestDisplayName:
437
+ """Display alias: TUI label only, folder/branch untouched."""
438
+
439
+ def test_set_and_read_alias(self, task_manager, sample_repo):
440
+ repo_path, branch = sample_repo
441
+ task = task_manager.create_task("DIC-1901-argocd-tls", ["sample-repo"], branch)
442
+
443
+ task_manager.set_task_display_name(task, "ArgoCD TLS rollout")
444
+ assert task.display_name == "ArgoCD TLS rollout"
445
+ assert task.display_label == "ArgoCD TLS rollout"
446
+ # Real identity untouched
447
+ assert task.name == "DIC-1901-argocd-tls"
448
+ assert task.path.name == "DIC-1901-argocd-tls"
449
+ assert (task.path / ".tasktree_name").exists()
450
+ # A fresh load sees the alias too (read lazily from disk)
451
+ reloaded = task_manager.get_task("DIC-1901-argocd-tls")
452
+ assert reloaded is not None
453
+ assert reloaded.display_label == "ArgoCD TLS rollout"
454
+
455
+ def test_clear_alias_with_empty(self, task_manager, sample_repo):
456
+ repo_path, branch = sample_repo
457
+ task = task_manager.create_task("ALIAS-CLEAR", ["sample-repo"], branch)
458
+ task_manager.set_task_display_name(task, "temp label")
459
+ task_manager.set_task_display_name(task, "")
460
+ assert task.display_name is None
461
+ assert task.display_label == "ALIAS-CLEAR"
462
+ assert not (task.path / ".tasktree_name").exists()
463
+
464
+ def test_alias_equal_to_real_name_clears(self, task_manager, sample_repo):
465
+ repo_path, branch = sample_repo
466
+ task = task_manager.create_task("ALIAS-SAME", ["sample-repo"], branch)
467
+ task_manager.set_task_display_name(task, "ALIAS-SAME")
468
+ assert not (task.path / ".tasktree_name").exists()
469
+ assert task.display_label == "ALIAS-SAME"
470
+
471
+ def test_no_alias_by_default(self, task_manager, sample_repo):
472
+ repo_path, branch = sample_repo
473
+ task = task_manager.create_task("ALIAS-NONE", ["sample-repo"], branch)
474
+ assert task.display_name is None
475
+ assert task.display_label == "ALIAS-NONE"
476
+
477
+
436
478
  class TestForgeAwareSafety:
437
479
  """Squash/rebase merges detected via the forge (glab/gh) MR state."""
438
480