gitdirector 1.5.0__tar.gz → 1.5.2__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 (49) hide show
  1. {gitdirector-1.5.0 → gitdirector-1.5.2}/PKG-INFO +65 -2
  2. {gitdirector-1.5.0 → gitdirector-1.5.2}/README.md +64 -1
  3. {gitdirector-1.5.0 → gitdirector-1.5.2}/pyproject.toml +1 -1
  4. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/cli.py +2 -0
  5. gitdirector-1.5.2/src/gitdirector/commands/autoclean.py +39 -0
  6. gitdirector-1.5.2/src/gitdirector/commands/gd_tmux.py +79 -0
  7. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/help.py +2 -1
  8. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app.py +25 -4
  9. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/diff_renderer.py +40 -1
  10. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/_shared.py +3 -2
  11. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/commit.py +17 -14
  12. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/diff_files.py +20 -5
  13. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/panels.py +41 -21
  14. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/repos.py +9 -8
  15. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/sessions.py +3 -2
  16. gitdirector-1.5.2/src/gitdirector/commands/tui/terminal_caps.py +133 -0
  17. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/terminal_widget.py +28 -13
  18. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/core.py +11 -2
  19. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/monitor.py +13 -4
  20. gitdirector-1.5.0/src/gitdirector/commands/autoclean.py +0 -85
  21. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/__init__.py +0 -0
  22. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/__init__.py +0 -0
  23. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/cd.py +0 -0
  24. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/info.py +0 -0
  25. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/link.py +0 -0
  26. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/listt.py +0 -0
  27. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/pull.py +0 -0
  28. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/status.py +0 -0
  29. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/__init__.py +0 -0
  30. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_panels.py +0 -0
  31. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_repos.py +0 -0
  32. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_sessions.py +0 -0
  33. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/app_ui.py +0 -0
  34. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/constants.py +0 -0
  35. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/panel_view.py +0 -0
  36. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/panels.py +0 -0
  37. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/__init__.py +0 -0
  38. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/tui/screens/diff.py +0 -0
  39. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/commands/unlink.py +0 -0
  40. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/config.py +0 -0
  41. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/info.py +0 -0
  42. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/__init__.py +0 -0
  43. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/__init__.py +0 -0
  44. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/integrations/tmux/panels.py +0 -0
  45. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/manager.py +0 -0
  46. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/repo.py +0 -0
  47. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/storage.py +0 -0
  48. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/ui_theme.py +0 -0
  49. {gitdirector-1.5.0 → gitdirector-1.5.2}/src/gitdirector/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gitdirector
3
- Version: 1.5.0
3
+ Version: 1.5.2
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
@@ -74,8 +74,9 @@ If you find GitDirector useful, please star this repository on GitHub, we need m
74
74
  | `gitdirector status` | Show repositories with staged/unstaged files |
75
75
  | `gitdirector pull` | Pull latest changes for all tracked repositories |
76
76
  | `gitdirector cd NAME` | Open or switch to a tmux session for a repository |
77
- | `gitdirector autoclean links\|sessions` | Clean broken links or stale tmux sessions |
77
+ | `gitdirector autoclean` | Remove broken repository links from tracking |
78
78
  | `gitdirector info PATH\|NAME [--full]` | Show file statistics for a repository |
79
+ | `gitdirector gd-tmux PATH\|NAME "command"` | Create a gd tmux session and run a command in it |
79
80
  | `gitdirector help` | Show help |
80
81
 
81
82
  ### link
@@ -175,6 +176,68 @@ Opens a [tmux](https://github.com/tmux/tmux) session rooted at the repository di
175
176
  > Debian/Ubuntu: `sudo apt install tmux`
176
177
  > Arch: `sudo pacman -S tmux`
177
178
 
179
+ ### gd-tmux
180
+
181
+ ```bash
182
+ gitdirector gd-tmux my-repo "npm test"
183
+ gitdirector gd-tmux /path/to/my-repo "make build"
184
+ gitdirector gd-tmux my-repo 'echo "hello world"'
185
+ ```
186
+
187
+ Creates a new gd tmux session rooted at the given repository, runs the supplied command inside it, and attaches the session. The session is created using the same code path the TUI's **Launch AI agent** menu uses: it is a real `gd/{repo}/{command}/{N}` session, visible in the Sessions tab of `gitdirector console`, and it self-destructs when the command exits.
188
+
189
+ - `PATH|NAME` — accept either an absolute path to a tracked repository or its basename, with the same disambiguation rules as `gitdirector unlink`.
190
+ - `"command"` — the shell command to run inside the new session. Pass it quoted so the shell hands the whole string to gitdirector; quotes and arguments are preserved verbatim.
191
+
192
+ The command is the same as opening the repo in the console, picking **Launch AI agent** from the action menu, and entering the command — but you can do it from any shell without entering the TUI first. The new session shows up in the Sessions tab immediately.
193
+
194
+ ### autoclean
195
+
196
+ ```bash
197
+ gitdirector autoclean
198
+ ```
199
+
200
+ Scans the tracked repositories in `~/.gitdirector/config.yaml` for paths that no longer exist on disk and removes them from the config. Lists any broken links for review and asks for confirmation before deleting them.
201
+
202
+ ## Note for AI coding agents
203
+
204
+ If you are an AI coding agent reading this project: when the user asks you to run a long-lived process inside a tracked repository — a dev server, a frontend bundler, a backend API, a watcher, a database, a test runner with a long timeout, or anything else that does not exit on its own — **do not run it in the foreground**. A foreground process will block your shell for the rest of the session and the user will not be able to interact with you.
205
+
206
+ Use `gitdirector gd-tmux` to launch the process in a detached gd tmux session that the user can monitor and tear down themselves. The session shows up in the Sessions tab of `gitdirector console`, auto-cleans up when the process exits, and supports path-or-name lookup the same way every other gitdirector command does.
207
+
208
+ **Always prefer the absolute path over the repo name.** The by-name lookup matches the *directory name on disk* verbatim (via `Path.name`) — case-sensitive, dots and spaces included — and is ambiguous if two tracked repos share the same basename. When in doubt, look up the path with `gitdirector list` and use the absolute path. Quoting a path is fine; an absolute path is never ambiguous.
209
+
210
+ Examples:
211
+
212
+ ```bash
213
+ # Frontend dev server in the user's web repo — use the absolute path
214
+ gitdirector gd-tmux /Users/me/projects/webapp "npm run dev"
215
+
216
+ # Backend API in the api repo
217
+ gitdirector gd-tmux /Users/me/projects/api "uvicorn app.main:app --reload --port 8000"
218
+
219
+ # A long-running integration test with output captured to a log
220
+ gitdirector gd-tmux /Users/me/projects/myrepo "pytest -x -v 2>&1 | tee /tmp/test.log"
221
+
222
+ # A watcher or build loop
223
+ gitdirector gd-tmux /Users/me/projects/myrepo "npm run watch"
224
+
225
+ # A repo whose directory name contains a space — quote both the path and the command
226
+ gitdirector gd-tmux "/Users/me/projects/My Repo" "npm run dev"
227
+ ```
228
+
229
+ The by-name form is supported for ergonomic use by humans in interactive shells, but agents should default to the absolute path to avoid any chance of matching the wrong repo.
230
+
231
+ Rules of thumb:
232
+
233
+ - **Use the absolute path.** It is unambiguous and survives the user renaming or moving the directory. The by-name form is convenient shorthand, not a slug — it is the raw directory name, case-sensitive.
234
+ - **Repo names with spaces, dots, or punctuation are matched verbatim** when you do use the by-name form. A repo at `/Users/me/My Repo` is matched by `"My Repo"`, not by `My` or `Repo`. Quote the name to keep the shell from splitting it.
235
+ - **Quoting the command matters.** The command string is handed to `sh -lc` inside the new session, so write it as a single quoted string. `'echo "hi"'` (single-quote outer, double-quote inner) is the safest pattern for embedded double quotes.
236
+ - **Path separators in the command are fine.** Commands like `python /path/to/script.py` or `find . -name foo/bar` work; the command is embedded verbatim into the inner shell, while the session-name label is auto-sanitized.
237
+ - **The session self-destructs** when the command exits (success or failure), so you do not need to clean it up.
238
+ - **If you need to capture output**, redirect it to a file in the command itself (`> /tmp/out 2>&1`) and read the file later — the session is gone once the command exits.
239
+ - **If the user asks you to stop a running process**, kill the matching session: `gitdirector console` → Sessions tab, or directly with `tmux kill-session -t =<session-name>`.
240
+
178
241
  ## Configuration
179
242
 
180
243
  Config is stored at `~/.gitdirector/config.yaml`.
@@ -31,8 +31,9 @@ If you find GitDirector useful, please star this repository on GitHub, we need m
31
31
  | `gitdirector status` | Show repositories with staged/unstaged files |
32
32
  | `gitdirector pull` | Pull latest changes for all tracked repositories |
33
33
  | `gitdirector cd NAME` | Open or switch to a tmux session for a repository |
34
- | `gitdirector autoclean links\|sessions` | Clean broken links or stale tmux sessions |
34
+ | `gitdirector autoclean` | Remove broken repository links from tracking |
35
35
  | `gitdirector info PATH\|NAME [--full]` | Show file statistics for a repository |
36
+ | `gitdirector gd-tmux PATH\|NAME "command"` | Create a gd tmux session and run a command in it |
36
37
  | `gitdirector help` | Show help |
37
38
 
38
39
  ### link
@@ -132,6 +133,68 @@ Opens a [tmux](https://github.com/tmux/tmux) session rooted at the repository di
132
133
  > Debian/Ubuntu: `sudo apt install tmux`
133
134
  > Arch: `sudo pacman -S tmux`
134
135
 
136
+ ### gd-tmux
137
+
138
+ ```bash
139
+ gitdirector gd-tmux my-repo "npm test"
140
+ gitdirector gd-tmux /path/to/my-repo "make build"
141
+ gitdirector gd-tmux my-repo 'echo "hello world"'
142
+ ```
143
+
144
+ Creates a new gd tmux session rooted at the given repository, runs the supplied command inside it, and attaches the session. The session is created using the same code path the TUI's **Launch AI agent** menu uses: it is a real `gd/{repo}/{command}/{N}` session, visible in the Sessions tab of `gitdirector console`, and it self-destructs when the command exits.
145
+
146
+ - `PATH|NAME` — accept either an absolute path to a tracked repository or its basename, with the same disambiguation rules as `gitdirector unlink`.
147
+ - `"command"` — the shell command to run inside the new session. Pass it quoted so the shell hands the whole string to gitdirector; quotes and arguments are preserved verbatim.
148
+
149
+ The command is the same as opening the repo in the console, picking **Launch AI agent** from the action menu, and entering the command — but you can do it from any shell without entering the TUI first. The new session shows up in the Sessions tab immediately.
150
+
151
+ ### autoclean
152
+
153
+ ```bash
154
+ gitdirector autoclean
155
+ ```
156
+
157
+ Scans the tracked repositories in `~/.gitdirector/config.yaml` for paths that no longer exist on disk and removes them from the config. Lists any broken links for review and asks for confirmation before deleting them.
158
+
159
+ ## Note for AI coding agents
160
+
161
+ If you are an AI coding agent reading this project: when the user asks you to run a long-lived process inside a tracked repository — a dev server, a frontend bundler, a backend API, a watcher, a database, a test runner with a long timeout, or anything else that does not exit on its own — **do not run it in the foreground**. A foreground process will block your shell for the rest of the session and the user will not be able to interact with you.
162
+
163
+ Use `gitdirector gd-tmux` to launch the process in a detached gd tmux session that the user can monitor and tear down themselves. The session shows up in the Sessions tab of `gitdirector console`, auto-cleans up when the process exits, and supports path-or-name lookup the same way every other gitdirector command does.
164
+
165
+ **Always prefer the absolute path over the repo name.** The by-name lookup matches the *directory name on disk* verbatim (via `Path.name`) — case-sensitive, dots and spaces included — and is ambiguous if two tracked repos share the same basename. When in doubt, look up the path with `gitdirector list` and use the absolute path. Quoting a path is fine; an absolute path is never ambiguous.
166
+
167
+ Examples:
168
+
169
+ ```bash
170
+ # Frontend dev server in the user's web repo — use the absolute path
171
+ gitdirector gd-tmux /Users/me/projects/webapp "npm run dev"
172
+
173
+ # Backend API in the api repo
174
+ gitdirector gd-tmux /Users/me/projects/api "uvicorn app.main:app --reload --port 8000"
175
+
176
+ # A long-running integration test with output captured to a log
177
+ gitdirector gd-tmux /Users/me/projects/myrepo "pytest -x -v 2>&1 | tee /tmp/test.log"
178
+
179
+ # A watcher or build loop
180
+ gitdirector gd-tmux /Users/me/projects/myrepo "npm run watch"
181
+
182
+ # A repo whose directory name contains a space — quote both the path and the command
183
+ gitdirector gd-tmux "/Users/me/projects/My Repo" "npm run dev"
184
+ ```
185
+
186
+ The by-name form is supported for ergonomic use by humans in interactive shells, but agents should default to the absolute path to avoid any chance of matching the wrong repo.
187
+
188
+ Rules of thumb:
189
+
190
+ - **Use the absolute path.** It is unambiguous and survives the user renaming or moving the directory. The by-name form is convenient shorthand, not a slug — it is the raw directory name, case-sensitive.
191
+ - **Repo names with spaces, dots, or punctuation are matched verbatim** when you do use the by-name form. A repo at `/Users/me/My Repo` is matched by `"My Repo"`, not by `My` or `Repo`. Quote the name to keep the shell from splitting it.
192
+ - **Quoting the command matters.** The command string is handed to `sh -lc` inside the new session, so write it as a single quoted string. `'echo "hi"'` (single-quote outer, double-quote inner) is the safest pattern for embedded double quotes.
193
+ - **Path separators in the command are fine.** Commands like `python /path/to/script.py` or `find . -name foo/bar` work; the command is embedded verbatim into the inner shell, while the session-name label is auto-sanitized.
194
+ - **The session self-destructs** when the command exits (success or failure), so you do not need to clean it up.
195
+ - **If you need to capture output**, redirect it to a file in the command itself (`> /tmp/out 2>&1`) and read the file later — the session is gone once the command exits.
196
+ - **If the user asks you to stop a running process**, kill the matching session: `gitdirector console` → Sessions tab, or directly with `tmux kill-session -t =<session-name>`.
197
+
135
198
  ## Configuration
136
199
 
137
200
  Config is stored at `~/.gitdirector/config.yaml`.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "gitdirector"
7
- version = "1.5.0"
7
+ version = "1.5.2"
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" }
@@ -10,6 +10,7 @@ from .commands import (
10
10
  autoclean,
11
11
  cd,
12
12
  console,
13
+ gd_tmux,
13
14
  help,
14
15
  info,
15
16
  link,
@@ -55,6 +56,7 @@ help.register(cli)
55
56
  tui.register(cli)
56
57
  autoclean.register(cli)
57
58
  info.register(cli)
59
+ gd_tmux.register(cli)
58
60
 
59
61
 
60
62
  def main():
@@ -0,0 +1,39 @@
1
+ import click
2
+
3
+ from ..config import Config
4
+ from . import console
5
+
6
+
7
+ def register(cli: click.Group):
8
+ @cli.command()
9
+ def autoclean():
10
+ _autoclean_links()
11
+
12
+
13
+ def _autoclean_links():
14
+ config = Config()
15
+ broken = [p for p in config.repositories if not p.exists()]
16
+
17
+ if not broken:
18
+ console.print()
19
+ console.print(" [green]All links are valid.[/green]")
20
+ console.print()
21
+ return
22
+
23
+ console.print()
24
+ console.print(f" Found [yellow]{len(broken)}[/yellow] broken link(s):\n")
25
+ for p in broken:
26
+ console.print(f" [red]✕[/red] {p}", soft_wrap=True)
27
+ console.print()
28
+
29
+ if not click.confirm(" Remove these broken links?"):
30
+ console.print()
31
+ console.print(" [dim]Cancelled.[/dim]")
32
+ console.print()
33
+ return
34
+
35
+ config.remove_repositories(broken)
36
+
37
+ console.print()
38
+ console.print(f" [green]Removed {len(broken)} broken link(s).[/green]")
39
+ console.print()
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from ..manager import RepositoryManager
6
+ from ..storage import normalize_repository_path
7
+ from . import console
8
+
9
+
10
+ def _resolve_repo(target: str) -> Path | None:
11
+ """Resolve *target* to a tracked repository path.
12
+
13
+ Tries the value as a filesystem path first. If no path-based match is
14
+ found and the value does not look path-like, falls back to looking it up
15
+ by repository name. Prints a rich error and returns ``None`` on missing
16
+ or ambiguous targets.
17
+ """
18
+ manager = RepositoryManager()
19
+ candidate = Path(target)
20
+ is_path_like = (
21
+ "/" in target
22
+ or "\\" in target
23
+ or target in (".", "..")
24
+ or candidate.is_absolute()
25
+ or target.startswith("~")
26
+ or candidate.exists()
27
+ )
28
+
29
+ if is_path_like:
30
+ normalized = normalize_repository_path(candidate)
31
+ if manager.config.has_repository(normalized):
32
+ return normalized
33
+ console.print(f"\n [red]No tracked repository at path: {normalized}[/red]\n")
34
+ return None
35
+
36
+ matches = [r for r in manager.config.repositories if r.name == target]
37
+ if not matches:
38
+ console.print(f"\n [red]No tracked repository named: {target}[/red]\n")
39
+ return None
40
+ if len(matches) > 1:
41
+ paths_list = "\n".join(f" {p}" for p in matches)
42
+ console.print(
43
+ f"\n [red]Multiple repositories named '{target}' — use the full path:[/red]\n"
44
+ f"{paths_list}\n"
45
+ )
46
+ return None
47
+ return matches[0]
48
+
49
+
50
+ def register(cli: click.Group):
51
+ @cli.command()
52
+ @click.argument("target", metavar="PATH|NAME")
53
+ @click.argument("command")
54
+ def gd_tmux(target: str, command: str):
55
+ """Create a gd tmux session for a repository and run a command in it."""
56
+ if not command.strip():
57
+ console.print("\n [red]Command must not be empty.[/red]\n")
58
+ raise SystemExit(1)
59
+
60
+ repo_path = _resolve_repo(target)
61
+ if repo_path is None:
62
+ raise SystemExit(1)
63
+
64
+ try:
65
+ from ..integrations.tmux import (
66
+ attach_tmux_session,
67
+ create_tmux_session,
68
+ launch_command_in_tmux_session,
69
+ )
70
+ except ImportError:
71
+ console.print(
72
+ "\n [red]The tmux integration is unavailable for the gd-tmux command.[/red]\n"
73
+ " Reinstall gitdirector or check your installation.\n"
74
+ )
75
+ raise SystemExit(1)
76
+
77
+ session_name = create_tmux_session(repo_path.name, repo_path, purpose=command)
78
+ launch_command_in_tmux_session(session_name, command)
79
+ attach_tmux_session(session_name, skip_config_sync=True)
@@ -32,8 +32,9 @@ def show_help():
32
32
  ("pull", "Pull latest changes for all tracked repositories"),
33
33
  ("cd NAME", "Open or switch to a tmux session for a repository"),
34
34
  ("console", "Interactive TUI for browsing and opening repositories"),
35
- ("autoclean links|sessions", "Clean broken links or stale tmux sessions"),
35
+ ("autoclean", "Remove broken repository links from tracking"),
36
36
  ("info PATH|NAME [--full]", "Show file statistics for a repository"),
37
+ ('gd-tmux PATH|NAME "cmd"', "Create a gd tmux session and run a command in it"),
37
38
  ("help", "Show this help message"),
38
39
  ]:
39
40
  cmd_table.add_row(cmd, desc)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from concurrent.futures import ThreadPoolExecutor
6
7
  from pathlib import Path
7
8
  from typing import Callable
@@ -55,6 +56,9 @@ __all__ = [
55
56
  ]
56
57
 
57
58
 
59
+ logger = logging.getLogger(__name__)
60
+
61
+
58
62
  class GitDirectorConsole(
59
63
  ConsolePanelsMixin,
60
64
  ConsoleSessionsMixin,
@@ -355,13 +359,13 @@ class GitDirectorConsole(
355
359
  if path is None:
356
360
  return
357
361
 
358
- from ...integrations.tmux import create_tmux_session, launch_agent_in_tmux_session
362
+ from ...integrations.tmux import create_tmux_session, launch_command_in_tmux_session
359
363
 
360
364
  purpose = agent_cmd if agent_cmd else "shell"
361
365
  session_name = create_tmux_session(path.name, path, purpose=purpose)
362
366
 
363
367
  if agent_cmd:
364
- ready_marker = launch_agent_in_tmux_session(session_name, agent_cmd)
368
+ ready_marker = launch_command_in_tmux_session(session_name, agent_cmd)
365
369
  self.push_screen(
366
370
  AgentLoadingScreen(agent_cmd, session_name, ready_marker),
367
371
  callback=lambda _: self.set_timer(0.2, lambda: self._refresh_repo_for_path(path)),
@@ -657,10 +661,27 @@ class GitDirectorConsole(
657
661
 
658
662
  from ..pull import pull_repository
659
663
 
660
- result = pull_repository(path)
664
+ try:
665
+ result = pull_repository(path)
666
+ except Exception as exc:
667
+ logger.exception("pull worker crashed")
668
+ error_result = (path.name, False, f"Pull failed: {exc}")
669
+ if self._background_shutdown_requested(worker):
670
+ return
671
+ try:
672
+ self.call_from_thread(
673
+ self._show_pull_result, loading_screen, path, command, error_result
674
+ )
675
+ except Exception:
676
+ logger.debug("Failed to post pull error to UI", exc_info=True)
677
+ return
678
+
661
679
  if self._background_shutdown_requested(worker):
662
680
  return
663
- self.call_from_thread(self._show_pull_result, loading_screen, path, command, result)
681
+ try:
682
+ self.call_from_thread(self._show_pull_result, loading_screen, path, command, result)
683
+ except Exception:
684
+ logger.debug("Failed to post pull result to UI", exc_info=True)
664
685
 
665
686
  def _show_pull_result(
666
687
  self,
@@ -218,6 +218,7 @@ class ChangedFile:
218
218
  additions: int = 0
219
219
  deletions: int = 0
220
220
  is_binary: bool = False
221
+ is_image: bool = False
221
222
  is_rename: bool = False
222
223
  old_path: str | None = None
223
224
  diff_text: str = ""
@@ -263,6 +264,35 @@ _RENAME_TO_RE = re.compile(r"^rename to (.+)$")
263
264
  _BINARY_RE = re.compile(r"^Binary files .* differ$")
264
265
  _HUNK_HEADER_RE = re.compile(r"^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@")
265
266
 
267
+ _IMAGE_EXTENSIONS: frozenset[str] = frozenset(
268
+ {
269
+ ".png",
270
+ ".jpg",
271
+ ".jpeg",
272
+ ".gif",
273
+ ".bmp",
274
+ ".ico",
275
+ ".webp",
276
+ ".tiff",
277
+ ".tif",
278
+ }
279
+ )
280
+
281
+
282
+ def is_image_file(path: str) -> bool:
283
+ """Return ``True`` if ``path`` has a raster-image extension.
284
+
285
+ Used to decide whether the diff viewer should suppress the right-hand
286
+ diff/preview pane entirely for a file. SVG is intentionally excluded
287
+ because it's text-based and the regular diff renderer handles it fine.
288
+ """
289
+ if not path:
290
+ return False
291
+ name = PurePosixPath(path).name.lower()
292
+ if "." not in name:
293
+ return False
294
+ return "." + name.rsplit(".", 1)[1] in _IMAGE_EXTENSIONS
295
+
266
296
 
267
297
  def _parse_hunk_start(header: str) -> tuple[int, int]:
268
298
  """Return ``(old_start, new_start)`` from an ``@@`` hunk header."""
@@ -373,6 +403,7 @@ def parse_diff_files(diff_text: str) -> list[ChangedFile]:
373
403
  additions=current.additions,
374
404
  deletions=current.deletions,
375
405
  is_binary=current.is_binary,
406
+ is_image=current.is_image,
376
407
  is_rename=current.is_rename,
377
408
  old_path=current.old_path,
378
409
  diff_text="\n".join(current_lines).rstrip("\n"),
@@ -399,6 +430,7 @@ def parse_diff_files(diff_text: str) -> list[ChangedFile]:
399
430
  path=new_name,
400
431
  status="M",
401
432
  is_rename=is_rename,
433
+ is_image=is_image_file(new_name),
402
434
  old_path=old_name if is_rename else None,
403
435
  )
404
436
  current_lines = [raw_line]
@@ -469,6 +501,7 @@ def build_diff_bundle(diff_text: str, untracked_paths: list[str], untracked_look
469
501
  path=rel_path,
470
502
  status="?",
471
503
  additions=line_count,
504
+ is_image=is_image_file(rel_path),
472
505
  diff_text=synthetic.rstrip("\n"),
473
506
  raw_untracked_text=text,
474
507
  )
@@ -512,6 +545,9 @@ def render_change_summary(
512
545
  if file.is_binary:
513
546
  text.append(" ")
514
547
  text.append("[binary]", style="dim")
548
+ elif file.is_image:
549
+ text.append(" ")
550
+ text.append("[image]", style="dim")
515
551
  elif file.additions or file.deletions:
516
552
  text.append(" ")
517
553
  text.append(f"+{file.additions}", style="#3fb950")
@@ -539,9 +575,12 @@ def render_file_diff(
539
575
 
540
576
  pieces.append(_render_file_header(file))
541
577
 
578
+ if file.is_image:
579
+ return Group(*pieces)
580
+
542
581
  if file.is_binary:
543
582
  body = RichText(
544
- "\n Binary file differs from HEAD.\n" " Diff is not shown for binary files.\n",
583
+ "\n Binary file differs from HEAD.\n Diff is not shown for binary files.\n",
545
584
  style="italic dim",
546
585
  )
547
586
  pieces.append(Padding(body, (1, 2)))
@@ -10,6 +10,7 @@ from textual.widgets import OptionList, Static
10
10
  from textual.widgets.option_list import Option
11
11
 
12
12
  from ..constants import _MODAL_BINDINGS, _MODAL_CSS, _SORT_COLUMN_NAMES
13
+ from ..terminal_caps import strip_unsupported_css as _safe_css
13
14
 
14
15
 
15
16
  def _render_ansi_output(output: str) -> Text:
@@ -21,7 +22,7 @@ class ConfirmScreen(ModalScreen[bool]):
21
22
 
22
23
  BINDINGS = _MODAL_BINDINGS
23
24
 
24
- CSS = (
25
+ CSS = _safe_css(
25
26
  "ConfirmScreen { align: center middle; background: $panel 80%; hatch: right $primary 30%; }"
26
27
  + _MODAL_CSS
27
28
  )
@@ -61,7 +62,7 @@ class SortMenuScreen(ModalScreen[tuple | None]):
61
62
 
62
63
  BINDINGS = _MODAL_BINDINGS
63
64
 
64
- CSS = (
65
+ CSS = _safe_css(
65
66
  "SortMenuScreen {"
66
67
  " align: center middle; background: $panel 80%; hatch: right $primary 30%;"
67
68
  " }" + _MODAL_CSS
@@ -33,6 +33,7 @@ from textual.widgets import Input, LoadingIndicator, OptionList, Static
33
33
  from textual.widgets.option_list import Option
34
34
 
35
35
  from ..constants import _MODAL_BINDINGS, _MODAL_CSS
36
+ from ..terminal_caps import strip_unsupported_css as _safe_css
36
37
 
37
38
  # ---------------------------------------------------------------------------
38
39
  # Stage-all confirm
@@ -52,7 +53,7 @@ class StageFilesConfirmScreen(ModalScreen[bool]):
52
53
 
53
54
  BINDINGS = _MODAL_BINDINGS
54
55
 
55
- CSS = (
56
+ CSS = _safe_css(
56
57
  "StageFilesConfirmScreen {"
57
58
  " align: center middle; background: $panel 80%; hatch: right $primary 30%;"
58
59
  " }" + _MODAL_CSS
@@ -86,7 +87,7 @@ class StageFilesConfirmScreen(ModalScreen[bool]):
86
87
  id="action-menu",
87
88
  )
88
89
  yield Static(
89
- "[green]+N[/green] additions [red]-N[/red] deletions " "\u00b7 esc to cancel",
90
+ "[green]+N[/green] additions [red]-N[/red] deletions \u00b7 esc to cancel",
90
91
  id="menu-hint",
91
92
  )
92
93
 
@@ -142,10 +143,12 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
142
143
  Binding("up", "action_cursor_up", "\u2191 action", show=False),
143
144
  ]
144
145
 
145
- CSS = (
146
+ CSS = _safe_css(
146
147
  "CommitMessageScreen {"
147
148
  " align: center middle; background: $panel 80%; hatch: right $primary 30%;"
148
- " }" + _MODAL_CSS + """
149
+ " }"
150
+ + _MODAL_CSS
151
+ + """
149
152
  #commit-message-container {
150
153
  width: 60%;
151
154
  height: auto;
@@ -202,8 +205,7 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
202
205
  noun = "file" if self.file_count == 1 else "files"
203
206
  with Vertical(id="commit-message-container"):
204
207
  yield Static(
205
- f"[bold white]Commit changes in[/bold white] "
206
- f"[cyan]{escape(self.repo_name)}[/cyan]",
208
+ f"[bold white]Commit changes in[/bold white] [cyan]{escape(self.repo_name)}[/cyan]",
207
209
  id="commit-message-title",
208
210
  )
209
211
  yield Static(
@@ -222,10 +224,7 @@ class CommitMessageScreen(ModalScreen[Optional[tuple[str, bool]]]):
222
224
  id="commit-message-action-list",
223
225
  )
224
226
  yield Static(
225
- "type message "
226
- "[\u2191\u2193] pick action "
227
- "[enter] confirm "
228
- "[esc] cancel",
227
+ "type message [\u2191\u2193] pick action [enter] confirm [esc] cancel",
229
228
  id="commit-message-hint",
230
229
  )
231
230
 
@@ -304,10 +303,12 @@ class CommitLoadingScreen(ModalScreen[None]):
304
303
 
305
304
  BINDINGS = [Binding("escape", "noop", "Esc", show=False)]
306
305
 
307
- CSS = (
306
+ CSS = _safe_css(
308
307
  "CommitLoadingScreen {"
309
308
  " align: center middle; background: $panel 80%; hatch: right $primary 30%;"
310
- " }" + _MODAL_CSS + """
309
+ " }"
310
+ + _MODAL_CSS
311
+ + """
311
312
  #commit-loading-container {
312
313
  width: 50%;
313
314
  height: auto;
@@ -368,10 +369,12 @@ class CommitResultScreen(ModalScreen[None]):
368
369
 
369
370
  BINDINGS = _MODAL_BINDINGS
370
371
 
371
- CSS = (
372
+ CSS = _safe_css(
372
373
  "CommitResultScreen {"
373
374
  " align: center middle; background: $panel 80%; hatch: right $primary 30%;"
374
- " }" + _MODAL_CSS + """
375
+ " }"
376
+ + _MODAL_CSS
377
+ + """
375
378
  #commit-result-container {
376
379
  width: 60%;
377
380
  height: auto;
@@ -353,6 +353,9 @@ class FileTile(Static):
353
353
  if self._spec.file.is_binary:
354
354
  text.append("[binary]", style="#8b949e")
355
355
  return text
356
+ if self._spec.file.is_image:
357
+ text.append("[image]", style="#8b949e")
358
+ return text
356
359
  additions = self._spec.file.additions
357
360
  deletions = self._spec.file.deletions
358
361
  text.append(f"+{additions}", style="bold #3fb950")
@@ -445,6 +448,9 @@ class FileTileList(ListView):
445
448
  super().__init__(**kwargs)
446
449
  self._specs: list[_FileTileSpec] = []
447
450
  self._repo_dir: str = ""
451
+ self._pending_index: int | None = None
452
+ self._pending_retry_count = 0
453
+ self._PENDING_RETRY_LIMIT = 50
448
454
 
449
455
  def set_files(self, files: list[ChangedFile], repo_dir: str = "") -> None:
450
456
  self._repo_dir = repo_dir
@@ -455,19 +461,28 @@ class FileTileList(ListView):
455
461
  tile = FileTile(spec)
456
462
  self.append(ListItem(tile, id=f"file-tile-{id(tile)}"))
457
463
  if self._specs:
458
- # The ListItem children mount asynchronously, so we set the
459
- # initial index via a deferred call. Trying to set ``index``
460
- # now would fire ``watch_index`` against half-mounted children
461
- # and crash with NoMatches.
462
464
  self._suppress_watch = True
463
465
  self._pending_index = 0
466
+ self._pending_retry_count = 0
464
467
  self.call_after_refresh(self._apply_initial_selection)
468
+ else:
469
+ self._suppress_watch = False
470
+ self._pending_index = None
465
471
 
466
472
  def _apply_initial_selection(self) -> None:
467
- pending = getattr(self, "_pending_index", None)
473
+ pending = self._pending_index
468
474
  if pending is None:
469
475
  return
476
+ if len(self._nodes) <= pending:
477
+ self._pending_retry_count += 1
478
+ if self._pending_retry_count > self._PENDING_RETRY_LIMIT:
479
+ self._pending_index = None
480
+ self._suppress_watch = False
481
+ return
482
+ self.call_after_refresh(self._apply_initial_selection)
483
+ return
470
484
  self._pending_index = None
485
+ self._pending_retry_count = 0
471
486
  self._suppress_watch = False
472
487
  self.index = pending
473
488