tasktree-manager 2.3.0__tar.gz → 2.5.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 (59) hide show
  1. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/CHANGELOG.md +12 -0
  2. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/docs/user-guide.md +7 -1
  4. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/pyproject.toml +1 -1
  5. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/_version.py +2 -2
  6. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/app.py +58 -28
  7. tasktree_manager-2.5.0/tasktree_manager/commands.py +69 -0
  8. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/task_list.py +15 -1
  9. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/worktree_list.py +14 -1
  10. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_app.py +94 -0
  11. tasktree_manager-2.5.0/tests/test_commands.py +55 -0
  12. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  13. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  14. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  15. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  16. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  17. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  18. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  19. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  20. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  21. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  22. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  23. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.github/workflows/ci.yml +0 -0
  24. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.github/workflows/release.yml +0 -0
  25. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/.gitignore +0 -0
  26. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/CONTRIBUTING.md +0 -0
  27. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/LICENSE +0 -0
  28. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/OPENSPEC.md +0 -0
  29. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/README.md +0 -0
  30. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/ROADMAP.md +0 -0
  31. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/docs/claude-code-integration-spec.md +0 -0
  32. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/docs/configuration.md +0 -0
  33. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/docs/installation.md +0 -0
  34. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/docs/troubleshooting.md +0 -0
  35. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/mise.toml +0 -0
  36. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/scripts/bump_version.py +0 -0
  37. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/__init__.py +0 -0
  38. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/services/__init__.py +0 -0
  39. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/services/claude_hooks.py +0 -0
  40. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/services/config.py +0 -0
  41. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/services/git_ops.py +0 -0
  42. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/services/models.py +0 -0
  43. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/services/task_manager.py +0 -0
  44. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/__init__.py +0 -0
  45. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/create_modal.py +0 -0
  46. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  47. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  48. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tasktree_manager/widgets/status_panel.py +0 -0
  49. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/__init__.py +0 -0
  50. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/conftest.py +0 -0
  51. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_config.py +0 -0
  52. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_git_ops.py +0 -0
  53. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_integration_git.py +0 -0
  54. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_memory.py +0 -0
  55. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_messages_panel.py +0 -0
  56. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_modals.py +0 -0
  57. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_setup_modal.py +0 -0
  58. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_status_panel.py +0 -0
  59. {tasktree_manager-2.3.0 → tasktree_manager-2.5.0}/tests/test_task_manager.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.5.0] - 2026-06-10
19
+
20
+ ### Added
21
+ - Searchable app actions in the command palette (#7)
22
+
23
+
24
+ ## [2.4.0] - 2026-06-10
25
+
26
+ ### Added
27
+ - Context-sensitive footer showing the focused panel's keys (#6)
28
+
29
+
18
30
  ## [2.3.0] - 2026-06-10
19
31
 
20
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree-manager
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -246,6 +246,12 @@ Shows detailed git status for the selected worktree:
246
246
 
247
247
  All keybindings can be customized in [`config.toml`](configuration.md).
248
248
 
249
+ The footer is context-sensitive, lazygit-style: it shows the actions for the
250
+ focused panel (task actions in the Task List, worktree/git actions in the
251
+ Worktree List) plus the always-available `r` Refresh, `?` Help, and `q` Quit.
252
+ Every key works regardless of which panel has focus — the footer only changes
253
+ what is displayed. Press `?` for the full reference at any time.
254
+
249
255
  ### Navigation
250
256
 
251
257
  | Key | Action | Description |
@@ -358,7 +364,7 @@ Useful when you're spinning up several similar tasks that share the same set of
358
364
 
359
365
  | Key | Action | Description |
360
366
  |---------|-------------------------|----------------------------------------------|
361
- | `Ctrl+P`| Open Command Palette | Switch themes and run commands |
367
+ | `Ctrl+P`| Open Command Palette | Search and run any action (hotkeys shown), switch themes |
362
368
  | `o` | Open folder | Open current folder in new terminal tab |
363
369
  | `m` | Toggle messages | Show/hide the activity messages panel |
364
370
  | `?` | Show help | Display help modal with keybindings |
@@ -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.3.0"
73
+ version = "2.5.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.3.0'
22
- __version_tuple__ = version_tuple = (2, 3, 0)
21
+ __version__ = version = '2.5.0'
22
+ __version_tuple__ = version_tuple = (2, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -10,6 +10,7 @@ from textual.binding import Binding
10
10
  from textual.containers import Horizontal, Vertical
11
11
  from textual.widgets import Footer, Header, Static
12
12
 
13
+ from .commands import TaskTreeCommands
13
14
  from .services.claude_hooks import ensure_claude_hooks, has_claude_session
14
15
  from .services.config import Config
15
16
  from .services.git_ops import GitOps
@@ -35,6 +36,10 @@ class TaskTreeApp(App):
35
36
 
36
37
  TITLE = "tasktree-manager"
37
38
 
39
+ # System commands plus all keybound tasktree actions (searchable by
40
+ # name, displayed with their configured hotkey)
41
+ COMMANDS = App.COMMANDS | {TaskTreeCommands}
42
+
38
43
  CSS = """
39
44
  /* Main layout */
40
45
  Screen {
@@ -210,19 +215,21 @@ class TaskTreeApp(App):
210
215
  }
211
216
  """
212
217
 
213
- # Default bindings - will be overridden in __init__ with config values
214
- # Footer order: n, a, d, g, o, c, p, r, m, q, ?
215
- # Uppercase shown only when shift is pressed: C, P, S
218
+ # Default bindings - will be overridden in __init__ with config values.
219
+ # The footer is context-sensitive: app-level bindings keep every key
220
+ # working globally but stay hidden, except the always-visible globals
221
+ # (refresh/help/quit). The focused panel contributes its own visible
222
+ # bindings (see compose), lazygit-style.
216
223
  BINDINGS = [
217
- Binding("n", "new_task", "New Task"),
218
- Binding("y", "clone_task", "Clone Task"),
219
- Binding("a", "add_repo", "Add Repo"),
220
- Binding("d", "delete_task", "Delete Task"),
221
- Binding("g", "open_lazygit", "Lazygit"),
222
- Binding("e", "open_editor", "Editor"),
223
- Binding("o", "open_folder", "Open"),
224
- Binding("c", "open_claude_resume", "Claude"),
225
- Binding("p", "push_all", "Push All"),
224
+ Binding("n", "new_task", "New", show=False),
225
+ Binding("y", "clone_task", "Clone", show=False),
226
+ Binding("a", "add_repo", "Add Repo", show=False),
227
+ Binding("d", "delete_task", "Delete", show=False),
228
+ Binding("g", "open_lazygit", "Lazygit", show=False),
229
+ Binding("e", "open_editor", "Editor", show=False),
230
+ Binding("o", "open_folder", "Open", show=False),
231
+ Binding("c", "open_claude_resume", "Claude", show=False),
232
+ Binding("p", "push_all", "Push", show=False),
226
233
  Binding("r", "refresh", "Refresh"),
227
234
  Binding("m", "toggle_messages", "Messages"),
228
235
  Binding("q", "quit", "Quit"),
@@ -231,7 +238,7 @@ class TaskTreeApp(App):
231
238
  Binding("C", "open_claude_gui_code", "Claude GUI", show=False),
232
239
  Binding("P", "pull_all", "Pull All", show=False),
233
240
  Binding("S", "toggle_grouping", "Group", show=False),
234
- Binding("D", "delete_worktree", "Del Worktree", show=False),
241
+ Binding("D", "delete_worktree", "Del WT", show=False),
235
242
  Binding("tab", "focus_next", "Next Panel", show=False),
236
243
  Binding("shift+tab", "focus_previous", "Prev Panel", show=False),
237
244
  Binding("j", "cursor_down", "Down", show=False),
@@ -264,24 +271,26 @@ class TaskTreeApp(App):
264
271
  self._last_tasks_fingerprint: tuple | None = None
265
272
 
266
273
  def _build_bindings_from_config(self) -> list[Binding]:
267
- """Build keybindings list from config.
274
+ """Build app-level keybindings from config.
275
+
276
+ Every key works globally regardless of focus, but only the global
277
+ actions (refresh/help/quit) are shown in the footer; the focused
278
+ panel contributes its own visible bindings (see compose).
268
279
 
269
280
  Returns:
270
281
  List of Binding objects with keys from config
271
282
  """
272
283
  kb = self.config.keybindings
273
- # Footer order: n, a, d, g, o, c, p, r, m, q, ?
274
- # Uppercase shown only when shift is pressed: C, P, S
275
284
  return [
276
- Binding(kb.get("new_task", "n"), "new_task", "New Task"),
277
- Binding(kb.get("clone_task", "y"), "clone_task", "Clone Task"),
278
- Binding(kb.get("add_repo", "a"), "add_repo", "Add Repo"),
279
- Binding(kb.get("delete_task", "d"), "delete_task", "Delete Task"),
280
- Binding(kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit"),
281
- Binding(kb.get("open_editor", "e"), "open_editor", "Editor"),
282
- Binding(kb.get("open_folder", "o"), "open_folder", "Open"),
283
- Binding(kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
284
- Binding(kb.get("push_all", "p"), "push_all", "Push All"),
285
+ Binding(kb.get("new_task", "n"), "new_task", "New", show=False),
286
+ Binding(kb.get("clone_task", "y"), "clone_task", "Clone", show=False),
287
+ Binding(kb.get("add_repo", "a"), "add_repo", "Add Repo", show=False),
288
+ Binding(kb.get("delete_task", "d"), "delete_task", "Delete", show=False),
289
+ Binding(kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit", show=False),
290
+ Binding(kb.get("open_editor", "e"), "open_editor", "Editor", show=False),
291
+ Binding(kb.get("open_folder", "o"), "open_folder", "Open", show=False),
292
+ Binding(kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude", show=False),
293
+ Binding(kb.get("push_all", "p"), "push_all", "Push", show=False),
285
294
  Binding(kb.get("refresh", "r"), "refresh", "Refresh"),
286
295
  Binding(kb.get("toggle_messages", "m"), "toggle_messages", "Messages"),
287
296
  Binding(kb.get("quit", "q"), "quit", "Quit"),
@@ -295,7 +304,7 @@ class TaskTreeApp(App):
295
304
  ),
296
305
  Binding(kb.get("pull_all", "P"), "pull_all", "Pull All", show=False),
297
306
  Binding(kb.get("toggle_grouping", "S"), "toggle_grouping", "Group", show=False),
298
- Binding(kb.get("delete_worktree", "D"), "delete_worktree", "Del Worktree", show=False),
307
+ Binding(kb.get("delete_worktree", "D"), "delete_worktree", "Del WT", show=False),
299
308
  Binding(kb.get("focus_next", "tab"), "focus_next", "Next Panel", show=False),
300
309
  Binding(
301
310
  kb.get("focus_previous", "shift+tab"), "focus_previous", "Prev Panel", show=False
@@ -312,16 +321,37 @@ class TaskTreeApp(App):
312
321
 
313
322
  def compose(self) -> ComposeResult:
314
323
  """Compose the app layout."""
324
+ kb = self.config.keybindings
325
+ # Footer bindings shown only while the respective panel has focus.
326
+ # The keys themselves also exist app-level (hidden) so they work
327
+ # regardless of focus; these entries only add footer visibility.
328
+ task_panel_bindings = [
329
+ (kb.get("new_task", "n"), "new_task", "New"),
330
+ (kb.get("clone_task", "y"), "clone_task", "Clone"),
331
+ (kb.get("add_repo", "a"), "add_repo", "Add Repo"),
332
+ (kb.get("delete_task", "d"), "delete_task", "Delete"),
333
+ (kb.get("open_claude_resume", "c"), "open_claude_resume", "Claude"),
334
+ (kb.get("cycle_sort", "s"), "cycle_sort", "Sort"),
335
+ ]
336
+ worktree_panel_bindings = [
337
+ (kb.get("open_lazygit", "g"), "open_lazygit", "Lazygit"),
338
+ (kb.get("open_editor", "e"), "open_editor", "Editor"),
339
+ (kb.get("open_folder", "o"), "open_folder", "Open"),
340
+ (kb.get("open_shell", "enter"), "open_shell", "Shell"),
341
+ (kb.get("push_all", "p"), "push_all", "Push"),
342
+ (kb.get("delete_worktree", "D"), "delete_worktree", "Del WT"),
343
+ ]
344
+
315
345
  yield Header()
316
346
  with Horizontal(id="main-container"):
317
347
  # Left column: Tasks (top) and Worktrees (bottom)
318
348
  with Vertical(id="left-column"):
319
349
  with Vertical(id="task-panel"):
320
350
  yield Static("Tasks", classes="panel-title", id="task-panel-title")
321
- yield TaskList(id="task-list")
351
+ yield TaskList(id="task-list", context_bindings=task_panel_bindings)
322
352
  with Vertical(id="worktree-panel"):
323
353
  yield Static("Worktrees", classes="panel-title", id="worktree-panel-title")
324
- yield WorktreeList(id="worktree-list")
354
+ yield WorktreeList(id="worktree-list", context_bindings=worktree_panel_bindings)
325
355
  # Right column: Info/Status panel
326
356
  with Vertical(id="right-column"):
327
357
  with Vertical(id="status-panel"):
@@ -0,0 +1,69 @@
1
+ """Command palette provider for tasktree-manager actions."""
2
+
3
+ from functools import partial
4
+
5
+ from textual.command import DiscoveryHit, Hit, Hits, Provider
6
+
7
+ # (action, title, help) for every keybound app action. The key for each
8
+ # action is looked up from the app's config at search time so customized
9
+ # keybindings display correctly.
10
+ APP_COMMANDS: list[tuple[str, str, str]] = [
11
+ ("new_task", "New Task", "Create a new task with worktrees"),
12
+ ("clone_task", "Clone Task", "Create a new task with the same repos as the current one"),
13
+ ("add_repo", "Add Repo", "Add repositories to the current task"),
14
+ ("delete_task", "Delete Task", "Delete the current task (with safety checks)"),
15
+ ("delete_worktree", "Delete Worktree", "Delete the selected worktree from the task"),
16
+ ("open_lazygit", "Open Lazygit", "Open lazygit in the selected worktree"),
17
+ ("open_editor", "Open Editor", "Open your editor in the focused task/worktree folder"),
18
+ ("open_folder", "Open Folder", "Open the focused folder in a new terminal tab"),
19
+ ("open_shell", "Open Shell", "Open a shell in the selected worktree"),
20
+ ("open_claude_resume", "Open Claude", "Open Claude Code for the task (resume if possible)"),
21
+ (
22
+ "open_claude_gui_code",
23
+ "Open Claude Desktop",
24
+ "Open the Claude desktop app on the Code page",
25
+ ),
26
+ ("push_all", "Push All", "Push all worktrees in the current task"),
27
+ ("pull_all", "Pull All", "Pull all worktrees in the current task"),
28
+ ("refresh", "Refresh", "Refresh git status for all tasks"),
29
+ ("toggle_messages", "Toggle Messages", "Show or hide the activity messages panel"),
30
+ ("toggle_grouping", "Toggle Grouping", "Group worktrees by dirty/clean status"),
31
+ ("cycle_sort", "Cycle Sort", "Cycle through task sort modes"),
32
+ ("help", "Help", "Show the keybinding reference"),
33
+ ("quit", "Quit", "Exit tasktree-manager"),
34
+ ]
35
+
36
+
37
+ class TaskTreeCommands(Provider):
38
+ """Expose all keybound tasktree actions in the command palette."""
39
+
40
+ def _entries(self):
41
+ """Yield (display title with hotkey, action, help) tuples."""
42
+ config = getattr(self.app, "config", None)
43
+ keybindings = config.keybindings if config else {}
44
+ for action, title, help_text in APP_COMMANDS:
45
+ key = keybindings.get(action, "")
46
+ display = f"{title} ({key})" if key else title
47
+ yield display, action, help_text
48
+
49
+ async def discover(self) -> Hits:
50
+ """List all commands when the palette opens with an empty query."""
51
+ for display, action, help_text in self._entries():
52
+ yield DiscoveryHit(
53
+ display,
54
+ partial(self.app.run_action, action),
55
+ help=help_text,
56
+ )
57
+
58
+ async def search(self, query: str) -> Hits:
59
+ """Fuzzy-match commands against the query."""
60
+ matcher = self.matcher(query)
61
+ for display, action, help_text in self._entries():
62
+ score = matcher.match(display)
63
+ if score > 0:
64
+ yield Hit(
65
+ score,
66
+ matcher.highlight(display),
67
+ partial(self.app.run_action, action),
68
+ help=help_text,
69
+ )
@@ -45,12 +45,26 @@ class TaskList(OptionList):
45
45
  self.label = label
46
46
  super().__init__()
47
47
 
48
- def __init__(self, *args, **kwargs):
48
+ def __init__(
49
+ self,
50
+ *args,
51
+ context_bindings: list[tuple[str, str, str]] | None = None,
52
+ **kwargs,
53
+ ):
49
54
  super().__init__(*args, **kwargs)
50
55
  self.tasks: list[Task] = []
51
56
  self._sort_mode: SortMode = SortMode.NAME_ASC
52
57
  # Last known Claude session statuses, kept so indicators survive reloads
53
58
  self._claude_statuses: dict[str, str] = {}
59
+ # Footer-visible (key, app action, description) bindings shown while
60
+ # this panel has focus; the keys also exist app-level (hidden) so
61
+ # they keep working regardless of focus
62
+ for key, action, description in context_bindings or []:
63
+ # Drop any inherited binding on the same key - dispatch and the
64
+ # footer both use the first binding found, so an OptionList
65
+ # default (e.g. "enter") would otherwise shadow the app action
66
+ self._bindings.key_to_bindings.pop(key, None)
67
+ self._bindings.bind(key, f"app.{action}", description, show=True)
54
68
 
55
69
  @property
56
70
  def index(self) -> int | None:
@@ -31,12 +31,25 @@ class WorktreeList(OptionList):
31
31
  self.enabled = enabled
32
32
  super().__init__()
33
33
 
34
- def __init__(self, *args, **kwargs):
34
+ def __init__(
35
+ self,
36
+ *args,
37
+ context_bindings: list[tuple[str, str, str]] | None = None,
38
+ **kwargs,
39
+ ):
35
40
  super().__init__(*args, **kwargs)
36
41
  self.worktrees: list[Worktree] = []
37
42
  self._grouping_enabled: bool = False
38
43
  # Maps option index to worktree index (for handling headers)
39
44
  self._option_to_worktree: dict[int, int] = {}
45
+ # Footer-visible (key, app action, description) bindings shown while
46
+ # this panel has focus; the keys also exist app-level (hidden) so
47
+ # they keep working regardless of focus. Replacing "enter" here also
48
+ # fixes shell opening: OptionList's own select binding used to
49
+ # swallow the key before the app's open_shell binding could see it.
50
+ for key, action, description in context_bindings or []:
51
+ self._bindings.key_to_bindings.pop(key, None)
52
+ self._bindings.bind(key, f"app.{action}", description, show=True)
40
53
 
41
54
  @property
42
55
  def index(self) -> int | None:
@@ -570,3 +570,97 @@ class TestAppConfiguration:
570
570
  from tasktree_manager.services.git_ops import GitOps
571
571
 
572
572
  assert GitOps.network_timeout == app.config.git_timeout
573
+
574
+
575
+ class TestContextSensitiveFooter:
576
+ """Tests for the context-sensitive footer bindings."""
577
+
578
+ @staticmethod
579
+ def _shown_actions(app) -> set[str]:
580
+ """Actions currently visible in the footer."""
581
+ return {
582
+ active.binding.action
583
+ for active in app.screen.active_bindings.values()
584
+ if active.binding.show
585
+ }
586
+
587
+ async def test_task_panel_footer_shows_task_actions(self, app, sample_repo, task_manager):
588
+ """Task list focused: task actions plus globals are visible."""
589
+ repo_path, branch = sample_repo
590
+ task_manager.create_task("FOOTER-TASKS", ["sample-repo"], branch)
591
+
592
+ async with app.run_test() as pilot:
593
+ await pilot.pause()
594
+
595
+ assert app.focused.id == "task-list"
596
+ shown = self._shown_actions(app)
597
+ assert "app.new_task" in shown
598
+ assert "app.clone_task" in shown
599
+ assert "app.delete_task" in shown
600
+ # Worktree-context actions are hidden in this context
601
+ assert "app.open_lazygit" not in shown
602
+ assert "app.open_shell" not in shown
603
+ # Globals always visible
604
+ assert "refresh" in shown
605
+ assert "toggle_messages" in shown
606
+ assert "help" in shown
607
+ assert "quit" in shown
608
+
609
+ async def test_worktree_panel_footer_shows_worktree_actions(
610
+ self, app, sample_repo, task_manager
611
+ ):
612
+ """Worktree list focused: worktree actions plus globals are visible."""
613
+ repo_path, branch = sample_repo
614
+ task_manager.create_task("FOOTER-WT", ["sample-repo"], branch)
615
+
616
+ async with app.run_test() as pilot:
617
+ await pilot.pause()
618
+ await pilot.press("tab")
619
+ await pilot.pause()
620
+
621
+ assert app.focused.id == "worktree-list"
622
+ shown = self._shown_actions(app)
623
+ assert "app.open_lazygit" in shown
624
+ assert "app.open_shell" in shown
625
+ assert "app.push_all" in shown
626
+ # Task-context actions are hidden in this context
627
+ assert "app.new_task" not in shown
628
+ # Globals always visible
629
+ assert "refresh" in shown
630
+ assert "quit" in shown
631
+
632
+ async def test_keys_work_outside_their_panel(self, app, sample_repos, task_manager):
633
+ """A task-context key still works while the worktree list is focused."""
634
+ _, branch = sample_repos
635
+ task_manager.create_task("FOOTER-KEYS", ["repo-alpha"], branch)
636
+
637
+ async with app.run_test() as pilot:
638
+ await pilot.pause()
639
+ await pilot.press("tab") # focus worktree list
640
+ await pilot.pause()
641
+
642
+ # 'n' is a task-panel binding but must still open the modal
643
+ await pilot.press("n")
644
+ await pilot.pause()
645
+
646
+ create_modals = [s for s in app.screen_stack if isinstance(s, CreateTaskModal)]
647
+ assert len(create_modals) == 1
648
+
649
+ async def test_enter_opens_shell_from_worktree_list(
650
+ self, app, sample_repo, task_manager, monkeypatch
651
+ ):
652
+ """Enter on the worktree list triggers open_shell (was swallowed by select)."""
653
+ repo_path, branch = sample_repo
654
+ task_manager.create_task("FOOTER-ENTER", ["sample-repo"], branch)
655
+
656
+ called = []
657
+ monkeypatch.setattr(app, "action_open_shell", lambda: called.append(True))
658
+
659
+ async with app.run_test() as pilot:
660
+ await pilot.pause()
661
+ await pilot.press("tab")
662
+ await pilot.pause()
663
+ await pilot.press("enter")
664
+ await pilot.pause()
665
+
666
+ assert called
@@ -0,0 +1,55 @@
1
+ """Tests for the command palette provider."""
2
+
3
+ from tasktree_manager.app import TaskTreeApp
4
+ from tasktree_manager.commands import APP_COMMANDS, TaskTreeCommands
5
+
6
+
7
+ class TestTaskTreeCommands:
8
+ """Tests for the TaskTreeCommands palette provider."""
9
+
10
+ async def test_app_registers_provider(self, app):
11
+ """The app exposes the provider alongside the system commands."""
12
+ assert TaskTreeCommands in TaskTreeApp.COMMANDS
13
+
14
+ async def test_discover_lists_all_commands(self, app):
15
+ """Opening the palette with an empty query lists every action."""
16
+ async with app.run_test() as pilot:
17
+ await pilot.pause()
18
+
19
+ provider = TaskTreeCommands(app.screen)
20
+ hits = [hit async for hit in provider.discover()]
21
+
22
+ assert len(hits) == len(APP_COMMANDS)
23
+
24
+ async def test_search_finds_action_with_hotkey(self, app):
25
+ """Searching shows the matching command with its hotkey."""
26
+ async with app.run_test() as pilot:
27
+ await pilot.pause()
28
+
29
+ provider = TaskTreeCommands(app.screen)
30
+ hits = [hit async for hit in provider.search("lazygit")]
31
+
32
+ assert hits
33
+ assert any("(g)" in str(hit.match_display) for hit in hits)
34
+
35
+ async def test_search_shows_customized_key(self, app):
36
+ """A remapped keybinding shows the configured key, not the default."""
37
+ app.config.keybindings["push_all"] = "x"
38
+
39
+ async with app.run_test() as pilot:
40
+ await pilot.pause()
41
+
42
+ provider = TaskTreeCommands(app.screen)
43
+ hits = [hit async for hit in provider.search("push all")]
44
+
45
+ assert any("(x)" in str(hit.match_display) for hit in hits)
46
+
47
+ async def test_search_no_match_returns_nothing(self, app):
48
+ """A nonsense query yields no hits."""
49
+ async with app.run_test() as pilot:
50
+ await pilot.pause()
51
+
52
+ provider = TaskTreeCommands(app.screen)
53
+ hits = [hit async for hit in provider.search("zzz-no-such-command")]
54
+
55
+ assert hits == []