gitpulse-tui 1.2.1__tar.gz → 1.2.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 (31) hide show
  1. {gitpulse_tui-1.2.1/gitpulse_tui.egg-info → gitpulse_tui-1.2.2}/PKG-INFO +1 -1
  2. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/README.md +14 -3
  3. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/git_ops.py +8 -8
  4. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/bulk_results.py +10 -10
  5. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/command_palette.py +5 -6
  6. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/digest_screen.py +23 -23
  7. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/fleet_status.py +21 -17
  8. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/sidebar.py +39 -46
  9. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/stale_screen.py +20 -22
  10. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/styles.tcss +133 -104
  11. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/tabs.py +79 -79
  12. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/utils.py +1 -1
  13. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2/gitpulse_tui.egg-info}/PKG-INFO +1 -1
  14. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/pyproject.toml +1 -1
  15. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/LICENSE +0 -0
  16. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/__init__.py +0 -0
  17. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/__main__.py +0 -0
  18. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/config.py +0 -0
  19. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/digest.py +0 -0
  20. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/main.py +0 -0
  21. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/parallel.py +0 -0
  22. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/scanner.py +0 -0
  23. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/stale.py +0 -0
  24. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/__init__.py +0 -0
  25. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/watcher.py +0 -0
  26. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/SOURCES.txt +0 -0
  27. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
  28. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/entry_points.txt +0 -0
  29. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/requires.txt +0 -0
  30. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/top_level.txt +0 -0
  31. {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpulse-tui
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Git Repo Dashboard TUI — live status, commits, diffs, and branches in your terminal
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -14,15 +14,26 @@ Built with **Python**, **Textual**, **Rich**, and **GitPython**.
14
14
 
15
15
  ![Commits tab](ss/commits.png)
16
16
 
17
- ## Install (one command)
17
+ ## Installation
18
+
19
+ The easiest way to install GitPulse is via PyPI using `pip` or `pipx`:
20
+
21
+ ```bash
22
+ pip install gitpulse-tui
23
+ ```
24
+ *(We recommend using `pipx install gitpulse-tui` to install it in an isolated environment)*
25
+
26
+ ### Install from source
27
+
28
+ If you prefer to install from source or want to contribute to the project:
18
29
 
19
30
  ```bash
20
- git clone https://github.com/yourname/git-tui.git
31
+ git clone https://github.com/lebiraja/git-tui.git
21
32
  cd git-tui
22
33
  ./install.sh
23
34
  ```
24
35
 
25
- That's it. The installer:
36
+ The installer:
26
37
  - Checks your Python version (3.10+ required)
27
38
  - Creates a virtual environment automatically
28
39
  - Installs all dependencies
@@ -711,8 +711,8 @@ def get_file_tree(path: Path) -> "rich.tree.Tree":
711
711
  # Render the dict structure into a Rich Tree
712
712
  repo_name = path.name
713
713
  rich_tree = RichTree(
714
- f"[bold #7aa2f7]📁 {repo_name}[/]",
715
- guide_style="#3b4261",
714
+ f"[bold #ff2d4a]📁 {repo_name}[/]",
715
+ guide_style="#2a2a3a",
716
716
  )
717
717
 
718
718
  def build_tree(node: "RichTree", d: dict) -> None:
@@ -721,22 +721,22 @@ def get_file_tree(path: Path) -> "rich.tree.Tree":
721
721
  files = sorted(k for k, v in d.items() if v is None)
722
722
 
723
723
  for name in dirs:
724
- branch = node.add(f"[bold #bb9af7]📂 {name}[/]")
724
+ branch = node.add(f"[bold #e040fb]📂 {name}[/]")
725
725
  build_tree(branch, d[name])
726
726
 
727
727
  for name in files:
728
728
  # Color-code by extension
729
729
  ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
730
730
  if ext in ("py", "js", "ts", "go", "rs", "c", "cpp", "java"):
731
- label = f"[#9ece6a] {name}[/]"
731
+ label = f"[#3ddc84] {name}[/]"
732
732
  elif ext in ("md", "rst", "txt"):
733
- label = f"[#e0af68] {name}[/]"
733
+ label = f"[#ffb74d] {name}[/]"
734
734
  elif ext in ("json", "yaml", "yml", "toml", "ini", "cfg", "env"):
735
- label = f"[#7dcfff] {name}[/]"
735
+ label = f"[#4dd0e1] {name}[/]"
736
736
  elif ext in ("sh", "bash", "zsh"):
737
- label = f"[#f7768e] {name}[/]"
737
+ label = f"[#ff5252] {name}[/]"
738
738
  else:
739
- label = f"[#c0caf5] {name}[/]"
739
+ label = f"[#d4d4dc] {name}[/]"
740
740
  node.add(label)
741
741
 
742
742
  build_tree(rich_tree, root_dict)
@@ -30,14 +30,14 @@ class BulkResultsScreen(ModalScreen):
30
30
  #results-frame {
31
31
  width: 88%;
32
32
  height: 75%;
33
- background: #1e2030;
34
- border: thick #bb9af7;
33
+ background: #1a1a24;
34
+ border: thick #e040fb;
35
35
  }
36
36
  #results-title {
37
37
  dock: top;
38
38
  height: 1;
39
- background: #24283b;
40
- color: #bb9af7;
39
+ background: #242430;
40
+ color: #e040fb;
41
41
  text-style: bold;
42
42
  padding: 0 1;
43
43
  }
@@ -48,10 +48,10 @@ class BulkResultsScreen(ModalScreen):
48
48
  #results-footer {
49
49
  dock: bottom;
50
50
  height: 1;
51
- background: #1e2030;
52
- color: #565f89;
51
+ background: #1a1a24;
52
+ color: #555568;
53
53
  padding: 0 1;
54
- border-top: solid #3b4261;
54
+ border-top: solid #2a2a3a;
55
55
  }
56
56
  """
57
57
 
@@ -87,13 +87,13 @@ class BulkResultsScreen(ModalScreen):
87
87
  self._completed += 1
88
88
 
89
89
  if isinstance(result, Exception):
90
- status = "[bold #f7768e]ERROR[/]"
90
+ status = "[bold #ff5252]ERROR[/]"
91
91
  output = str(result)[:80]
92
92
  elif isinstance(result, str) and result.lower().startswith("error"):
93
- status = "[bold #f7768e]FAIL[/]"
93
+ status = "[bold #ff5252]FAIL[/]"
94
94
  output = result[:80]
95
95
  else:
96
- status = "[bold #9ece6a]OK[/]"
96
+ status = "[bold #3ddc84]OK[/]"
97
97
  output = (str(result) if result else "done")[:80]
98
98
 
99
99
  table.add_row(repo.name, status, output)
@@ -39,19 +39,19 @@ class CommandPaletteModal(ModalScreen):
39
39
  height: auto;
40
40
  max-height: 24;
41
41
  padding: 1 2;
42
- background: #1e2030;
43
- border: thick #7aa2f7;
42
+ background: #1a1a24;
43
+ border: thick #ff2d4a;
44
44
  }
45
45
  #palette-title {
46
46
  text-style: bold;
47
- color: #7aa2f7;
47
+ color: #ff2d4a;
48
48
  margin-bottom: 1;
49
49
  text-align: center;
50
50
  width: 100%;
51
51
  height: 1;
52
52
  }
53
53
  #palette-scope {
54
- color: #e0af68;
54
+ color: #ffb74d;
55
55
  margin-bottom: 1;
56
56
  width: 100%;
57
57
  height: 1;
@@ -93,7 +93,7 @@ class CommandPaletteModal(ModalScreen):
93
93
  lv.clear()
94
94
  for key, label, desc in actions:
95
95
  lv.append(ListItem(
96
- Static(f"[bold #9ece6a]{label}[/] [dim #565f89]{desc}[/]", markup=True),
96
+ Static(f"[bold #3ddc84]{label}[/] [dim #555568]{desc}[/]", markup=True),
97
97
  id=f"action-{key}",
98
98
  ))
99
99
  if actions:
@@ -120,7 +120,6 @@ class CommandPaletteModal(ModalScreen):
120
120
  lv: ListView = self.query_one("#palette-list", ListView)
121
121
  item = lv.highlighted_child
122
122
  if item is None and self._filtered:
123
- # If nothing highlighted but there's a match, pick first
124
123
  self._dispatch(self._filtered[0][0])
125
124
  return
126
125
  if item is not None and item.id and item.id.startswith("action-"):
@@ -51,23 +51,23 @@ class DigestScreen(ModalScreen):
51
51
  #digest-frame {
52
52
  width: 95%;
53
53
  height: 90%;
54
- background: #1e2030;
55
- border: thick #7aa2f7;
54
+ background: #1a1a24;
55
+ border: thick #ff2d4a;
56
56
  }
57
57
  #digest-header {
58
58
  dock: top;
59
59
  height: 3;
60
- background: #24283b;
61
- color: #7aa2f7;
60
+ background: #242430;
61
+ color: #ff2d4a;
62
62
  text-style: bold;
63
63
  padding: 0 2;
64
- border-bottom: heavy #3b4261;
64
+ border-bottom: heavy #2a2a3a;
65
65
  layout: horizontal;
66
66
  align: left middle;
67
67
  }
68
68
  #digest-window-label {
69
69
  width: auto;
70
- color: #bb9af7;
70
+ color: #e040fb;
71
71
  margin-left: 2;
72
72
  }
73
73
  #digest-scroll {
@@ -81,10 +81,10 @@ class DigestScreen(ModalScreen):
81
81
  #digest-footer {
82
82
  dock: bottom;
83
83
  height: 1;
84
- background: #1e2030;
85
- color: #565f89;
84
+ background: #1a1a24;
85
+ color: #555568;
86
86
  padding: 0 1;
87
- border-top: solid #3b4261;
87
+ border-top: solid #2a2a3a;
88
88
  }
89
89
  """
90
90
 
@@ -123,7 +123,7 @@ class DigestScreen(ModalScreen):
123
123
 
124
124
  def _load_digest(self) -> None:
125
125
  label: Static = self.query_one("#digest-window-label", Static)
126
- label.update(f"[#bb9af7]window: {self._window}[/]")
126
+ label.update(f"[#e040fb]window: {self._window}[/]")
127
127
 
128
128
  body: Static = self.query_one("#digest-body", Static)
129
129
  body.update("[dim italic]Computing digest…[/]")
@@ -131,7 +131,7 @@ class DigestScreen(ModalScreen):
131
131
  try:
132
132
  since_ts = parse_since(self._window)
133
133
  except ValueError as e:
134
- body.update(f"[bold #f7768e]Error: {e}[/]")
134
+ body.update(f"[bold #ff5252]Error: {e}[/]")
135
135
  return
136
136
 
137
137
  # Run in a worker so we don't block the UI
@@ -148,7 +148,7 @@ class DigestScreen(ModalScreen):
148
148
  self._render_digest()
149
149
  elif event.state == WorkerState.ERROR:
150
150
  body: Static = self.query_one("#digest-body", Static)
151
- body.update(f"[bold #f7768e]Error building digest: {event.worker.error}[/]")
151
+ body.update(f"[bold #ff5252]Error building digest: {event.worker.error}[/]")
152
152
 
153
153
  def _render_digest(self) -> None:
154
154
  d = self._digest
@@ -160,34 +160,34 @@ class DigestScreen(ModalScreen):
160
160
  if d.total_commits == 0:
161
161
  body.update(
162
162
  "[dim italic] No commits found for this window and author pattern.[/]\n"
163
- "[dim #565f89] Tip: configure author emails in ~/.config/gitpulse/config.toml[/]"
163
+ "[dim #555568] Tip: configure author emails in ~/.config/gitpulse/config.toml[/]"
164
164
  )
165
165
  return
166
166
 
167
167
  lines: list[str] = []
168
168
  since_str = datetime.fromtimestamp(d.since_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
169
169
  lines.append(
170
- f"[bold #7aa2f7]{d.total_commits}[/] commits across "
171
- f"[bold #9ece6a]{d.repos_active}[/] repos "
172
- f"[#9ece6a]+{d.total_insertions}[/] [#f7768e]-{d.total_deletions}[/] lines "
170
+ f"[bold #ff2d4a]{d.total_commits}[/] commits across "
171
+ f"[bold #3ddc84]{d.repos_active}[/] repos "
172
+ f"[#3ddc84]+{d.total_insertions}[/] [#ff5252]-{d.total_deletions}[/] lines "
173
173
  f"[dim]since {since_str} UTC[/]"
174
174
  )
175
- lines.append("[dim #3b4261]─" * 60 + "[/]")
175
+ lines.append("[dim #2a2a3a]─" * 60 + "[/]")
176
176
 
177
177
  for rd in d.by_repo:
178
178
  lines.append(
179
- f"\n[bold #bb9af7]📁 {rd.repo.name}[/] "
179
+ f"\n[bold #e040fb]📁 {rd.repo.name}[/] "
180
180
  f"[dim]{len(rd.commits)} commit{'s' if len(rd.commits) != 1 else ''} "
181
- f"[#9ece6a]+{rd.insertions}[/]"
182
- f" [#f7768e]-{rd.deletions}[/][/dim]"
181
+ f"[#3ddc84]+{rd.insertions}[/]"
182
+ f" [#ff5252]-{rd.deletions}[/][/dim]"
183
183
  )
184
184
  for c in rd.commits:
185
185
  rel = relative_time(c.ts)
186
- stats = f"[#9ece6a]+{c.insertions}[/] [#f7768e]-{c.deletions}[/]" if (c.insertions or c.deletions) else ""
186
+ stats = f"[#3ddc84]+{c.insertions}[/] [#ff5252]-{c.deletions}[/]" if (c.insertions or c.deletions) else ""
187
187
  msg = c.message[:70]
188
188
  lines.append(
189
- f" [dim #7aa2f7]{c.short_hash}[/] {msg}"
190
- f" {stats} [dim #565f89]{rel}[/]"
189
+ f" [dim #ff2d4a]{c.short_hash}[/] {msg}"
190
+ f" {stats} [dim #555568]{rel}[/]"
191
191
  )
192
192
 
193
193
  body.update("\n".join(lines))
@@ -31,7 +31,7 @@ class FleetChip(Static):
31
31
  content-align: center middle;
32
32
  }
33
33
  FleetChip:hover {
34
- background: #283457;
34
+ background: #2d1520;
35
35
  }
36
36
  """
37
37
 
@@ -53,20 +53,21 @@ class FleetStatus(Widget):
53
53
  - ahead — repos with unpushed commits
54
54
  - stashes — total stash entries (sum)
55
55
  - stale — repos with stale local branches
56
+ - all — reset chip to clear any active filter
56
57
  """
57
58
 
58
59
  DEFAULT_CSS = """
59
60
  FleetStatus {
60
61
  height: 3;
61
- background: #1e2030;
62
- border-bottom: heavy #3b4261;
62
+ background: #1a1a24;
63
+ border-bottom: heavy #2a2a3a;
63
64
  layout: horizontal;
64
65
  align: left middle;
65
66
  padding: 0 1;
66
67
  }
67
68
  FleetStatus > Static#fleet-label {
68
69
  width: auto;
69
- color: #565f89;
70
+ color: #555568;
70
71
  margin-right: 1;
71
72
  }
72
73
  """
@@ -85,13 +86,16 @@ class FleetStatus(Widget):
85
86
  yield FleetChip("ahead", id="chip-ahead")
86
87
  yield FleetChip("stashes", id="chip-stashes")
87
88
  yield FleetChip("stale", id="chip-stale")
89
+ yield FleetChip("all", id="chip-all")
88
90
 
89
91
  def on_mount(self) -> None:
90
- self._set_chip("chip-dirty", "🔴", 0, "#f7768e")
91
- self._set_chip("chip-behind", "↓", 0, "#f7768e")
92
- self._set_chip("chip-ahead", "↑", 0, "#e0af68")
93
- self._set_chip("chip-stashes", "📦", 0, "#7aa2f7")
94
- self._set_chip("chip-stale", "💀", 0, "#bb9af7")
92
+ self._set_chip("chip-dirty", "◆ dirty", 0, "#ff5252")
93
+ self._set_chip("chip-behind", "↓ behind", 0, "#ff5252")
94
+ self._set_chip("chip-ahead", "↑ ahead", 0, "#ffb74d")
95
+ self._set_chip("chip-stashes", "⊞ stash", 0, "#4dd0e1")
96
+ self._set_chip("chip-stale", "☠ stale", 0, "#e040fb")
97
+ chip: FleetChip = self.query_one("#chip-all", FleetChip)
98
+ chip.update("[dim #555568]· all[/]")
95
99
 
96
100
  def update_counters(self, repos: list[RepoInfo]) -> None:
97
101
  """Recompute all chips from the current repo list."""
@@ -101,15 +105,15 @@ class FleetStatus(Widget):
101
105
  total_stashes = sum(r.stash_count for r in repos)
102
106
  n_stale = sum(1 for r in repos if r.has_stale_branches)
103
107
 
104
- self._set_chip("chip-dirty", "🔴", n_dirty, "#f7768e")
105
- self._set_chip("chip-behind", "↓", total_behind, "#f7768e")
106
- self._set_chip("chip-ahead", "↑", n_ahead, "#e0af68")
107
- self._set_chip("chip-stashes", "📦", total_stashes, "#7aa2f7")
108
- self._set_chip("chip-stale", "💀", n_stale, "#bb9af7")
108
+ self._set_chip("chip-dirty", "◆ dirty", n_dirty, "#ff5252")
109
+ self._set_chip("chip-behind", "↓ behind", total_behind, "#ff5252")
110
+ self._set_chip("chip-ahead", "↑ ahead", n_ahead, "#ffb74d")
111
+ self._set_chip("chip-stashes", "⊞ stash", total_stashes, "#4dd0e1")
112
+ self._set_chip("chip-stale", "☠ stale", n_stale, "#e040fb")
109
113
 
110
- def _set_chip(self, widget_id: str, icon: str, count: int, color: str) -> None:
114
+ def _set_chip(self, widget_id: str, label: str, count: int, color: str) -> None:
111
115
  chip: FleetChip = self.query_one(f"#{widget_id}", FleetChip)
112
116
  if count == 0:
113
- chip.update(f"[dim #565f89]{icon} 0[/]")
117
+ chip.update(f"[dim #2a2a3a]{label}: 0[/]")
114
118
  else:
115
- chip.update(f"[bold {color}]{icon} {count}[/]")
119
+ chip.update(f"[bold {color}]{label}: {count}[/]")
@@ -31,13 +31,13 @@ def _make_badge(info: RepoInfo) -> str:
31
31
  """Build a Rich markup badge string with icon and optional file count."""
32
32
  count = info.modified_count
33
33
  if info.status == RepoStatus.CLEAN:
34
- return "[bold white on #2d7d46] ✔ Clean [/]"
34
+ return "[bold #3ddc84 on #0f2a1a] ✔ Clean [/]"
35
35
  elif info.status == RepoStatus.MODIFIED:
36
- label = f" ● Modified ({count}) " if count else " ● Modified "
37
- return f"[bold #1a1b26 on #e0af68]{label}[/]"
36
+ label = f" ● {count} modified " if count else " ● Modified "
37
+ return f"[bold #ffb74d on #2a1e00]{label}[/]"
38
38
  else: # UNTRACKED
39
- label = f" ○ Untracked ({count}) " if count else " ○ Untracked "
40
- return f"[bold white on #db4b4b]{label}[/]"
39
+ label = f" ○ {count} untracked " if count else " ○ Untracked "
40
+ return f"[bold #ff5252 on #2a0a0a]{label}[/]"
41
41
 
42
42
 
43
43
  # ---------------------------------------------------------------------------
@@ -49,12 +49,12 @@ _SPARK_CHARS = " ▁▂▃▄▅▆▇█"
49
49
  def _sparkline(activity: list[int]) -> str:
50
50
  """Build a 7-char sparkline from weekly commit counts (oldest→newest)."""
51
51
  if not activity or len(activity) < 7:
52
- return "[dim #3b4261]▁▁▁▁▁▁▁[/]"
52
+ return "[dim #2a2a3a]▁▁▁▁▁▁▁[/]"
53
53
  mx = max(activity)
54
54
  if mx == 0:
55
- return "[dim #3b4261]▁▁▁▁▁▁▁[/]"
55
+ return "[dim #2a2a3a]▁▁▁▁▁▁▁[/]"
56
56
  chars = "".join(_SPARK_CHARS[min(8, int(v / mx * 8))] for v in activity)
57
- return f"[#7aa2f7]{chars}[/]"
57
+ return f"[#ff2d4a]{chars}[/]"
58
58
 
59
59
 
60
60
  # ---------------------------------------------------------------------------
@@ -88,24 +88,29 @@ class RepoListItem(ListItem):
88
88
 
89
89
  # Selection checkbox prefix
90
90
  if self._selected:
91
- checkbox = "[bold #9ece6a][✓][/] "
91
+ checkbox = "[bold #3ddc84][✓][/] "
92
92
  else:
93
- checkbox = "[dim #3b4261][ ][/] "
93
+ checkbox = "[dim #2a2a3a][ ][/] "
94
+
95
+ # Shorten path for display
96
+ path_str = str(info.path)
97
+ home = str(Path.home())
98
+ if path_str.startswith(home):
99
+ path_str = "~" + path_str[len(home):]
100
+ if len(path_str) > 38:
101
+ path_str = "…" + path_str[-37:]
94
102
 
95
103
  # Line 1: checkbox + repo name + badge
96
- line1 = f"{checkbox}[bold #c0caf5]{info.name}[/] {badge}"
104
+ line1 = f"{checkbox}[bold #d4d4dc]{info.name}[/] {badge}"
97
105
  # Line 2: branch | relative time | sparkline
98
- line2 = f" [#bb9af7]⎇ {info.branch}[/] [dim #565f89]⏱ {rel}[/] {spark}"
106
+ line2 = f" [#e040fb]⎇ {info.branch}[/] [dim #555568]⏱ {rel}[/] {spark}"
99
107
  # Line 3: truncated last commit message for quick context
100
108
  commit_msg = info.last_commit_msg
101
109
  if len(commit_msg) > 36:
102
110
  commit_msg = commit_msg[:35] + "…"
103
- line3 = f" [dim #565f89]💬 {commit_msg}[/]" if commit_msg else " [dim #3b4261]no commits[/]"
111
+ line3 = f" [dim #555568]💬 {commit_msg}[/]" if commit_msg else " [dim #2a2a3a]no commits[/]"
104
112
  # Line 4: truncated repo path for disambiguation
105
- path_str = str(info.path)
106
- if len(path_str) > 38:
107
- path_str = "…" + path_str[-37:]
108
- line4 = f" [dim #3b4261]{path_str}[/]"
113
+ line4 = f" [dim #2a2a3a]{path_str}[/]"
109
114
 
110
115
  yield Static(f"{line1}\n{line2}\n{line3}\n{line4}", markup=True)
111
116
 
@@ -157,45 +162,33 @@ class RepoSidebar(Static):
157
162
  self._selected.discard(path)
158
163
  else:
159
164
  self._selected.add(path)
160
- self._post_selection_changed()
165
+ self.post_message(self.SelectionChanged(
166
+ count=len(self._selected),
167
+ paths=list(self._selected),
168
+ ))
161
169
 
162
170
  def select_all_visible(self) -> None:
163
171
  for r in self._current_repos:
164
172
  self._selected.add(r.path)
165
- self._post_selection_changed()
173
+ self.post_message(self.SelectionChanged(
174
+ count=len(self._selected),
175
+ paths=list(self._selected),
176
+ ))
166
177
  self.populate(self._current_repos)
167
178
 
168
179
  def clear_selection(self) -> None:
169
180
  self._selected.clear()
170
- self._post_selection_changed()
181
+ self.post_message(self.SelectionChanged(count=0, paths=[]))
171
182
  self.populate(self._current_repos)
172
183
 
173
184
  def selected_repos(self) -> list[RepoInfo]:
174
185
  return [r for r in self._current_repos if r.path in self._selected]
175
186
 
176
- def _post_selection_changed(self) -> None:
177
- self.post_message(self.SelectionChanged(
178
- count=len(self._selected),
179
- paths=list(self._selected),
180
- ))
181
- # Update the header to show selection count
182
- self._update_selection_indicator()
183
-
184
- def _update_selection_indicator(self) -> None:
185
- try:
186
- title: Static = self.query_one("#sidebar-title", Static)
187
- sel = len(self._selected)
188
- if sel > 0:
189
- # Append selection count to current title markup — regenerate
190
- pass # handled in update_header when called after populate
191
- except Exception:
192
- pass
193
-
194
187
  # ── Compose ─────────────────────────────────────────────────────────
195
188
 
196
189
  def compose(self) -> ComposeResult:
197
190
  yield Static(
198
- "⚡ [bold #7aa2f7]GitPulse[/]",
191
+ "⚡ [bold #ff2d4a]GitPulse[/]",
199
192
  id="sidebar-title",
200
193
  markup=True,
201
194
  )
@@ -218,23 +211,23 @@ class RepoSidebar(Static):
218
211
  """
219
212
  title: Static = self.query_one("#sidebar-title", Static)
220
213
  if scanning:
221
- title.update("⚡ [bold #7aa2f7]GitPulse[/] [dim #565f89]scanning…[/]")
214
+ title.update("⚡ [bold #ff2d4a]GitPulse[/] [dim #555568]scanning…[/]")
222
215
  return
223
216
  count_str = (
224
- f"[dim #565f89]{count} repo{'s' if count != 1 else ''}[/]"
217
+ f"[dim #555568]{count} repo{'s' if count != 1 else ''}[/]"
225
218
  if count else ""
226
219
  )
227
220
  if live is True:
228
- live_str = " [bold #9ece6a]●live[/]"
221
+ live_str = " [bold #3ddc84]●live[/]"
229
222
  elif live is False:
230
- live_str = " [dim #565f89]○paused[/]"
223
+ live_str = " [dim #555568]○paused[/]"
231
224
  else:
232
225
  live_str = ""
233
226
 
234
227
  sel = len(self._selected)
235
- sel_str = f" [bold #e0af68][{sel} sel][/]" if sel > 0 else ""
228
+ sel_str = f" [bold #ffb74d][{sel} sel][/]" if sel > 0 else ""
236
229
 
237
- title.update(f"⚡ [bold #7aa2f7]GitPulse[/]{live_str} {count_str}{sel_str}")
230
+ title.update(f"⚡ [bold #ff2d4a]GitPulse[/]{live_str} {count_str}{sel_str}")
238
231
 
239
232
  def populate(self, repos: list[RepoInfo]) -> None:
240
233
  """Clear and re-populate the repo list."""
@@ -245,7 +238,7 @@ class RepoSidebar(Static):
245
238
  if not repos:
246
239
  from textual.widgets import ListItem as _LI
247
240
  list_view.append(_LI(Static(
248
- "[dim italic #565f89]\n 📂 No repositories found\n"
241
+ "[dim italic #555568]\n 📂 No repositories found\n"
249
242
  " Try a different root or\n"
250
243
  " press r to rescan\n[/]",
251
244
  markup=True,
@@ -38,11 +38,11 @@ class DeleteConfirmModal(ModalScreen):
38
38
  width: 56;
39
39
  height: auto;
40
40
  padding: 1 2;
41
- background: #1e2030;
42
- border: thick #f7768e;
41
+ background: #1a1a24;
42
+ border: thick #ff5252;
43
43
  }
44
- #dconf-title { color: #f7768e; text-style: bold; margin-bottom: 1; }
45
- #dconf-info { color: #e0af68; margin-bottom: 1; }
44
+ #dconf-title { color: #ff5252; text-style: bold; margin-bottom: 1; }
45
+ #dconf-info { color: #ffb74d; margin-bottom: 1; }
46
46
  #dconf-input { width: 100%; margin-bottom: 1; }
47
47
  #dconf-btns { layout: horizontal; width: 100%; height: 3; align: center middle; }
48
48
  """
@@ -101,14 +101,14 @@ class StaleScreen(ModalScreen):
101
101
  #stale-frame {
102
102
  width: 96%;
103
103
  height: 90%;
104
- background: #1e2030;
105
- border: thick #bb9af7;
104
+ background: #1a1a24;
105
+ border: thick #e040fb;
106
106
  }
107
107
  #stale-header {
108
108
  dock: top;
109
109
  height: 1;
110
- background: #24283b;
111
- color: #bb9af7;
110
+ background: #242430;
111
+ color: #e040fb;
112
112
  text-style: bold;
113
113
  padding: 0 1;
114
114
  }
@@ -116,10 +116,10 @@ class StaleScreen(ModalScreen):
116
116
  #stale-footer {
117
117
  dock: bottom;
118
118
  height: 1;
119
- background: #1e2030;
120
- color: #565f89;
119
+ background: #1a1a24;
120
+ color: #555568;
121
121
  padding: 0 1;
122
- border-top: solid #3b4261;
122
+ border-top: solid #2a2a3a;
123
123
  }
124
124
  """
125
125
 
@@ -142,7 +142,7 @@ class StaleScreen(ModalScreen):
142
142
 
143
143
  def compose(self) -> ComposeResult:
144
144
  with Container(id="stale-frame"):
145
- yield Static(" 💀 Stale Branches", id="stale-header", markup=False)
145
+ yield Static(" Stale Branches", id="stale-header", markup=False)
146
146
  with TabbedContent(id="stale-tabs"):
147
147
  for cat, label in [
148
148
  ("stale", f"Stale ({self._stale_weeks}w+)"),
@@ -170,7 +170,7 @@ class StaleScreen(ModalScreen):
170
170
 
171
171
  def _load_data(self) -> None:
172
172
  self.query_one("#stale-header", Static).update(
173
- " 💀 Stale Branches [dim #565f89]loading…[/]"
173
+ " Stale Branches [dim #555568]loading…[/]"
174
174
  )
175
175
  self.run_worker(self._fetch_worker, thread=True, group="stale")
176
176
 
@@ -187,10 +187,10 @@ class StaleScreen(ModalScreen):
187
187
  if event.state == WorkerState.SUCCESS and event.worker.result is not None:
188
188
  self._categories = event.worker.result
189
189
  self._populate_tables()
190
- self.query_one("#stale-header", Static).update(" 💀 Stale Branches")
190
+ self.query_one("#stale-header", Static).update(" Stale Branches")
191
191
  elif event.state == WorkerState.ERROR:
192
192
  self.query_one("#stale-header", Static).update(
193
- f" 💀 Error: {event.worker.error}"
193
+ f" Error: {event.worker.error}"
194
194
  )
195
195
 
196
196
  def _populate_tables(self) -> None:
@@ -199,12 +199,12 @@ class StaleScreen(ModalScreen):
199
199
  table.clear()
200
200
  for b in sorted(branches, key=lambda x: x.age_days, reverse=True):
201
201
  sel_key = (b.repo_name, b.name)
202
- checkbox = "[bold #9ece6a]✓[/]" if sel_key in self._selected else "[ ]"
202
+ checkbox = "[bold #3ddc84]✓[/]" if sel_key in self._selected else "[ ]"
203
203
  flags = []
204
- if b.is_wip: flags.append("[#e0af68]WIP[/]")
205
- if b.is_merged_into_default: flags.append("[#9ece6a]merged[/]")
206
- if b.is_current: flags.append("[#7aa2f7]current[/]")
207
- if not b.has_upstream: flags.append("[dim]no-remote[/]")
204
+ if b.is_wip: flags.append("[#ffb74d]WIP[/]")
205
+ if b.is_merged_into_default: flags.append("[#3ddc84]merged[/]")
206
+ if b.is_current: flags.append("[#ff2d4a]current[/]")
207
+ if not b.has_upstream: flags.append("[dim]no-remote[/]")
208
208
  flags_str = " ".join(flags) if flags else "[dim]—[/]"
209
209
  age_str = f"{b.age_days}d"
210
210
  msg = b.last_commit_msg[:40] + ("…" if len(b.last_commit_msg) > 40 else "")
@@ -256,7 +256,6 @@ class StaleScreen(ModalScreen):
256
256
  async def _after_confirm(confirmed: bool) -> None:
257
257
  if not confirmed:
258
258
  return
259
- # Collect BranchDetail objects for selected
260
259
  to_delete: list[BranchDetail] = []
261
260
  for cat_branches in self._categories.values():
262
261
  for b in cat_branches:
@@ -274,7 +273,6 @@ class StaleScreen(ModalScreen):
274
273
 
275
274
  self._selected.clear()
276
275
  self._load_data()
277
- self.app.post_message_no_wait = getattr(self.app, "post_message_no_wait", None)
278
276
 
279
277
  self.app.push_screen(DeleteConfirmModal(count=len(self._selected)), _after_confirm)
280
278