synapse-cli-agent 0.1.13__py3-none-any.whl

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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,178 @@
1
+ """Git working-tree changes hover popover."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.text import Text
8
+ from textual.containers import Vertical
9
+ from textual.events import Click, Enter, Leave
10
+ from textual.widgets import Static
11
+
12
+ from synapse.ui.topbar.core import display_width
13
+ from synapse.ui.topbar.git_chrome import (
14
+ GitChangedFile,
15
+ format_changed_file_plain,
16
+ render_changed_file_row,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from synapse.ui.topbar.widget import TopBar
21
+
22
+ _MAX_ROWS = 14
23
+ _MIN_WIDTH = 36
24
+ _MAX_WIDTH = 72
25
+
26
+
27
+ class PopoverFileRow(Static):
28
+ """One clickable changed-file row inside the hover popover."""
29
+
30
+ def __init__(self, item: GitChangedFile, row_text: Text) -> None:
31
+ super().__init__(row_text)
32
+ self.item = item
33
+
34
+
35
+ class GitChangesPopover(Vertical):
36
+ """Overlay list of changed files under the branch chrome."""
37
+
38
+ DEFAULT_CSS = """
39
+ GitChangesPopover {
40
+ layer: overlay;
41
+ width: auto;
42
+ height: auto;
43
+ max-height: 16;
44
+ padding: 0 1;
45
+ border: solid $theme-border;
46
+ background: $theme-bar;
47
+ color: $theme-fg;
48
+ overflow-x: hidden;
49
+ overflow-y: auto;
50
+ /* Quiet 1-cell rail: track blends into bar; thumb is muted border. */
51
+ scrollbar-size-vertical: 1;
52
+ scrollbar-size-horizontal: 0;
53
+ scrollbar-background: $theme-bar;
54
+ scrollbar-color: $theme-border;
55
+ scrollbar-background-hover: $theme-bar;
56
+ scrollbar-color-hover: $theme-muted;
57
+ scrollbar-background-active: $theme-bar;
58
+ scrollbar-color-active: $theme-dim;
59
+ scrollbar-corner-color: $theme-bar;
60
+ }
61
+ GitChangesPopover #git-changes-title {
62
+ height: 1;
63
+ color: $theme-dim;
64
+ text-style: bold;
65
+ }
66
+ GitChangesPopover #git-changes-body {
67
+ height: auto;
68
+ max-height: 14;
69
+ overflow-y: auto;
70
+ overflow-x: hidden;
71
+ layout: vertical;
72
+ scrollbar-size-vertical: 1;
73
+ scrollbar-size-horizontal: 0;
74
+ scrollbar-background: $theme-bar;
75
+ scrollbar-color: $theme-border;
76
+ scrollbar-background-hover: $theme-bar;
77
+ scrollbar-color-hover: $theme-muted;
78
+ scrollbar-background-active: $theme-bar;
79
+ scrollbar-color-active: $theme-dim;
80
+ scrollbar-corner-color: $theme-bar;
81
+ }
82
+ GitChangesPopover PopoverFileRow {
83
+ height: 1;
84
+ width: 1fr;
85
+ }
86
+ GitChangesPopover PopoverFileRow:hover {
87
+ text-style: bold;
88
+ }
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ files: list[GitChangedFile],
94
+ *,
95
+ color_clean: str = "#81c995",
96
+ color_dirty: str = "#f28b82",
97
+ color_dim: str = "#9aa0a6",
98
+ color_fg: str = "#e8eaed",
99
+ color_orange: str = "#f4b183",
100
+ color_added: str = "#81c995",
101
+ color_deleted: str = "#f28b82",
102
+ id: str | None = None,
103
+ ) -> None:
104
+ # Callers that mount onto Screen should pass a unique id; fixed default
105
+ # ids collide when Textual remove() is still draining the previous node.
106
+ super().__init__(id=id)
107
+ self.files = list(files or [])
108
+ self._colors = {
109
+ "clean": color_clean,
110
+ "dirty": color_dirty,
111
+ "dim": color_dim,
112
+ "fg": color_fg,
113
+ "orange": color_orange,
114
+ "added": color_added,
115
+ "deleted": color_deleted,
116
+ }
117
+ self._owner: TopBar | None = None
118
+
119
+ def compose(self): # type: ignore[override]
120
+ n = len(self.files)
121
+ title = f"changed files ({n}) · click to explore" if n else "no changes"
122
+ yield Static(title, id="git-changes-title")
123
+ with Vertical(id="git-changes-body"):
124
+ if not self.files:
125
+ yield Static(Text("working tree clean", style=self._colors["dim"]))
126
+ else:
127
+ shown = self.files[:_MAX_ROWS]
128
+ path_w = max(12, min(48, max((len(f.path) for f in shown), default=12)))
129
+ for item in shown:
130
+ row_text = render_changed_file_row(
131
+ item,
132
+ path_width=path_w,
133
+ color_status_m=self._colors["orange"],
134
+ color_status_a=self._colors["added"],
135
+ color_status_d=self._colors["deleted"],
136
+ color_status_u=self._colors["dim"],
137
+ color_path=self._colors["fg"],
138
+ color_added=self._colors["added"],
139
+ color_deleted=self._colors["deleted"],
140
+ color_muted=self._colors["dim"],
141
+ )
142
+ yield PopoverFileRow(item, row_text)
143
+ extra = len(self.files) - len(shown)
144
+ if extra > 0:
145
+ yield Static(Text(f"... +{extra} more", style=self._colors["dim"]))
146
+
147
+ def measure_width(self) -> int:
148
+ """Preferred content width in cells."""
149
+ if not self.files:
150
+ return _MIN_WIDTH
151
+ plain_rows = [format_changed_file_plain(f) for f in self.files[:_MAX_ROWS]]
152
+ body = max((display_width(r) for r in plain_rows), default=_MIN_WIDTH)
153
+ title_w = display_width(f"changed files ({len(self.files)}) · click to explore")
154
+ # border + padding ~ 4 cells
155
+ return max(_MIN_WIDTH, min(_MAX_WIDTH, max(body, title_w) + 4))
156
+
157
+ def on_enter(self, event: Enter) -> None:
158
+ event.stop()
159
+ if self._owner is not None:
160
+ self._owner.on_popover_enter()
161
+
162
+ def on_leave(self, event: Leave) -> None:
163
+ event.stop()
164
+ if self._owner is not None:
165
+ self._owner.on_popover_leave()
166
+
167
+ def on_click(self, event: Click) -> None:
168
+ """Click a file row (or the popover) to open Git Explore."""
169
+ event.stop()
170
+ path: str | None = None
171
+ widget = event.widget
172
+ if isinstance(widget, PopoverFileRow):
173
+ path = widget.item.path
174
+ elif self.files:
175
+ # Title / empty area → open explore without a focused path.
176
+ path = None
177
+ if self._owner is not None:
178
+ self._owner.request_explore(path)
@@ -0,0 +1,475 @@
1
+ """Git branch chrome for the TUI topbar.
2
+
3
+ Compact status:
4
+
5
+ - clean + in sync: green branch name (no dirty mark)
6
+ - dirty: red branch name + red `` *`` (space before star)
7
+ - diff stats (when dirty): `` Nf +A -D`` (files changed, lines added, lines deleted)
8
+ - ahead: ``↑N`` in ahead color
9
+ - behind: ``↓N`` in behind color
10
+
11
+ Diff stats use ``git diff HEAD --shortstat`` (working tree + index vs HEAD).
12
+
13
+ Changed-file popover data uses ``git status --porcelain`` plus staged/unstaged
14
+ ``numstat`` so untracked files are included and each path gets ``+A -D``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import subprocess
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from rich.text import Text
25
+
26
+ # Cap large ahead/behind / file-count display so chrome stays short.
27
+ # Line add/delete counts are always shown in full (topbar + popover).
28
+ _COUNT_CAP = 99
29
+ # Popover / shortstat path caps (display only).
30
+ _PATH_CAP = 48
31
+ _FILE_LIST_CAP = 40
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class GitBranchChrome:
36
+ """Local git branch snapshot for topbar rendering."""
37
+
38
+ name: str
39
+ dirty: bool = False
40
+ ahead: int | None = None # None = no upstream tracking
41
+ behind: int | None = None
42
+ files_changed: int = 0
43
+ lines_added: int = 0
44
+ lines_deleted: int = 0
45
+
46
+ @property
47
+ def synced(self) -> bool:
48
+ """Clean working tree and not ahead/behind (or no upstream)."""
49
+ if self.dirty:
50
+ return False
51
+ if self.ahead is not None and int(self.ahead) > 0:
52
+ return False
53
+ if self.behind is not None and int(self.behind) > 0:
54
+ return False
55
+ return True
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class GitChangedFile:
60
+ """One dirty / untracked path for the branch hover popover."""
61
+
62
+ path: str
63
+ # Single display status: M / A / D / R / C / ? / U / T / …
64
+ status: str
65
+ lines_added: int = 0
66
+ lines_deleted: int = 0
67
+ is_untracked: bool = False
68
+ # Optional rename/copy source (display only).
69
+ source_path: str | None = None
70
+
71
+ @property
72
+ def has_line_stats(self) -> bool:
73
+ return (not self.is_untracked) and (
74
+ self.lines_added > 0
75
+ or self.lines_deleted > 0
76
+ or self.status in {"D", "A", "M", "R", "C", "T"}
77
+ )
78
+
79
+
80
+ def _run_git(args: list[str], *, cwd: Path, timeout: float = 0.8) -> str | None:
81
+ try:
82
+ proc = subprocess.run(
83
+ ["git", *args],
84
+ cwd=str(cwd),
85
+ capture_output=True,
86
+ text=True,
87
+ encoding="utf-8",
88
+ errors="replace",
89
+ timeout=timeout,
90
+ check=False,
91
+ )
92
+ except Exception: # noqa: BLE001
93
+ return None
94
+ if proc.returncode != 0:
95
+ return None
96
+ # rstrip only: leading spaces are meaningful in porcelain XY codes.
97
+ return (proc.stdout or "").rstrip()
98
+
99
+
100
+ def _format_count(n: int) -> str:
101
+ """Cap large counts (ahead/behind/files) for compact chrome."""
102
+ n = max(0, int(n or 0))
103
+ if n > _COUNT_CAP:
104
+ return f"{_COUNT_CAP}+"
105
+ return str(n)
106
+
107
+
108
+ def _format_lines(n: int) -> str:
109
+ """Full line add/delete count (no 99+ cap)."""
110
+ return str(max(0, int(n or 0)))
111
+
112
+
113
+ def _probe_diff_shortstat(cwd: Path, *, timeout: float = 0.8) -> tuple[int, int, int]:
114
+ """Return ``(files_changed, lines_added, lines_deleted)`` from
115
+ ``git diff HEAD --shortstat`` (working tree + index vs HEAD).
116
+
117
+ Returns ``(0, 0, 0)`` on failure or when there is nothing to diff
118
+ (e.g. no commits yet).
119
+ """
120
+ raw = _run_git(["diff", "HEAD", "--shortstat"], cwd=cwd, timeout=timeout)
121
+ if not raw:
122
+ return 0, 0, 0
123
+
124
+ files = 0
125
+ inserted = 0
126
+ deleted = 0
127
+ m = re.search(r"(\d+)\s+files?\s+changed", raw)
128
+ if m:
129
+ files = int(m.group(1))
130
+ m = re.search(r"(\d+)\s+insertions?\(\+\)", raw)
131
+ if m:
132
+ inserted = int(m.group(1))
133
+ m = re.search(r"(\d+)\s+deletions?\(-\)", raw)
134
+ if m:
135
+ deleted = int(m.group(1))
136
+ return files, inserted, deleted
137
+
138
+
139
+ def _status_letter(xy: str, *, untracked: bool = False) -> str:
140
+ """Collapse porcelain XY codes into one display letter."""
141
+ if untracked or xy == "??":
142
+ return "?"
143
+ if xy == "!!":
144
+ return "!"
145
+ x = (xy[:1] or " ").upper()
146
+ y = (xy[1:2] or " ").upper() if len(xy) > 1 else " "
147
+ # Prefer worktree letter when both present and differ; else staged.
148
+ for ch in (y, x):
149
+ if ch not in {" ", "?"}:
150
+ return ch
151
+ return "M"
152
+
153
+
154
+ def _parse_porcelain_line(line: str) -> tuple[str, str, str | None] | None:
155
+ """Return ``(xy, path, source_path)`` or None for empty/unusable lines."""
156
+ raw = (line or "").rstrip("\n")
157
+ if not raw:
158
+ return None
159
+ if raw.startswith("??") or raw.startswith("!!"):
160
+ path = raw[2:].lstrip()
161
+ if not path:
162
+ return None
163
+ return raw[:2], path, None
164
+ if len(raw) < 4:
165
+ return None
166
+ xy = raw[:2]
167
+ rest = raw[3:]
168
+ source: str | None = None
169
+ # Rename / copy: ``R old -> new`` (non -z porcelain).
170
+ if " -> " in rest and (xy[:1] in {"R", "C"} or xy[1:2] in {"R", "C"}):
171
+ left, right = rest.split(" -> ", 1)
172
+ source = left.strip() or None
173
+ path = right.strip()
174
+ else:
175
+ path = rest.strip()
176
+ if not path:
177
+ return None
178
+ return xy, path, source
179
+
180
+
181
+ def _parse_numstat(raw: str | None) -> dict[str, tuple[int, int]]:
182
+ """Parse ``git diff --numstat`` output into path → (added, deleted)."""
183
+ out: dict[str, tuple[int, int]] = {}
184
+ if not raw:
185
+ return out
186
+ for line in raw.splitlines():
187
+ parts = line.split("\t")
188
+ if len(parts) < 3:
189
+ continue
190
+ a_s, d_s, path = parts[0], parts[1], parts[-1]
191
+ if not path:
192
+ continue
193
+ # Binary files show ``-`` for both counts.
194
+ try:
195
+ added = 0 if a_s == "-" else max(0, int(a_s))
196
+ except ValueError:
197
+ added = 0
198
+ try:
199
+ deleted = 0 if d_s == "-" else max(0, int(d_s))
200
+ except ValueError:
201
+ deleted = 0
202
+ prev = out.get(path, (0, 0))
203
+ out[path] = (prev[0] + added, prev[1] + deleted)
204
+ return out
205
+
206
+
207
+ def probe_git_changed_files(
208
+ cwd: Path | str,
209
+ *,
210
+ timeout: float = 1.0,
211
+ limit: int = _FILE_LIST_CAP,
212
+ ) -> list[GitChangedFile]:
213
+ """List dirty / untracked files with optional line-level add/delete stats.
214
+
215
+ Sources:
216
+ - ``git status --porcelain=v1`` for paths + status letters (includes ``??``)
217
+ - ``git diff --numstat`` + ``git diff --cached --numstat`` for ``+A -D``
218
+ """
219
+ root = Path(cwd)
220
+ porcelain = _run_git(["status", "--porcelain=v1"], cwd=root, timeout=timeout)
221
+ if porcelain is None:
222
+ return []
223
+ if not porcelain:
224
+ return []
225
+
226
+ unstaged = _parse_numstat(
227
+ _run_git(["diff", "--numstat"], cwd=root, timeout=timeout)
228
+ )
229
+ staged = _parse_numstat(
230
+ _run_git(["diff", "--cached", "--numstat"], cwd=root, timeout=timeout)
231
+ )
232
+
233
+ items: list[GitChangedFile] = []
234
+ seen: set[str] = set()
235
+ for line in porcelain.splitlines():
236
+ parsed = _parse_porcelain_line(line)
237
+ if parsed is None:
238
+ continue
239
+ xy, path, source = parsed
240
+ if path in seen:
241
+ continue
242
+ seen.add(path)
243
+ untracked = xy == "??"
244
+ status = _status_letter(xy, untracked=untracked)
245
+ added = 0
246
+ deleted = 0
247
+ if not untracked:
248
+ a1, d1 = unstaged.get(path, (0, 0))
249
+ a2, d2 = staged.get(path, (0, 0))
250
+ added = a1 + a2
251
+ deleted = d1 + d2
252
+ # Renames: numstat may key the new path only; also try source.
253
+ if source and added == 0 and deleted == 0:
254
+ a1, d1 = unstaged.get(source, (0, 0))
255
+ a2, d2 = staged.get(source, (0, 0))
256
+ added = a1 + a2
257
+ deleted = d1 + d2
258
+ items.append(
259
+ GitChangedFile(
260
+ path=path,
261
+ status=status,
262
+ lines_added=added,
263
+ lines_deleted=deleted,
264
+ is_untracked=untracked,
265
+ source_path=source,
266
+ )
267
+ )
268
+ if limit > 0 and len(items) >= int(limit):
269
+ break
270
+ return items
271
+
272
+
273
+ def format_changed_file_plain(item: GitChangedFile, *, path_width: int = _PATH_CAP) -> str:
274
+ """Plain row: ``M path/to/file.py +10 -3``."""
275
+ status = (item.status or "M")[:1]
276
+ path = item.path or ""
277
+ if path_width > 0 and len(path) > path_width:
278
+ path = "…" + path[-(path_width - 1) :]
279
+ if item.is_untracked:
280
+ stats = "untracked"
281
+ elif item.lines_added or item.lines_deleted:
282
+ stats = f"+{_format_lines(item.lines_added)} -{_format_lines(item.lines_deleted)}"
283
+ elif status == "D":
284
+ stats = "deleted"
285
+ elif status == "A":
286
+ stats = "added"
287
+ else:
288
+ stats = ""
289
+ body = f"{status} {path}"
290
+ return f"{body} {stats}" if stats else body
291
+
292
+
293
+ def render_changed_file_row(
294
+ item: GitChangedFile,
295
+ *,
296
+ path_width: int = _PATH_CAP,
297
+ color_status_m: str = "#f4b183",
298
+ color_status_a: str = "#81c995",
299
+ color_status_d: str = "#f28b82",
300
+ color_status_u: str = "#9aa0a6",
301
+ color_path: str = "#e8eaed",
302
+ color_added: str = "#81c995",
303
+ color_deleted: str = "#f28b82",
304
+ color_muted: str = "#9aa0a6",
305
+ ) -> Text:
306
+ """Styled popover row for one changed file."""
307
+ status = (item.status or "M")[:1]
308
+ if item.is_untracked or status == "?":
309
+ st_style = color_status_u
310
+ elif status == "A":
311
+ st_style = color_status_a
312
+ elif status == "D":
313
+ st_style = color_status_d
314
+ elif status in {"R", "C"}:
315
+ st_style = color_status_m
316
+ else:
317
+ st_style = color_status_m
318
+
319
+ path = item.path or ""
320
+ if path_width > 0 and len(path) > path_width:
321
+ path = "…" + path[-(path_width - 1) :]
322
+
323
+ out = Text()
324
+ out.append(f"{status} ", style=st_style)
325
+ out.append(path, style=color_path)
326
+ if item.is_untracked:
327
+ out.append(" untracked", style=color_muted)
328
+ elif item.lines_added or item.lines_deleted:
329
+ out.append(" ")
330
+ if item.lines_added:
331
+ out.append(f"+{_format_lines(item.lines_added)}", style=color_added)
332
+ if item.lines_deleted:
333
+ out.append(" ")
334
+ if item.lines_deleted:
335
+ out.append(f"-{_format_lines(item.lines_deleted)}", style=color_deleted)
336
+ elif status == "D":
337
+ out.append(" deleted", style=color_deleted)
338
+ elif status == "A":
339
+ out.append(" added", style=color_added)
340
+ return out
341
+
342
+
343
+ def format_branch_chrome_plain(
344
+ info: GitBranchChrome | None,
345
+ *,
346
+ mark: str = "⎇",
347
+ ) -> str:
348
+ """Plain-text form for tests / fallbacks: ``⎇ main * 3f +120 -45 ↑2↓1``."""
349
+ if info is None or not (info.name or "").strip():
350
+ return ""
351
+ name = info.name.strip()
352
+ parts: list[str] = []
353
+ head = f"{mark} {name}".strip() if (mark or "").strip() else name
354
+ if info.dirty:
355
+ head = f"{head} *"
356
+ parts.append(head)
357
+ # Diff stats (only when there are changes).
358
+ if info.files_changed > 0:
359
+ parts.append(f"{_format_count(info.files_changed)}f")
360
+ if info.lines_added > 0:
361
+ parts.append(f"+{_format_lines(info.lines_added)}")
362
+ if info.lines_deleted > 0:
363
+ parts.append(f"-{_format_lines(info.lines_deleted)}")
364
+ bits = ""
365
+ if info.ahead is not None and int(info.ahead) > 0:
366
+ bits += f"↑{_format_count(int(info.ahead))}"
367
+ if info.behind is not None and int(info.behind) > 0:
368
+ bits += f"↓{_format_count(int(info.behind))}"
369
+ if bits:
370
+ parts.append(bits)
371
+ return " ".join(parts)
372
+
373
+
374
+ def render_branch_chrome(
375
+ info: GitBranchChrome | None,
376
+ *,
377
+ mark: str = "⎇",
378
+ color_clean: str = "#81c995",
379
+ color_dirty: str = "#f28b82",
380
+ color_ahead: str = "#8ab4f8",
381
+ color_behind: str = "#f4b183",
382
+ color_diverged: str = "#e8eaed",
383
+ color_files: str = "#9aa0a6",
384
+ color_added: str = "#81c995",
385
+ color_deleted: str = "#f28b82",
386
+ ) -> Text:
387
+ """Styled branch chrome for the topbar.
388
+
389
+ Color rules:
390
+ - fully clean/synced: green name (no star)
391
+ - dirty: red name + red `` *``
392
+ - diff stats: ``Nf`` (files count, muted), ``+A`` (added, green), ``-D`` (deleted, red)
393
+ - clean but diverged: neutral name; ahead blue; behind orange
394
+ """
395
+ out = Text()
396
+ if info is None or not (info.name or "").strip():
397
+ return out
398
+
399
+ name = info.name.strip()
400
+ mark_s = (mark or "").strip()
401
+ if info.dirty:
402
+ name_style = color_dirty
403
+ elif info.synced:
404
+ name_style = color_clean
405
+ else:
406
+ name_style = color_diverged
407
+
408
+ if mark_s:
409
+ out.append(f"{mark_s} ", style=name_style)
410
+ out.append(name, style=name_style)
411
+ if info.dirty:
412
+ # Space before dirty mark; mark shares dirty red with the name.
413
+ out.append(" *", style=color_dirty)
414
+
415
+ # Diff stats: files changed + lines added / deleted (vs HEAD).
416
+ if info.files_changed > 0:
417
+ out.append(f" {_format_count(info.files_changed)}f", style=color_files)
418
+ if info.lines_added > 0:
419
+ out.append(f" +{_format_lines(info.lines_added)}", style=color_added)
420
+ if info.lines_deleted > 0:
421
+ out.append(f" -{_format_lines(info.lines_deleted)}", style=color_deleted)
422
+
423
+ bits = Text()
424
+ if info.ahead is not None and int(info.ahead) > 0:
425
+ bits.append(f"↑{_format_count(int(info.ahead))}", style=color_ahead)
426
+ if info.behind is not None and int(info.behind) > 0:
427
+ bits.append(f"↓{_format_count(int(info.behind))}", style=color_behind)
428
+ if bits.plain:
429
+ out.append(" ")
430
+ out.append_text(bits)
431
+ return out
432
+
433
+
434
+ def probe_git_branch_chrome(cwd: Path | str, *, timeout: float = 0.8) -> GitBranchChrome | None:
435
+ """Probe local branch name, dirty flag, and upstream ahead/behind."""
436
+ root = Path(cwd)
437
+ name = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root, timeout=timeout)
438
+ if not name or name == "HEAD":
439
+ return None
440
+
441
+ dirty = False
442
+ porcelain = _run_git(["status", "--porcelain"], cwd=root, timeout=timeout)
443
+ # status returns "" when clean; None on failure → treat as not dirty.
444
+ if porcelain:
445
+ dirty = True
446
+
447
+ ahead: int | None = None
448
+ behind: int | None = None
449
+ # left = upstream-only (behind), right = HEAD-only (ahead)
450
+ counts = _run_git(
451
+ ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
452
+ cwd=root,
453
+ timeout=timeout,
454
+ )
455
+ if counts:
456
+ parts = counts.split()
457
+ if len(parts) >= 2:
458
+ try:
459
+ behind = max(0, int(parts[0]))
460
+ ahead = max(0, int(parts[1]))
461
+ except ValueError:
462
+ ahead = None
463
+ behind = None
464
+
465
+ files, inserted, deleted = _probe_diff_shortstat(root, timeout=timeout)
466
+
467
+ return GitBranchChrome(
468
+ name=name,
469
+ dirty=dirty,
470
+ ahead=ahead,
471
+ behind=behind,
472
+ files_changed=files,
473
+ lines_added=inserted,
474
+ lines_deleted=deleted,
475
+ )