gitdirector 1.7.3__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 (57) hide show
  1. {gitdirector-1.7.3 → gitdirector-1.7.4}/PKG-INFO +1 -1
  2. {gitdirector-1.7.3 → gitdirector-1.7.4}/pyproject.toml +1 -1
  3. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app.py +53 -1
  4. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_panels.py +8 -3
  5. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_repos.py +10 -12
  6. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/panels.py +14 -5
  7. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/commit.py +23 -12
  8. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/diff.py +18 -27
  9. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/sessions.py +31 -9
  10. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/__init__.py +2 -0
  11. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/core.py +4 -2
  12. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/panels.py +6 -0
  13. {gitdirector-1.7.3 → gitdirector-1.7.4}/README.md +0 -0
  14. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/__init__.py +0 -0
  15. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/cli.py +0 -0
  16. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/__init__.py +0 -0
  17. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/autoclean.py +0 -0
  18. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/capture.py +0 -0
  19. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/cd.py +0 -0
  20. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/completion.py +0 -0
  21. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/gd_send.py +0 -0
  22. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/gd_tmux.py +0 -0
  23. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/help.py +0 -0
  24. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/info.py +0 -0
  25. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/link.py +0 -0
  26. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/listt.py +0 -0
  27. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/pull.py +0 -0
  28. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/reset.py +0 -0
  29. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/status.py +0 -0
  30. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/__init__.py +0 -0
  31. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_groups.py +0 -0
  32. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_sessions.py +0 -0
  33. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/app_ui.py +0 -0
  34. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/constants.py +0 -0
  35. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/diff_renderer.py +0 -0
  36. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/panel_view.py +0 -0
  37. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/__init__.py +0 -0
  38. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/_shared.py +0 -0
  39. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/diff_files.py +0 -0
  40. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/groups.py +0 -0
  41. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/panels.py +0 -0
  42. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/repos.py +0 -0
  43. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/screens/session_actions.py +0 -0
  44. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/table_text.py +0 -0
  45. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/terminal_caps.py +0 -0
  46. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/tui/terminal_widget.py +0 -0
  47. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/commands/unlink.py +0 -0
  48. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/config.py +0 -0
  49. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/github_credential_helper.py +0 -0
  50. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/info.py +0 -0
  51. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/integrations/__init__.py +0 -0
  52. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/integrations/tmux/monitor.py +0 -0
  53. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/manager.py +0 -0
  54. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/repo.py +0 -0
  55. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/storage.py +0 -0
  56. {gitdirector-1.7.3 → gitdirector-1.7.4}/src/gitdirector/ui_theme.py +0 -0
  57. {gitdirector-1.7.3 → 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.3
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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "gitdirector"
7
- version = "1.7.3"
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" }
@@ -670,7 +670,11 @@ class GitDirectorConsole(
670
670
  def action_show_info(self) -> None:
671
671
  if self._active_tab != "repos":
672
672
  return
673
- 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)
674
678
  return
675
679
  path = self._get_selected_path()
676
680
  if path is None:
@@ -733,6 +737,54 @@ class GitDirectorConsole(
733
737
  return
734
738
  self.call_from_thread(screen.populate, result)
735
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
+
736
788
  def _push_info_screen(self, name: str, path: Path, result) -> None:
737
789
  self.push_screen(RepoInfoScreen(name, path))
738
790
  total = len(self._results)
@@ -370,6 +370,11 @@ class ConsolePanelsMixin:
370
370
  )
371
371
 
372
372
  def _do_delete_panel(self, confirmed: bool, panel_name: str) -> None:
373
- if confirmed:
374
- self._panel_store.delete(panel_name)
375
- 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()
@@ -199,19 +199,17 @@ class ConsoleReposMixin:
199
199
  marker = "▸" if self._repo_group_is_collapsed(group) else "▾"
200
200
  return f"[bold cyan]{marker} {escape(group.name)}[/bold cyan]"
201
201
 
202
- def _repo_group_count_label(self, shown: int, total: int) -> str:
203
- repo_label = "repo" if total == 1 else "repos"
204
- if shown == total:
205
- return f"{total} {repo_label}"
206
- 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]"
207
205
 
208
- 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:
209
207
  table.add_row(
210
208
  self._repo_group_label(group),
211
- f"[bold]{self._repo_group_count_label(shown, group.repo_count)}[/bold]",
212
- "[dim]group[/dim]",
213
- "[dim]enter actions[/dim]",
214
- "",
209
+ self._repo_group_count_label(group),
210
+ "",
211
+ "",
212
+ "",
215
213
  str(group.path),
216
214
  key=self._group_row_key(group.path),
217
215
  )
@@ -264,7 +262,7 @@ class ConsoleReposMixin:
264
262
  shown_group_count += 1
265
263
  shown_repo_count += len(group_paths)
266
264
  grouped_paths.update(group_paths)
267
- self._add_repo_group_row(table, group, len(group_paths))
265
+ self._add_repo_group_row(table, group)
268
266
  if self._repo_group_is_collapsed(group):
269
267
  continue
270
268
  for path in sorted(group_paths, key=lambda item: item.name.lower()):
@@ -298,7 +296,7 @@ class ConsoleReposMixin:
298
296
  shown_group_count += 1
299
297
  shown_repo_count += len(group_infos)
300
298
  grouped_paths.update(info.path for info in group_infos)
301
- self._add_repo_group_row(table, group, len(group_infos))
299
+ self._add_repo_group_row(table, group)
302
300
  if self._repo_group_is_collapsed(group):
303
301
  continue
304
302
  group_infos.sort(key=key_func, reverse=self._sort_reverse)
@@ -627,13 +627,20 @@ class PanelStore:
627
627
  with advisory_file_lock(self.lock_file):
628
628
  write_yaml_atomic(self.panels_file, data)
629
629
 
630
- def _kill_panel_sessions(self, panel_names: list[str]) -> None:
630
+ def _kill_panel_sessions(self, panel_names: list[str]) -> bool:
631
631
  if not panel_names:
632
- return
633
- from ...integrations.tmux import kill_panel_tmux_session
632
+ return True
633
+ from ...integrations.tmux import kill_panel_tmux_session, panel_tmux_session_exists
634
634
 
635
635
  for panel_name in panel_names:
636
- kill_panel_tmux_session(panel_name)
636
+ if not kill_panel_tmux_session(panel_name) and panel_tmux_session_exists(panel_name):
637
+ return False
638
+ return True
639
+
640
+ def _sync_panel_tmux_config(self) -> None:
641
+ from ...integrations.tmux import sync_panel_tmux_config
642
+
643
+ sync_panel_tmux_config()
637
644
 
638
645
  def _cleanup_inner_panel_sessions(self, session_names: list[str]) -> None:
639
646
  if not session_names:
@@ -682,10 +689,12 @@ class PanelStore:
682
689
  for i, p in enumerate(self._panels):
683
690
  if p.name == name:
684
691
  inner_sessions = [s for s in p.panes.values() if s]
692
+ if not self._kill_panel_sessions([name]):
693
+ return False
685
694
  self._panels.pop(i)
686
695
  self._save()
687
- self._kill_panel_sessions([name])
688
696
  self._cleanup_inner_panel_sessions(inner_sessions)
697
+ self._sync_panel_tmux_config()
689
698
  return True
690
699
  return False
691
700
 
@@ -29,7 +29,7 @@ from textual.app import ComposeResult
29
29
  from textual.binding import Binding
30
30
  from textual.containers import Container, Vertical
31
31
  from textual.screen import ModalScreen
32
- from textual.widgets import Input, LoadingIndicator, OptionList, Static
32
+ from textual.widgets import LoadingIndicator, OptionList, Static, TextArea
33
33
  from textual.widgets.option_list import Option
34
34
 
35
35
  from ..constants import _MODAL_BINDINGS, _MODAL_CSS
@@ -186,12 +186,16 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
186
186
  }
187
187
  #commit-message-input {
188
188
  width: 1fr;
189
- height: 3;
189
+ height: auto;
190
+ min-height: 3;
191
+ max-height: 10;
190
192
  border: none;
191
193
  background: $boost;
192
194
  color: $text;
193
195
  margin: 0 0 1 0;
194
196
  padding: 0 1;
197
+ scrollbar-size-vertical: 0;
198
+ overflow-y: hidden;
195
199
  }
196
200
  #commit-message-input:focus {
197
201
  background: $boost;
@@ -232,27 +236,34 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
232
236
  f"[dim]\u00b7 {self.file_count} {noun}[/dim]",
233
237
  id="commit-message-stats",
234
238
  )
235
- yield Input(
239
+ yield TextArea(
240
+ "",
236
241
  placeholder="Commit message\u2026",
237
242
  id="commit-message-input",
238
243
  )
239
244
  yield _CommitActionOptionList(
240
- Option("[white]\u2713[/white] [bold]Commit[/bold]", id="commit"),
241
245
  Option("[cyan]\u2191[/cyan] [bold]Commit and push[/bold]", id="commit_push"),
246
+ Option("[white]\u2713[/white] [bold]Commit[/bold]", id="commit"),
242
247
  id="commit-message-action-list",
243
248
  )
244
249
  yield Static(
245
- "type message [tab] switch [\u2191\u2193/jk] pick action"
246
- " [enter] confirm [esc] cancel",
250
+ "type message \\[tab] switch [\u2191\u2193/jk] pick action"
251
+ " [ctrl+enter] confirm \\[esc] cancel",
247
252
  id="commit-message-hint",
248
253
  )
249
254
 
250
255
  def on_mount(self) -> None:
251
- self.query_one("#commit-message-input", Input).focus()
256
+ message = self.query_one("#commit-message-input", TextArea)
257
+ message.focus()
258
+ self.call_after_refresh(self._resize_message_input)
252
259
 
253
- def on_input_submitted(self, event: Input.Submitted) -> None:
254
- event.stop()
255
- self._confirm()
260
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
261
+ if event.text_area.id == "commit-message-input":
262
+ self.call_after_refresh(self._resize_message_input)
263
+
264
+ def _resize_message_input(self) -> None:
265
+ message = self.query_one("#commit-message-input", TextArea)
266
+ message.styles.height = min(10, max(3, message.virtual_size.height))
256
267
 
257
268
  def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
258
269
  # Click / enter on an option also confirms; the current
@@ -278,7 +289,7 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
278
289
 
279
290
  def action_focus_message(self) -> None:
280
291
  self._in_message = True
281
- self.query_one("#commit-message-input", Input).focus()
292
+ self.query_one("#commit-message-input", TextArea).focus()
282
293
 
283
294
  def action_focus_toggle(self) -> None:
284
295
  """Toggle focus between the message input and the action picker."""
@@ -301,7 +312,7 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
301
312
  self.query_one("#commit-message-action-list", OptionList).action_cursor_up()
302
313
 
303
314
  def _message(self) -> str:
304
- return self.query_one("#commit-message-input", Input).value.strip()
315
+ return self.query_one("#commit-message-input", TextArea).text.strip()
305
316
 
306
317
  def _message_valid(self) -> bool:
307
318
  msg = self._message()
@@ -114,21 +114,20 @@ class DiffReviewScreen(ModalScreen[None]):
114
114
  #diff-files-pane {
115
115
  width: 52;
116
116
  height: 1fr;
117
- border-right: solid $primary;
117
+ border-right: solid $boost;
118
118
  padding: 0 0;
119
119
  background: $surface;
120
120
  }
121
- #diff-files-pane.--files-focused {
122
- border-right: solid $accent;
123
- }
124
- #diff-files-pane.--diff-focused {
125
- border-right: solid $secondary;
126
- }
127
- #diff-content-pane.--files-focused {
128
- border-left: solid $accent;
121
+ .diff-pane-label {
122
+ height: 1;
123
+ padding: 0 1;
124
+ background: $boost;
125
+ color: $text-muted;
126
+ text-style: bold;
129
127
  }
130
- #diff-content-pane.--diff-focused {
131
- border-left: solid $secondary;
128
+ .diff-pane-label.--focused {
129
+ background: $accent;
130
+ color: $text;
132
131
  }
133
132
  #diff-files-list {
134
133
  width: 1fr;
@@ -149,12 +148,6 @@ class DiffReviewScreen(ModalScreen[None]):
149
148
  height: 1fr;
150
149
  padding: 0 0;
151
150
  }
152
- #diff-content-pane.--files-focused {
153
- border-left: solid $accent;
154
- }
155
- #diff-content-pane.--diff-focused {
156
- border-left: solid $secondary;
157
- }
158
151
  #diff-content-scroll {
159
152
  width: 1fr;
160
153
  height: 1fr;
@@ -235,8 +228,10 @@ class DiffReviewScreen(ModalScreen[None]):
235
228
  yield Static("", id="diff-summary")
236
229
  with Horizontal(id="diff-body"):
237
230
  with Vertical(id="diff-files-pane"):
231
+ yield Static("FILES", id="diff-files-pane-label", classes="diff-pane-label")
238
232
  yield FileTileList(id="diff-files-list")
239
233
  with Vertical(id="diff-content-pane"):
234
+ yield Static("DIFF", id="diff-content-pane-label", classes="diff-pane-label")
240
235
  with _DiffContentScroll(id="diff-content-scroll"):
241
236
  yield Static(id="diff-content")
242
237
  with Vertical(id="diff-loading"):
@@ -244,11 +239,11 @@ class DiffReviewScreen(ModalScreen[None]):
244
239
  yield Static("Loading diff\u2026", id="diff-loading-text")
245
240
  yield Static("", id="diff-empty")
246
241
  yield Static(
247
- "[bold]tab[/bold] panel "
242
+ "[bold]tab[/bold] toggle focus "
248
243
  "[bold]j/k[/bold] line "
249
244
  "[bold]J/K[/bold] page "
250
245
  "[bold]h/l[/bold] horizontal "
251
- "[bold]brackets[/bold] file "
246
+ "[bold]\\[ ][/bold] cycle "
252
247
  "[bold]g[/bold] commit "
253
248
  "[bold]r[/bold] refresh "
254
249
  "[bold]esc[/bold] close",
@@ -495,17 +490,16 @@ class DiffReviewScreen(ModalScreen[None]):
495
490
  if not self._files:
496
491
  target = _FOCUS_FILES
497
492
  try:
498
- # Toggle the focus-class on the two panes so the
499
- # divider border can shift colour to signal which side
500
- # has focus. We touch *both* panes on every focus
501
- # change so the stale classes from the previous focus
502
- # don't linger.
503
493
  files_pane = self.query_one("#diff-files-pane")
504
494
  content_pane = self.query_one("#diff-content-pane")
495
+ files_label = self.query_one("#diff-files-pane-label")
496
+ content_label = self.query_one("#diff-content-pane-label")
505
497
  files_pane.set_class(False, "--files-focused")
506
498
  files_pane.set_class(False, "--diff-focused")
507
499
  content_pane.set_class(False, "--files-focused")
508
500
  content_pane.set_class(False, "--diff-focused")
501
+ files_label.set_class(target == _FOCUS_FILES, "--focused")
502
+ content_label.set_class(target == _FOCUS_DIFF, "--focused")
509
503
  if target == _FOCUS_DIFF:
510
504
  content_pane.set_class(True, "--diff-focused")
511
505
  files_pane.set_class(True, "--diff-focused")
@@ -568,9 +562,6 @@ class DiffReviewScreen(ModalScreen[None]):
568
562
  if new_index == files_list.index:
569
563
  return
570
564
  files_list.index = new_index
571
- if self._focus_target != _FOCUS_FILES:
572
- self._focus_target = _FOCUS_FILES
573
- self._apply_focus()
574
565
 
575
566
  def action_cursor_down(self) -> None:
576
567
  if self._focus_target == _FOCUS_FILES or not self._files:
@@ -5,9 +5,10 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from textual.app import ComposeResult
8
+ from textual.binding import Binding
8
9
  from textual.containers import Vertical
9
10
  from textual.screen import ModalScreen
10
- from textual.widgets import Input, OptionList, Static
11
+ from textual.widgets import OptionList, Static, TextArea
11
12
  from textual.widgets.option_list import Option
12
13
 
13
14
  from ..constants import _MODAL_BINDINGS, _MODAL_CSS
@@ -143,6 +144,13 @@ class SelectSessionScreen(ModalScreen[str | None]):
143
144
  __all__ = ["EditSessionDescriptionScreen", "RemoveSessionScreen", "SelectSessionScreen"]
144
145
 
145
146
 
147
+ class DescriptionTextArea(TextArea):
148
+ BINDINGS = [*TextArea.BINDINGS, Binding("enter", "submit", show=False, priority=True)]
149
+
150
+ def action_submit(self) -> None:
151
+ self.screen.action_submit()
152
+
153
+
146
154
  class EditSessionDescriptionScreen(ModalScreen[str | None]):
147
155
  """Modal for editing the description stored on a tmux session.
148
156
 
@@ -165,12 +173,16 @@ class EditSessionDescriptionScreen(ModalScreen[str | None]):
165
173
  }
166
174
  EditSessionDescriptionScreen #description-input {
167
175
  width: 1fr;
168
- height: 3;
176
+ height: auto;
177
+ min-height: 3;
178
+ max-height: 10;
169
179
  border: none;
170
180
  background: $boost;
171
181
  color: $text;
172
182
  margin: 1 0;
173
183
  padding: 0 1;
184
+ scrollbar-size-vertical: 0;
185
+ overflow-y: hidden;
174
186
  }
175
187
  #description-session-name {
176
188
  text-align: center;
@@ -190,22 +202,32 @@ class EditSessionDescriptionScreen(ModalScreen[str | None]):
190
202
  with Vertical(id="menu-container"):
191
203
  yield Static("[bold white]Edit Description[/bold white]", id="menu-title")
192
204
  yield Static(f"[dim]{self.session_name}[/dim]", id="description-session-name")
193
- yield Input(
194
- value=self._initial_value,
205
+ yield DescriptionTextArea(
206
+ self._initial_value,
195
207
  placeholder="description (leave empty to reset to '-')",
196
208
  id="description-input",
197
209
  )
198
210
  yield Static("\\[enter] save \\[esc] cancel", id="menu-hint")
199
211
 
200
212
  def on_mount(self) -> None:
201
- inp = self.query_one("#description-input", Input)
213
+ inp = self.query_one("#description-input", TextArea)
202
214
  inp.focus()
203
215
  if self._initial_value:
204
- inp.action_end()
216
+ lines = self._initial_value.splitlines() or [""]
217
+ inp.move_cursor((len(lines) - 1, len(lines[-1])))
218
+ self.call_after_refresh(self._resize_description_input)
219
+
220
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
221
+ if event.text_area.id == "description-input":
222
+ self.call_after_refresh(self._resize_description_input)
223
+
224
+ def _resize_description_input(self) -> None:
225
+ inp = self.query_one("#description-input", TextArea)
226
+ inp.styles.height = min(10, max(3, inp.virtual_size.height))
205
227
 
206
- def on_input_submitted(self, event: Input.Submitted) -> None:
207
- new_value = event.value.strip()
208
- self.dismiss(new_value)
228
+ def action_submit(self) -> None:
229
+ inp = self.query_one("#description-input", TextArea)
230
+ self.dismiss(inp.text.strip())
209
231
 
210
232
  def action_cancel(self) -> None:
211
233
  self.dismiss(None)
@@ -78,6 +78,7 @@ from .panels import (
78
78
  cleanup_temp_panel_tmux_session,
79
79
  ensure_temp_panel_tmux_session,
80
80
  kill_panel_tmux_session,
81
+ panel_tmux_session_exists,
81
82
  rebuild_panel_tmux_session,
82
83
  )
83
84
 
@@ -93,6 +94,7 @@ __all__ = [
93
94
  "get_all_session_statuses",
94
95
  "kill_all_gd_sessions",
95
96
  "kill_panel_tmux_session",
97
+ "panel_tmux_session_exists",
96
98
  "kill_tmux_session",
97
99
  "launch_command_in_tmux_session",
98
100
  "list_all_gd_sessions",
@@ -460,7 +460,7 @@ def kill_all_gd_sessions() -> list[str]:
460
460
  session_names.update(
461
461
  session_name
462
462
  for session_name in _list_sessions()
463
- if _is_persistent_panel_session(session_name)
463
+ if _is_persistent_panel_session(session_name) or _is_temp_panel_session(session_name)
464
464
  )
465
465
  except (TmuxError, OSError, ValueError):
466
466
  logger.debug("Failed to enumerate GitDirector tmux sessions", exc_info=True)
@@ -582,7 +582,9 @@ def _sanitize_panel_name(name: str) -> str:
582
582
 
583
583
  def _is_temp_panel_session(session_name: str) -> bool:
584
584
  parts = session_name.split("/")
585
- return len(parts) > 4 and parts[:3] == ["gd", "temp", "panel"]
585
+ if parts[:3] != ["gd", "temp", "panel"]:
586
+ return False
587
+ return len(parts) > 4 or (len(parts) == 4 and parts[3].startswith("build-"))
586
588
 
587
589
 
588
590
  def _is_persistent_panel_session(session_name: str) -> bool:
@@ -88,6 +88,12 @@ def kill_panel_tmux_session(panel_name: str) -> bool:
88
88
  return kill_tmux_session(make_panel_session_name(panel_name))
89
89
 
90
90
 
91
+ def panel_tmux_session_exists(panel_name: str) -> bool:
92
+ if not isinstance(panel_name, str) or not panel_name:
93
+ raise ValueError("panel_tmux_session_exists requires a non-empty panel name")
94
+ return _session_exists(make_panel_session_name(panel_name))
95
+
96
+
91
97
  def _kill_tmux_session_by_name(intended_name: str) -> bool:
92
98
  """Kill a tmux session by the name we requested, tolerating ``.`` -> ``_``.
93
99
 
File without changes