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,84 @@
1
+ """Hover card for cumulative tool-output compression metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from textual.containers import Vertical
8
+ from textual.events import Enter, Leave
9
+ from textual.widgets import Static
10
+
11
+ from synapse.ui.topbar.core import display_width
12
+
13
+ if TYPE_CHECKING:
14
+ from synapse.ui.topbar.widget import TopBar
15
+
16
+
17
+ def _format_bytes(value: Any) -> str:
18
+ amount = max(0, int(value or 0))
19
+ if amount >= 1024**2:
20
+ return f"{amount / 1024**2:.1f} MiB"
21
+ if amount >= 1024:
22
+ return f"{amount / 1024:.1f} KiB"
23
+ return f"{amount} B"
24
+
25
+
26
+ class ToolOutputPopover(Vertical):
27
+ """Read-only hover summary for cumulative tool-output compression."""
28
+
29
+ DEFAULT_CSS = """
30
+ ToolOutputPopover {
31
+ layer: overlay;
32
+ width: auto;
33
+ height: auto;
34
+ padding: 0 1;
35
+ border: solid $theme-border;
36
+ background: $theme-bar;
37
+ color: $theme-fg;
38
+ }
39
+ ToolOutputPopover Static {
40
+ height: 1;
41
+ width: auto;
42
+ }
43
+ """
44
+
45
+ def __init__(self, stats: dict[str, Any], *, id: str | None = None) -> None:
46
+ super().__init__(id=id)
47
+ self._stats = dict(stats or {})
48
+ self._owner: TopBar | None = None
49
+
50
+ def _lines(self) -> list[str]:
51
+ stats = self._stats
52
+ original = int(stats.get("original_bytes", 0) or 0)
53
+ visible = int(stats.get("visible_bytes", 0) or 0)
54
+ reread = int(stats.get("retrieval_bytes", 0) or 0)
55
+ net = int(stats.get("effective_saved_bytes", 0) or 0)
56
+ ratio = float(stats.get("effective_savings_ratio", 0.0) or 0.0)
57
+ return [
58
+ f"tool input {_format_bytes(original)}",
59
+ f"model-visible {_format_bytes(visible)}",
60
+ f"re-read cost {_format_bytes(reread)}",
61
+ f"net saved {_format_bytes(net)} ({ratio:.0%})",
62
+ (
63
+ f"{int(stats.get('outputs_considered', 0) or 0)} outputs · "
64
+ f"{int(stats.get('transformed', 0) or 0)} compressed · "
65
+ f"{int(stats.get('skipped', 0) or 0)} skipped"
66
+ ),
67
+ ]
68
+
69
+ def compose(self): # type: ignore[override]
70
+ for line in self._lines():
71
+ yield Static(line)
72
+
73
+ def measure_width(self) -> int:
74
+ return max(36, min(52, max(display_width(line) for line in self._lines()) + 4))
75
+
76
+ def on_enter(self, event: Enter) -> None:
77
+ event.stop()
78
+ if self._owner is not None:
79
+ self._owner.on_tool_output_popover_enter()
80
+
81
+ def on_leave(self, event: Leave) -> None:
82
+ event.stop()
83
+ if self._owner is not None:
84
+ self._owner.on_tool_output_popover_leave()
@@ -0,0 +1,474 @@
1
+ """Topbar controller and hover-overlay coordination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from textual.events import Click, Enter, Leave, MouseMove
10
+ from textual.message import Message
11
+ from textual.timer import Timer
12
+ from textual.widget import Widget
13
+ from textual.widgets import Static
14
+
15
+ from synapse.ui.topbar.core import TopBarRegistry, locate_component_span
16
+ from synapse.ui.topbar.git_changes_popover import GitChangesPopover
17
+ from synapse.ui.topbar.git_chrome import GitChangedFile, probe_git_changed_files
18
+ from synapse.ui.topbar.tool_output_popover import ToolOutputPopover
19
+
20
+ BRANCH_COMPONENT_ID = "branch"
21
+ TOOL_OUTPUT_COMPONENT_ID = "tool_output"
22
+ _HIDE_DELAY_S = 0.12
23
+
24
+
25
+ def _widget_is_or_inside(widget: Widget | None, root: Widget | None) -> bool:
26
+ """True if ``widget`` is ``root`` or a descendant of ``root``."""
27
+ if widget is None or root is None:
28
+ return False
29
+ node: Widget | None = widget
30
+ while node is not None:
31
+ if node is root:
32
+ return True
33
+ parent = getattr(node, "parent", None)
34
+ node = parent if isinstance(parent, Widget) else None
35
+ return False
36
+
37
+
38
+ class TopBar(Static):
39
+ """Single-line topbar Static with branch-hover change list."""
40
+
41
+ class OpenGitExplore(Message):
42
+ """Request the app to open the Git Explore modal."""
43
+
44
+ def __init__(self, path: str | None = None) -> None:
45
+ super().__init__()
46
+ self.path = path
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ registry_provider: Callable[[], TopBarRegistry],
52
+ workspace_provider: Callable[[], Path | str],
53
+ dirty_provider: Callable[[], bool] | None = None,
54
+ tool_output_stats_provider: Callable[[], dict[str, Any]] | None = None,
55
+ usable_width_provider: Callable[[], int] | None = None,
56
+ colors: dict[str, str] | None = None,
57
+ id: str | None = "topbar",
58
+ ) -> None:
59
+ super().__init__(id=id)
60
+ self._registry_provider = registry_provider
61
+ self._workspace_provider = workspace_provider
62
+ self._dirty_provider = dirty_provider
63
+ self._tool_output_stats_provider = tool_output_stats_provider
64
+ self._usable_width_provider = usable_width_provider
65
+ self._colors = colors or {}
66
+ # Independent hover flags: both must be False before hide.
67
+ self._branch_hover = False
68
+ self._popover_hover = False
69
+ self._hide_timer: Timer | None = None
70
+ self._popover: GitChangesPopover | None = None
71
+ self._popover_mounting = False
72
+ self._popover_seq = 0
73
+ self._tool_output_hover = False
74
+ self._tool_output_popover_hover = False
75
+ self._tool_output_hide_timer: Timer | None = None
76
+ self._tool_output_popover: ToolOutputPopover | None = None
77
+ self._tool_output_popover_mounting = False
78
+ self._tool_output_popover_seq = 0
79
+ self._files_cache: list[GitChangedFile] | None = None
80
+ self._files_cache_key: str | None = None
81
+
82
+ def _usable_width(self) -> int:
83
+ if self._usable_width_provider is not None:
84
+ try:
85
+ return max(20, int(self._usable_width_provider() or 0))
86
+ except Exception: # noqa: BLE001
87
+ pass
88
+ width = max(int(getattr(self.size, "width", 0) or 0), 48)
89
+ # CSS padding: 0 1
90
+ return max(20, width - 2)
91
+
92
+ def _is_dirty(self) -> bool:
93
+ if self._dirty_provider is None:
94
+ return True
95
+ try:
96
+ return bool(self._dirty_provider())
97
+ except Exception: # noqa: BLE001
98
+ return False
99
+
100
+ def _branch_span(self) -> tuple[int, int] | None:
101
+ try:
102
+ reg = self._registry_provider()
103
+ except Exception: # noqa: BLE001
104
+ return None
105
+ return locate_component_span(
106
+ reg, BRANCH_COMPONENT_ID, usable_width=self._usable_width()
107
+ )
108
+
109
+ def _pointer_on_branch(self, x: int) -> bool:
110
+ span = self._branch_span()
111
+ if span is None:
112
+ return False
113
+ start, width = span
114
+ if width <= 0:
115
+ return False
116
+ # content x already excludes widget border; CSS padding-left is 1.
117
+ content_x = int(x) - 1
118
+ return start <= content_x < start + width
119
+
120
+ def _load_files(self, *, force: bool = False) -> list[GitChangedFile]:
121
+ try:
122
+ ws = Path(self._workspace_provider())
123
+ except Exception: # noqa: BLE001
124
+ return []
125
+ key = str(ws)
126
+ if not force and self._files_cache is not None and self._files_cache_key == key:
127
+ return self._files_cache
128
+ files = probe_git_changed_files(ws)
129
+ self._files_cache = files
130
+ self._files_cache_key = key
131
+ return files
132
+
133
+ def invalidate_files_cache(self) -> None:
134
+ self._files_cache = None
135
+ self._files_cache_key = None
136
+
137
+ def is_popover_open(self) -> bool:
138
+ return self._popover_is_attached(self._popover)
139
+
140
+ def on_popover_enter(self) -> None:
141
+ """Popover mouse enter: keep open without faking branch hover."""
142
+ self._popover_hover = True
143
+ self._cancel_hide()
144
+
145
+ def on_popover_leave(self) -> None:
146
+ """Popover mouse leave: allow hide if branch is also not hovered."""
147
+ self._popover_hover = False
148
+ self.schedule_hide()
149
+
150
+ def keep_open(self) -> None:
151
+ """App-facing cancel of a pending hide (e.g. moving into popover)."""
152
+ self._popover_hover = True
153
+ self._cancel_hide()
154
+
155
+ def schedule_hide(self) -> None:
156
+ self._cancel_hide()
157
+ self._hide_timer = self.set_timer(_HIDE_DELAY_S, self._hide_popover)
158
+
159
+ def _cancel_hide(self) -> None:
160
+ if self._hide_timer is not None:
161
+ try:
162
+ self._hide_timer.stop()
163
+ except Exception: # noqa: BLE001
164
+ pass
165
+ self._hide_timer = None
166
+
167
+ def _hide_popover(self) -> None:
168
+ self._hide_timer = None
169
+ # Stay open only while pointer is on branch chrome or the popover itself.
170
+ if self._branch_hover or self._popover_hover:
171
+ return
172
+ self._remove_popover()
173
+
174
+ @staticmethod
175
+ def _popover_is_attached(pop: Widget | None) -> bool:
176
+ """True when popover is still part of a live screen tree."""
177
+ if pop is None:
178
+ return False
179
+ try:
180
+ if getattr(pop, "is_mounted", False):
181
+ return True
182
+ except Exception: # noqa: BLE001
183
+ pass
184
+ try:
185
+ return getattr(pop, "parent", None) is not None
186
+ except Exception: # noqa: BLE001
187
+ return False
188
+
189
+ def _clamp_popover_left(self, left: int, width: int) -> int:
190
+ try:
191
+ screen_w = int(getattr(self.screen.size, "width", 0) or 0)
192
+ if screen_w > 0 and left + width > screen_w:
193
+ return max(0, screen_w - width)
194
+ except Exception: # noqa: BLE001
195
+ pass
196
+ return left
197
+
198
+ def _position_popover(self, pop: GitChangesPopover, left: int) -> None:
199
+ width = pop.measure_width()
200
+ left = self._clamp_popover_left(left, width)
201
+ pop.styles.offset = (left, 1)
202
+ pop.styles.width = width
203
+ pop.styles.layer = "overlay"
204
+
205
+ def _purge_screen_popovers(self, *, keep: Widget | None = None) -> None:
206
+ """Drop any leftover GitChangesPopover nodes.
207
+
208
+ Textual ``Widget.remove()`` is asynchronous. A second hover can try to
209
+ mount another ``#git-changes-popover`` before the previous node is
210
+ actually gone, which raises ``DuplicateIds``.
211
+ """
212
+ try:
213
+ screen = self.screen
214
+ except Exception: # noqa: BLE001
215
+ return
216
+ try:
217
+ orphans = list(screen.query(GitChangesPopover))
218
+ except Exception: # noqa: BLE001
219
+ orphans = []
220
+ for widget in orphans:
221
+ if keep is not None and widget is keep:
222
+ continue
223
+ try:
224
+ widget.remove()
225
+ except Exception: # noqa: BLE001
226
+ pass
227
+
228
+ def _remove_popover(self) -> None:
229
+ pop = self._popover
230
+ self._popover = None
231
+ self._popover_hover = False
232
+ if pop is not None:
233
+ try:
234
+ pop.remove()
235
+ except Exception: # noqa: BLE001
236
+ pass
237
+ # Also clear any detached-but-still-registered duplicates.
238
+ self._purge_screen_popovers()
239
+
240
+ def show_popover(self, *, force_reload: bool = False) -> None:
241
+ if self._popover_mounting:
242
+ return
243
+ if not self._is_dirty():
244
+ self.dismiss()
245
+ return
246
+ files = self._load_files(force=force_reload)
247
+ if not files:
248
+ self.dismiss()
249
+ return
250
+
251
+ span = self._branch_span()
252
+ left = 1
253
+ if span is not None:
254
+ left = max(0, int(span[0]) + 1) # + CSS padding
255
+
256
+ # Already open and still attached: keep instance and re-position only.
257
+ if self._popover_is_attached(self._popover):
258
+ assert self._popover is not None
259
+ self._position_popover(self._popover, left)
260
+ return
261
+
262
+ # Stale handle or async-removed node: drop reference and sweep screen.
263
+ self._popover = None
264
+ self._purge_screen_popovers()
265
+
266
+ # Unique id so a dying previous node cannot collide during remove lag.
267
+ self._popover_seq += 1
268
+ pop = GitChangesPopover(
269
+ files,
270
+ color_clean=self._colors.get("clean", "#81c995"),
271
+ color_dirty=self._colors.get("dirty", "#f28b82"),
272
+ color_dim=self._colors.get("dim", "#9aa0a6"),
273
+ color_fg=self._colors.get("fg", "#e8eaed"),
274
+ color_orange=self._colors.get("orange", "#f4b183"),
275
+ color_added=self._colors.get("added", "#81c995"),
276
+ color_deleted=self._colors.get("deleted", "#f28b82"),
277
+ id=f"git-changes-popover-{self._popover_seq}",
278
+ )
279
+ pop._owner = self
280
+ self._position_popover(pop, left)
281
+
282
+ self._popover_mounting = True
283
+ try:
284
+ self._popover = pop
285
+ self.screen.mount(pop)
286
+ except Exception:
287
+ self._popover = None
288
+ try:
289
+ pop.remove()
290
+ except Exception: # noqa: BLE001
291
+ pass
292
+ raise
293
+ finally:
294
+ self._popover_mounting = False
295
+
296
+ def _tool_output_span(self) -> tuple[int, int] | None:
297
+ try:
298
+ reg = self._registry_provider()
299
+ except Exception: # noqa: BLE001
300
+ return None
301
+ return locate_component_span(
302
+ reg, TOOL_OUTPUT_COMPONENT_ID, usable_width=self._usable_width()
303
+ )
304
+
305
+ def _pointer_on_tool_output(self, x: int) -> bool:
306
+ span = self._tool_output_span()
307
+ if span is None:
308
+ return False
309
+ start, width = span
310
+ content_x = int(x) - 1 # CSS padding-left
311
+ return width > 0 and start <= content_x < start + width
312
+
313
+ def _tool_output_stats(self) -> dict[str, Any]:
314
+ if self._tool_output_stats_provider is None:
315
+ return {}
316
+ try:
317
+ return dict(self._tool_output_stats_provider() or {})
318
+ except Exception: # noqa: BLE001
319
+ return {}
320
+
321
+ def _cancel_tool_output_hide(self) -> None:
322
+ if self._tool_output_hide_timer is not None:
323
+ try:
324
+ self._tool_output_hide_timer.stop()
325
+ except Exception: # noqa: BLE001
326
+ pass
327
+ self._tool_output_hide_timer = None
328
+
329
+ def _remove_tool_output_popover(self) -> None:
330
+ pop = self._tool_output_popover
331
+ self._tool_output_popover = None
332
+ self._tool_output_popover_hover = False
333
+ if pop is not None:
334
+ try:
335
+ pop.remove()
336
+ except Exception: # noqa: BLE001
337
+ pass
338
+
339
+ def _hide_tool_output_popover(self) -> None:
340
+ self._tool_output_hide_timer = None
341
+ if not self._tool_output_hover and not self._tool_output_popover_hover:
342
+ self._remove_tool_output_popover()
343
+
344
+ def _schedule_tool_output_hide(self) -> None:
345
+ self._cancel_tool_output_hide()
346
+ self._tool_output_hide_timer = self.set_timer(_HIDE_DELAY_S, self._hide_tool_output_popover)
347
+
348
+ def on_tool_output_popover_enter(self) -> None:
349
+ self._tool_output_popover_hover = True
350
+ self._cancel_tool_output_hide()
351
+
352
+ def on_tool_output_popover_leave(self) -> None:
353
+ self._tool_output_popover_hover = False
354
+ self._schedule_tool_output_hide()
355
+
356
+ def show_tool_output_popover(self) -> None:
357
+ if self._tool_output_popover_mounting:
358
+ return
359
+ stats = self._tool_output_stats()
360
+ if not int(stats.get("transformed", 0) or 0):
361
+ self._remove_tool_output_popover()
362
+ return
363
+ span = self._tool_output_span()
364
+ left = max(0, int(span[0]) + 1) if span is not None else 1
365
+ if self._popover_is_attached(self._tool_output_popover):
366
+ self._remove_tool_output_popover()
367
+ self._tool_output_popover_seq += 1
368
+ pop = ToolOutputPopover(
369
+ stats, id=f"tool-output-popover-{self._tool_output_popover_seq}"
370
+ )
371
+ pop._owner = self
372
+ width = pop.measure_width()
373
+ pop.styles.offset = (self._clamp_popover_left(left, width), 1)
374
+ pop.styles.width = width
375
+ pop.styles.layer = "overlay"
376
+ self._tool_output_popover_mounting = True
377
+ try:
378
+ self._tool_output_popover = pop
379
+ self.screen.mount(pop)
380
+ except Exception:
381
+ self._tool_output_popover = None
382
+ try:
383
+ pop.remove()
384
+ except Exception: # noqa: BLE001
385
+ pass
386
+ raise
387
+ finally:
388
+ self._tool_output_popover_mounting = False
389
+
390
+ def on_enter(self, event: Enter) -> None:
391
+ # Enter alone may not carry a stable x on all backends; MouseMove refines.
392
+ try:
393
+ x = int(getattr(event, "x", -1))
394
+ except Exception: # noqa: BLE001
395
+ x = -1
396
+ if x >= 0 and self._pointer_on_branch(x) and self._is_dirty():
397
+ self._branch_hover = True
398
+ self._cancel_hide()
399
+ self.show_popover()
400
+
401
+ def on_leave(self, event: Leave) -> None:
402
+ del event
403
+ self._branch_hover = False
404
+ self.schedule_hide()
405
+ self._tool_output_hover = False
406
+ self._schedule_tool_output_hide()
407
+
408
+ def on_mouse_move(self, event: MouseMove) -> None:
409
+ x = int(event.x)
410
+ on_branch = self._pointer_on_branch(x)
411
+ if on_branch and self._is_dirty():
412
+ if not self._branch_hover or self._popover is None:
413
+ self._branch_hover = True
414
+ self._cancel_hide()
415
+ self.show_popover()
416
+ else:
417
+ self._branch_hover = True
418
+ self._cancel_hide()
419
+ elif self._branch_hover:
420
+ self._branch_hover = False
421
+ self.schedule_hide()
422
+
423
+ on_tool_output = self._pointer_on_tool_output(x)
424
+ if on_tool_output:
425
+ if not self._tool_output_hover or self._tool_output_popover is None:
426
+ self._tool_output_hover = True
427
+ self._cancel_tool_output_hide()
428
+ self.show_tool_output_popover()
429
+ else:
430
+ self._tool_output_hover = True
431
+ self._cancel_tool_output_hide()
432
+ elif self._tool_output_hover:
433
+ self._tool_output_hover = False
434
+ self._schedule_tool_output_hide()
435
+
436
+ def dismiss_if_outside(self, widget: Widget | None) -> bool:
437
+ """Dismiss when a click landed outside topbar + popover.
438
+
439
+ Returns True if the popover was open and dismissed.
440
+ """
441
+ if self._popover is None:
442
+ return False
443
+ if _widget_is_or_inside(widget, self._popover):
444
+ return False
445
+ if widget is self:
446
+ return False
447
+ self.dismiss()
448
+ return True
449
+
450
+ def dismiss(self) -> None:
451
+ self._branch_hover = False
452
+ self._popover_hover = False
453
+ self._cancel_hide()
454
+ self._remove_popover()
455
+ self._tool_output_hover = False
456
+ self._tool_output_popover_hover = False
457
+ self._cancel_tool_output_hide()
458
+ self._remove_tool_output_popover()
459
+
460
+ def request_explore(self, path: str | None = None) -> None:
461
+ """Dismiss the hover popover and ask the app to open Git Explore."""
462
+ self.dismiss()
463
+ self.post_message(self.OpenGitExplore(path))
464
+
465
+ def on_click(self, event: Click) -> None:
466
+ """Click branch chrome → open Git Explore (works even when clean)."""
467
+ try:
468
+ x = int(getattr(event, "x", -1))
469
+ except Exception: # noqa: BLE001
470
+ x = -1
471
+ if x < 0 or not self._pointer_on_branch(x):
472
+ return
473
+ event.stop()
474
+ self.request_explore(None)