gitdirector 1.7.2__tar.gz → 1.7.4__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 (60) hide show
  1. {gitdirector-1.7.2 → gitdirector-1.7.4}/PKG-INFO +5 -2
  2. {gitdirector-1.7.2 → gitdirector-1.7.4}/README.md +3 -1
  3. {gitdirector-1.7.2 → gitdirector-1.7.4}/pyproject.toml +2 -1
  4. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/capture.py +1 -2
  5. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/gd_send.py +2 -1
  6. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/gd_tmux.py +6 -19
  7. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/help.py +4 -1
  8. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/info.py +11 -20
  9. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/link.py +15 -0
  10. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/__init__.py +1 -2
  11. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app.py +65 -20
  12. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_groups.py +1 -1
  13. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_panels.py +24 -18
  14. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_repos.py +26 -20
  15. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_sessions.py +4 -7
  16. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_ui.py +10 -0
  17. gitdirector-1.7.4/src/gitdirector/commands/tui/panel_view.py +211 -0
  18. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/panels.py +126 -21
  19. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/commit.py +23 -12
  20. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/diff.py +18 -27
  21. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/panels.py +16 -6
  22. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/sessions.py +31 -9
  23. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/terminal_widget.py +6 -2
  24. gitdirector-1.7.4/src/gitdirector/commands/unlink.py +48 -0
  25. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/config.py +45 -17
  26. gitdirector-1.7.4/src/gitdirector/integrations/tmux/__init__.py +108 -0
  27. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/core.py +31 -10
  28. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/monitor.py +6 -1
  29. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/panels.py +13 -1
  30. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/manager.py +83 -1
  31. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/repo.py +7 -0
  32. gitdirector-1.7.2/src/gitdirector/commands/tui/panel_view.py +0 -478
  33. gitdirector-1.7.2/src/gitdirector/commands/unlink.py +0 -48
  34. gitdirector-1.7.2/src/gitdirector/integrations/tmux/__init__.py +0 -5
  35. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/__init__.py +0 -0
  36. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/cli.py +0 -0
  37. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/__init__.py +0 -0
  38. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/autoclean.py +0 -0
  39. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/cd.py +0 -0
  40. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/completion.py +0 -0
  41. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/listt.py +0 -0
  42. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/pull.py +0 -0
  43. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/reset.py +0 -0
  44. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/status.py +0 -0
  45. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/constants.py +0 -0
  46. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/diff_renderer.py +0 -0
  47. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/__init__.py +0 -0
  48. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/_shared.py +0 -0
  49. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/diff_files.py +0 -0
  50. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/groups.py +0 -0
  51. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/repos.py +0 -0
  52. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/session_actions.py +0 -0
  53. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/table_text.py +0 -0
  54. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/commands/tui/terminal_caps.py +0 -0
  55. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/github_credential_helper.py +0 -0
  56. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/info.py +0 -0
  57. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/integrations/__init__.py +0 -0
  58. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/storage.py +0 -0
  59. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/ui_theme.py +0 -0
  60. {gitdirector-1.7.2 → gitdirector-1.7.4}/src/gitdirector/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gitdirector
3
- Version: 1.7.2
3
+ Version: 1.7.4
4
4
  Summary: A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place.
5
5
  Keywords: git,repository,manager,cli,synchronization,batch
6
6
  Author: Anito Anto
@@ -24,6 +24,7 @@ Requires-Dist: rich>=12.0
24
24
  Requires-Dist: textual>=8.2.1
25
25
  Requires-Dist: tiktoken>=0.5.0
26
26
  Requires-Dist: pyte>=0.8.2
27
+ Requires-Dist: pathspec>=1.0.0
27
28
  Requires-Dist: pytest>=7.0 ; extra == 'dev'
28
29
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
29
30
  Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
@@ -67,7 +68,7 @@ If you find GitDirector useful, please star this repository on GitHub, we need m
67
68
  | Command | Description |
68
69
  | ---------------------------------------------------------- | ------------------------------------------------------ |
69
70
  | `gitdirector console` | Open the interactive TUI dashboard |
70
- | `gitdirector link PATH [--discover]` | Link a repository or discover all under a path |
71
+ | `gitdirector link PATH [--discover]` | Link a repository or discover all under a path (no-op if none) |
71
72
  | `gitdirector unlink PATH\|NAME [--discover]` | Unlink a repository by path, name, or all under a path |
72
73
  | `gitdirector list` | List all tracked repositories with live status |
73
74
  | `gitdirector status` | Show repositories with staged/unstaged files |
@@ -89,6 +90,8 @@ gitdirector link /path/to/repo
89
90
  gitdirector link /path/to/folder --discover # recursively find and link all repos
90
91
  ```
91
92
 
93
+ `link --discover` succeeds without changes when the path contains no new repositories.
94
+
92
95
  ### console
93
96
 
94
97
  ```bash
@@ -25,7 +25,7 @@ If you find GitDirector useful, please star this repository on GitHub, we need m
25
25
  | Command | Description |
26
26
  | ---------------------------------------------------------- | ------------------------------------------------------ |
27
27
  | `gitdirector console` | Open the interactive TUI dashboard |
28
- | `gitdirector link PATH [--discover]` | Link a repository or discover all under a path |
28
+ | `gitdirector link PATH [--discover]` | Link a repository or discover all under a path (no-op if none) |
29
29
  | `gitdirector unlink PATH\|NAME [--discover]` | Unlink a repository by path, name, or all under a path |
30
30
  | `gitdirector list` | List all tracked repositories with live status |
31
31
  | `gitdirector status` | Show repositories with staged/unstaged files |
@@ -47,6 +47,8 @@ gitdirector link /path/to/repo
47
47
  gitdirector link /path/to/folder --discover # recursively find and link all repos
48
48
  ```
49
49
 
50
+ `link --discover` succeeds without changes when the path contains no new repositories.
51
+
50
52
  ### console
51
53
 
52
54
  ```bash
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "gitdirector"
7
- version = "1.7.2"
7
+ version = "1.7.4"
8
8
  description = "A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -37,6 +37,7 @@ dependencies = [
37
37
  "textual>=8.2.1",
38
38
  "tiktoken>=0.5.0",
39
39
  "pyte>=0.8.2",
40
+ "pathspec>=1.0.0",
40
41
  ]
41
42
 
42
43
  [project.optional-dependencies]
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
  import click
19
19
 
20
20
  from ..integrations.tmux import capture_pane
21
+ from ..integrations.tmux.core import _parse_gd_session_name
21
22
 
22
23
 
23
24
  def _resolve_session_name(name: str) -> str:
@@ -26,8 +27,6 @@ def _resolve_session_name(name: str) -> str:
26
27
  gd-capture refuses anything that doesn't match the ``gd/{repo}/{purpose}/{N}``
27
28
  shape — that way a typo can't be silently routed to the wrong session.
28
29
  """
29
- from ..integrations.tmux import _parse_gd_session_name
30
-
31
30
  parsed = _parse_gd_session_name(name)
32
31
  if parsed is None:
33
32
  raise click.BadParameter(
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import click
4
4
 
5
- from ..integrations.tmux import _parse_gd_session_name, send_key_to_session, send_text_to_session
5
+ from ..integrations.tmux import send_key_to_session, send_text_to_session
6
+ from ..integrations.tmux.core import _parse_gd_session_name
6
7
 
7
8
  SUPPORTED_KEYS = ("C-c",)
8
9
 
@@ -3,7 +3,6 @@ from pathlib import Path
3
3
  import click
4
4
 
5
5
  from ..manager import RepositoryManager
6
- from ..storage import normalize_repository_path
7
6
  from . import console
8
7
  from .completion import complete_repository_names
9
8
 
@@ -17,24 +16,12 @@ def _resolve_repo(target: str) -> Path | None:
17
16
  or ambiguous targets.
18
17
  """
19
18
  manager = RepositoryManager()
20
- candidate = Path(target)
21
- is_path_like = (
22
- "/" in target
23
- or "\\" in target
24
- or target in (".", "..")
25
- or candidate.is_absolute()
26
- or target.startswith("~")
27
- or candidate.exists()
28
- )
29
-
30
- if is_path_like:
31
- normalized = normalize_repository_path(candidate)
32
- if manager.config.has_repository(normalized):
33
- return normalized
34
- console.print(f"\n [red]No tracked repository at path: {normalized}[/red]\n")
19
+ repo_path, matches, path_attempted = manager.resolve_repository_target(target)
20
+ if repo_path is not None:
21
+ return repo_path
22
+ if path_attempted:
23
+ console.print(f"\n [red]No tracked repository at path: {target}[/red]\n")
35
24
  return None
36
-
37
- matches = [r for r in manager.config.repositories if r.name == target]
38
25
  if not matches:
39
26
  console.print(f"\n [red]No tracked repository named: {target}[/red]\n")
40
27
  return None
@@ -45,7 +32,7 @@ def _resolve_repo(target: str) -> Path | None:
45
32
  f"{paths_list}\n"
46
33
  )
47
34
  return None
48
- return matches[0]
35
+ return None
49
36
 
50
37
 
51
38
  def register(cli: click.Group):
@@ -26,7 +26,10 @@ def show_help():
26
26
  cmd_table.add_column("desc", style="dim")
27
27
 
28
28
  for cmd, desc in [
29
- ("link PATH [--discover]", "Link a repository or discover all repos under a path"),
29
+ (
30
+ "link PATH [--discover]",
31
+ "Link a repository or discover all repos under a path (no-op if none)",
32
+ ),
30
33
  ("unlink PATH|NAME [--discover]", "Unlink a repository or all repos under a path"),
31
34
  ("list", "List all tracked repositories"),
32
35
  ("status", "Show status summary and per-repo details"),
@@ -46,29 +46,20 @@ def register(cli: click.Group):
46
46
  def info(target: str, full: bool):
47
47
  """Show file statistics for a repository."""
48
48
  manager = RepositoryManager()
49
- candidate_path = Path(target).expanduser()
50
-
51
- if candidate_path.is_dir() and (candidate_path / ".git").is_dir():
52
- repo_path = candidate_path.resolve()
53
- else:
54
- repos = manager.config.repositories
55
- target_lower = target.lower()
56
- exact = [r for r in repos if r.name.lower() == target_lower]
57
- if exact:
58
- matches = exact
59
- else:
60
- matches = [r for r in repos if target_lower in r.name.lower()]
61
-
49
+ repo_path, matches, _path_attempted = manager.resolve_repository_target(
50
+ target,
51
+ allow_untracked_git_path=True,
52
+ fuzzy_names=True,
53
+ )
54
+ if repo_path is None:
62
55
  if not matches:
63
56
  console.print(f"\n [red]Repository '{target}' not found[/red]\n")
64
57
  raise SystemExit(1)
65
- if len(matches) > 1:
66
- console.print(f"\n [red]Multiple repositories match '{target}':[/red]")
67
- for m in matches:
68
- console.print(f" {m}")
69
- console.print()
70
- raise SystemExit(1)
71
- repo_path = matches[0]
58
+ console.print(f"\n [red]Multiple repositories match '{target}':[/red]")
59
+ for match in matches:
60
+ console.print(f" {match}")
61
+ console.print()
62
+ raise SystemExit(1)
72
63
 
73
64
  result = gather_repo_info(repo_path, full=full)
74
65
  _render_info_cli(result, repo_path.name, repo_path)
@@ -28,6 +28,21 @@ def register(cli: click.Group):
28
28
  else:
29
29
  for repo_path in repos:
30
30
  console.print(f" [green]+[/green] {repo_path}")
31
+ elif (
32
+ discover
33
+ and not repos
34
+ and message
35
+ in {
36
+ "No git repositories found",
37
+ "No new repositories found",
38
+ }
39
+ ):
40
+ console.print(f" {message}")
41
+ for repo_path in skipped:
42
+ console.print(
43
+ f" [dim yellow]\\[skipped][/dim yellow] "
44
+ f"[bright_black]{repo_path}[/bright_black]"
45
+ )
31
46
  else:
32
47
  console.print(f" [red]{message}[/red]")
33
48
  console.print()
@@ -13,7 +13,7 @@ from .constants import (
13
13
  _changes_label,
14
14
  _changes_sort_key,
15
15
  )
16
- from .panel_view import PanelViewScreen, PaneWidget
16
+ from .panel_view import PaneWidget
17
17
  from .panels import Panel, PanelStore
18
18
  from .screens import SortMenuScreen
19
19
  from .screens.diff import DiffReviewScreen
@@ -51,7 +51,6 @@ __all__ = [
51
51
  "Panel",
52
52
  "PanelActionMenuScreen",
53
53
  "PanelStore",
54
- "PanelViewScreen",
55
54
  "_PANELS_SORT_COLUMN_NAMES",
56
55
  "PullLoadingScreen",
57
56
  "PullResultScreen",
@@ -126,25 +126,18 @@ class GitDirectorConsole(
126
126
  padding: 0 1;
127
127
  scrollbar-size-horizontal: 0;
128
128
  }
129
- #no-repos-message {
130
- height: 1fr;
129
+ #repo-table,
130
+ #sessions-table,
131
+ #panels-table,
132
+ #no-repos-message,
133
+ #no-sessions-message,
134
+ #no-panels-message {
131
135
  display: none;
132
- align: center middle;
133
- color: $text-muted;
134
- padding: 2 4;
135
- content-align: center middle;
136
- }
137
- #no-sessions-message {
138
- height: 1fr;
139
- display: none;
140
- align: center middle;
141
- color: $text-muted;
142
- padding: 2 4;
143
- content-align: center middle;
144
136
  }
137
+ #no-repos-message,
138
+ #no-sessions-message,
145
139
  #no-panels-message {
146
140
  height: 1fr;
147
- display: none;
148
141
  align: center middle;
149
142
  color: $text-muted;
150
143
  padding: 2 4;
@@ -204,7 +197,7 @@ class GitDirectorConsole(
204
197
  Binding("1", "tab_repos", "Repos", show=False),
205
198
  Binding("2", "tab_sessions", "Sessions", show=False),
206
199
  Binding("3", "tab_panels", "Panels", show=False),
207
- Binding("space", "toggle_group", "Toggle Group", show=True),
200
+ Binding("space", "toggle_group", "Toggle", show=True),
208
201
  Binding("n", "new_panel", "New Panel", show=True),
209
202
  ]
210
203
 
@@ -493,7 +486,7 @@ class GitDirectorConsole(
493
486
  skip_config_sync: bool = False,
494
487
  callback: Callable[[object], None] | None = None,
495
488
  ) -> None:
496
- from ...integrations.tmux import _parse_gd_session_name
489
+ from ...integrations.tmux.core import _parse_gd_session_name
497
490
 
498
491
  title = "session"
499
492
  loading_hint = "waiting for session to initialize\u2026"
@@ -677,7 +670,11 @@ class GitDirectorConsole(
677
670
  def action_show_info(self) -> None:
678
671
  if self._active_tab != "repos":
679
672
  return
680
- if self._selected_repo_row_is_group():
673
+ group = self._get_selected_group()
674
+ if group is not None:
675
+ screen = RepoInfoScreen(f"{group.name} ({group.repo_count} repos)", group.path)
676
+ self.push_screen(screen)
677
+ self._gather_and_show_group_info(group, screen)
681
678
  return
682
679
  path = self._get_selected_path()
683
680
  if path is None:
@@ -690,7 +687,7 @@ class GitDirectorConsole(
690
687
  """Open the description editor for the currently highlighted session row."""
691
688
  if self._active_tab != "sessions":
692
689
  return
693
- from ...integrations.tmux import _get_session_description
690
+ from ...integrations.tmux.core import _get_session_description
694
691
 
695
692
  table = self.query_one("#sessions-table", DataTable)
696
693
  session_name = self._get_selected_row_key(table)
@@ -705,7 +702,7 @@ class GitDirectorConsole(
705
702
  def _handle_description_edit(self, session_name: str, value: str | None) -> None:
706
703
  if value is None:
707
704
  return
708
- from ...integrations.tmux import _set_session_description
705
+ from ...integrations.tmux.core import _set_session_description
709
706
 
710
707
  _set_session_description(session_name, value)
711
708
  for entry in self._sessions_entries:
@@ -740,6 +737,54 @@ class GitDirectorConsole(
740
737
  return
741
738
  self.call_from_thread(screen.populate, result)
742
739
 
740
+ @work(thread=True)
741
+ def _gather_and_show_group_info(self, group, screen: RepoInfoScreen) -> None:
742
+ worker = self._current_worker_or_none()
743
+ if self._background_shutdown_requested(worker):
744
+ return
745
+
746
+ from ...info import FileTypeInfo, RepoInfoResult, gather_repo_info
747
+
748
+ try:
749
+ results = [gather_repo_info(path) for path in group.repositories]
750
+ except Exception as exc:
751
+ if self._background_shutdown_requested(worker):
752
+ return
753
+ self.call_from_thread(screen.show_error, str(exc))
754
+ return
755
+
756
+ file_types: dict[str, tuple[int, int, int, bool]] = {}
757
+ for result in results:
758
+ for file_type in result.file_types:
759
+ count, lines, tokens, has_text = file_types.get(
760
+ file_type.extension, (0, 0, 0, False)
761
+ )
762
+ if file_type.line_count is not None:
763
+ lines += file_type.line_count
764
+ tokens += file_type.token_count or 0
765
+ has_text = True
766
+ file_types[file_type.extension] = (count + file_type.count, lines, tokens, has_text)
767
+
768
+ aggregate = RepoInfoResult(
769
+ total_files=sum(result.total_files for result in results),
770
+ file_types=sorted(
771
+ [
772
+ FileTypeInfo(
773
+ extension, count, lines if has_text else None, tokens if has_text else None
774
+ )
775
+ for extension, (count, lines, tokens, has_text) in file_types.items()
776
+ ],
777
+ key=lambda file_type: file_type.line_count or 0,
778
+ reverse=True,
779
+ ),
780
+ total_lines=sum(result.total_lines for result in results),
781
+ total_tokens=sum(result.total_tokens for result in results),
782
+ max_depth=max((result.max_depth for result in results), default=0),
783
+ )
784
+ if self._background_shutdown_requested(worker):
785
+ return
786
+ self.call_from_thread(screen.populate, aggregate)
787
+
743
788
  def _push_info_screen(self, name: str, path: Path, result) -> None:
744
789
  self.push_screen(RepoInfoScreen(name, path))
745
790
  total = len(self._results)
@@ -44,7 +44,7 @@ def detect_repo_groups(repo_paths: Iterable[Path]) -> list[RepoGroup]:
44
44
 
45
45
 
46
46
  def group_session_repo_label(path: Path) -> str:
47
- from ...integrations.tmux import _sanitize_repo_name
47
+ from ...integrations.tmux.core import _sanitize_repo_name
48
48
 
49
49
  return f"group_{_sanitize_repo_name(path.name) or 'repo'}"
50
50
 
@@ -71,7 +71,7 @@ class ConsolePanelsMixin:
71
71
  def _panel_matches_search(
72
72
  self, panel: Panel, query: str, live_sessions: set[str] | None = None
73
73
  ) -> bool:
74
- from ...integrations.tmux import make_panel_session_name
74
+ from ...integrations.tmux.core import make_panel_session_name
75
75
 
76
76
  normalized_query = query.replace("×", "x")
77
77
  live_panes = self._live_panel_pane_count(panel, live_sessions)
@@ -88,7 +88,7 @@ class ConsolePanelsMixin:
88
88
  return any(normalized_query in haystack.replace("×", "x") for haystack in haystacks)
89
89
 
90
90
  def _panel_sort_key_func(self):
91
- from ...integrations.tmux import make_panel_session_name
91
+ from ...integrations.tmux.core import make_panel_session_name
92
92
 
93
93
  col = self._panels_sort_column
94
94
  if col == 1:
@@ -115,7 +115,7 @@ class ConsolePanelsMixin:
115
115
  return lambda panel: panel.name.lower()
116
116
 
117
117
  def _apply_panels_filter_and_sort(self, live_sessions: set[str] | None = None) -> None:
118
- from ...integrations.tmux import _list_sessions, make_panel_session_name
118
+ from ...integrations.tmux.core import _list_sessions, make_panel_session_name
119
119
 
120
120
  try:
121
121
  table = self.query_one("#panels-table", DataTable)
@@ -128,7 +128,6 @@ class ConsolePanelsMixin:
128
128
  preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
129
129
  table
130
130
  )
131
- table.clear()
132
131
  no_msg = self.query_one("#no-panels-message", Static)
133
132
 
134
133
  panels = list(self._panels_entries)
@@ -148,12 +147,10 @@ class ConsolePanelsMixin:
148
147
 
149
148
  panels.sort(key=self._panel_sort_key_func(), reverse=self._panels_sort_reverse)
150
149
 
151
- if not panels and total == 0 and not self._search_query:
152
- table.display = False
153
- no_msg.display = True
154
- else:
155
- table.display = True
156
- no_msg.display = False
150
+ is_empty = not panels and total == 0 and not self._search_query
151
+ self._set_table_empty_state(table, no_msg, is_empty=is_empty)
152
+ table.clear()
153
+ if not is_empty:
157
154
  for panel in panels:
158
155
  filled = self._live_panel_pane_count(panel, live_sessions)
159
156
  total_panes = panel.total_panes
@@ -279,19 +276,23 @@ class ConsolePanelsMixin:
279
276
  def _do_rename_panel(self, old_name: str, new_name: str | None) -> None:
280
277
  if new_name is None or new_name == old_name:
281
278
  return
282
- existing = self._panel_store.get(new_name)
283
- if existing:
284
- self._update_status(f"Panel '{new_name}' already exists")
279
+ validation_message = CreatePanelScreen.validate_new_panel_name(
280
+ self._panel_store,
281
+ new_name,
282
+ current_name=old_name,
283
+ )
284
+ if validation_message:
285
+ self._update_status(validation_message)
285
286
  return
286
287
  self._panel_store.rename(old_name, new_name)
287
288
  self._load_panels()
288
289
 
289
290
  def _open_panel(self, panel_name: str) -> None:
290
- from ...integrations.tmux import (
291
+ from ...integrations.tmux import rebuild_panel_tmux_session
292
+ from ...integrations.tmux.core import (
291
293
  _protect_session,
292
294
  _session_exists,
293
295
  make_panel_session_name,
294
- rebuild_panel_tmux_session,
295
296
  )
296
297
 
297
298
  panel = self._panel_store.get(panel_name)
@@ -369,6 +370,11 @@ class ConsolePanelsMixin:
369
370
  )
370
371
 
371
372
  def _do_delete_panel(self, confirmed: bool, panel_name: str) -> None:
372
- if confirmed:
373
- self._panel_store.delete(panel_name)
374
- self._load_panels()
373
+ if not confirmed:
374
+ return
375
+ if not self._panel_store.delete(panel_name):
376
+ self._update_status(
377
+ f"Panel '{panel_name}' could not be deleted because its tmux session is active"
378
+ )
379
+ return
380
+ self._load_panels()
@@ -98,8 +98,8 @@ class ConsoleReposMixin:
98
98
 
99
99
  def _populate_initial_rows(self) -> None:
100
100
  table = self.query_one("#repo-table", DataTable)
101
- self.query_one("#no-repos-message", Static).display = False
102
- table.display = True
101
+ no_msg = self.query_one("#no-repos-message", Static)
102
+ self._set_table_empty_state(table, no_msg, is_empty=False)
103
103
  preserved_row_key = None
104
104
  preserved_row_index = None
105
105
  restore_focus = False
@@ -133,8 +133,10 @@ class ConsoleReposMixin:
133
133
  logger.debug("Failed to update repo row %s", row_key, exc_info=True)
134
134
 
135
135
  def _show_no_repos(self) -> None:
136
- self.query_one("#repo-table", DataTable).display = False
137
- self.query_one("#no-repos-message", Static).display = True
136
+ table = self.query_one("#repo-table", DataTable)
137
+ no_msg = self.query_one("#no-repos-message", Static)
138
+ self._set_table_empty_state(table, no_msg, is_empty=True)
139
+ table.clear()
138
140
  self._visible_repo_count = 0
139
141
  self._visible_group_count = 0
140
142
  self._update_status("No repositories linked")
@@ -197,19 +199,17 @@ class ConsoleReposMixin:
197
199
  marker = "▸" if self._repo_group_is_collapsed(group) else "▾"
198
200
  return f"[bold cyan]{marker} {escape(group.name)}[/bold cyan]"
199
201
 
200
- def _repo_group_count_label(self, shown: int, total: int) -> str:
201
- repo_label = "repo" if total == 1 else "repos"
202
- if shown == total:
203
- return f"{total} {repo_label}"
204
- return f"{shown}/{total} {repo_label}"
202
+ def _repo_group_count_label(self, group: RepoGroup) -> str:
203
+ repo_label = "repo" if group.repo_count == 1 else "repos"
204
+ return f"[bold cyan][{group.repo_count} {repo_label}][/bold cyan]"
205
205
 
206
- def _add_repo_group_row(self, table: DataTable, group: RepoGroup, shown: int) -> None:
206
+ def _add_repo_group_row(self, table: DataTable, group: RepoGroup) -> None:
207
207
  table.add_row(
208
208
  self._repo_group_label(group),
209
- f"[bold]{self._repo_group_count_label(shown, group.repo_count)}[/bold]",
210
- "[dim]group[/dim]",
211
- "[dim]enter actions[/dim]",
212
- "",
209
+ self._repo_group_count_label(group),
210
+ "",
211
+ "",
212
+ "",
213
213
  str(group.path),
214
214
  key=self._group_row_key(group.path),
215
215
  )
@@ -262,7 +262,7 @@ class ConsoleReposMixin:
262
262
  shown_group_count += 1
263
263
  shown_repo_count += len(group_paths)
264
264
  grouped_paths.update(group_paths)
265
- self._add_repo_group_row(table, group, len(group_paths))
265
+ self._add_repo_group_row(table, group)
266
266
  if self._repo_group_is_collapsed(group):
267
267
  continue
268
268
  for path in sorted(group_paths, key=lambda item: item.name.lower()):
@@ -296,7 +296,7 @@ class ConsoleReposMixin:
296
296
  shown_group_count += 1
297
297
  shown_repo_count += len(group_infos)
298
298
  grouped_paths.update(info.path for info in group_infos)
299
- self._add_repo_group_row(table, group, len(group_infos))
299
+ self._add_repo_group_row(table, group)
300
300
  if self._repo_group_is_collapsed(group):
301
301
  continue
302
302
  group_infos.sort(key=key_func, reverse=self._sort_reverse)
@@ -314,8 +314,7 @@ class ConsoleReposMixin:
314
314
 
315
315
  def _apply_filter_and_sort(self) -> None:
316
316
  table = self.query_one("#repo-table", DataTable)
317
- self.query_one("#no-repos-message", Static).display = False
318
- table.display = True
317
+ no_msg = self.query_one("#no-repos-message", Static)
319
318
  preserved_row_key = None
320
319
  preserved_row_index = None
321
320
  restore_focus = False
@@ -328,7 +327,14 @@ class ConsoleReposMixin:
328
327
  infos = list(self._results.values())
329
328
  total = len(infos)
330
329
  infos = self._filter_repo_infos_for_search(infos)
331
- self._render_repo_info_rows(table, infos)
330
+ is_empty = total == 0 and not self._repo_paths and not self._search_query
331
+ self._set_table_empty_state(table, no_msg, is_empty=is_empty)
332
+ table.clear()
333
+ if is_empty:
334
+ self._visible_repo_count = 0
335
+ self._visible_group_count = 0
336
+ else:
337
+ self._render_repo_info_rows(table, infos)
332
338
 
333
339
  if self._resume_selection_tab == "repos":
334
340
  self._restore_resume_selection("repos")
@@ -369,7 +375,7 @@ class ConsoleReposMixin:
369
375
 
370
376
  msg += " ↑↓/jk navigate [enter] actions"
371
377
  if group_count:
372
- msg += " [space] toggle group"
378
+ msg += " [space] toggle"
373
379
  msg += " g git / search s sort r refresh q quit"
374
380
  if self._search_query:
375
381
  msg += " [esc] clear search"
@@ -97,7 +97,6 @@ class ConsoleSessionsMixin:
97
97
  preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
98
98
  table
99
99
  )
100
- table.clear()
101
100
  no_msg = self.query_one("#no-sessions-message", Static)
102
101
 
103
102
  entries = list(self._sessions_entries)
@@ -130,12 +129,10 @@ class ConsoleSessionsMixin:
130
129
  )
131
130
  entries.sort(key=key_func, reverse=self._sessions_sort_reverse)
132
131
 
133
- if not entries and total == 0 and not self._search_query:
134
- table.display = False
135
- no_msg.display = True
136
- else:
137
- table.display = True
138
- no_msg.display = False
132
+ is_empty = not entries and total == 0 and not self._search_query
133
+ self._set_table_empty_state(table, no_msg, is_empty=is_empty)
134
+ table.clear()
135
+ if not is_empty:
139
136
  for entry in entries:
140
137
  status = entry.get("status", "running")
141
138
  description = _wrap_session_description(
@@ -24,6 +24,16 @@ logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
26
  class ConsoleUIHelpersMixin:
27
+ def _set_table_empty_state(
28
+ self,
29
+ table: DataTable,
30
+ empty_message: Static,
31
+ *,
32
+ is_empty: bool,
33
+ ) -> None:
34
+ table.display = not is_empty
35
+ empty_message.display = is_empty
36
+
27
37
  def _compose_status_message(self, message: str) -> str:
28
38
  notice = getattr(self, "_update_notice", None)
29
39
  if not notice: