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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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)
|