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,702 @@
1
+ """Git Explore dialog — file list + DiffView (opened from topbar branch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import gc
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rich.text import Text
11
+ from textual import work
12
+ from textual.app import ComposeResult
13
+ from textual.await_complete import AwaitComplete
14
+ from textual.binding import Binding
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
16
+ from textual.events import Click
17
+ from textual.message import Message
18
+ from textual.screen import ModalScreen
19
+ from textual.widgets import Static
20
+
21
+ from synapse.ui.git_explore import (
22
+ DIFF_MODES,
23
+ HAS_DIFF_VIEW,
24
+ DiffMode,
25
+ DiffPayload,
26
+ fallback_renderable,
27
+ load_file_diff,
28
+ make_diff_view,
29
+ )
30
+ from synapse.ui.topbar.git_chrome import (
31
+ GitChangedFile,
32
+ probe_git_changed_files,
33
+ )
34
+
35
+
36
+ def _clear_textual_style_cache_refs() -> None:
37
+ """Release detached widgets retained by Textual's instance-keyed style LRU."""
38
+ try:
39
+ from textual._styles_cache import StylesCache
40
+
41
+ cache_clear = getattr(StylesCache.get_inner_outer, "cache_clear", None)
42
+ if callable(cache_clear):
43
+ cache_clear()
44
+ except Exception: # noqa: BLE001
45
+ pass
46
+
47
+
48
+ class ExploreFileRow(Static):
49
+ """One selectable changed-file row (theme-safe styles for ANSI)."""
50
+
51
+ def __init__(self, item: GitChangedFile, *, selected: bool = False) -> None:
52
+ self.item = item
53
+ self._selected = selected
54
+ # Build content before Static init so no active App is required.
55
+ super().__init__(self._build_text())
56
+ if selected:
57
+ self.add_class("-selected")
58
+
59
+ def set_selected(self, on: bool) -> None:
60
+ self._selected = on
61
+ if on:
62
+ self.add_class("-selected")
63
+ else:
64
+ self.remove_class("-selected")
65
+ try:
66
+ self.update(self._build_text())
67
+ except Exception: # noqa: BLE001
68
+ pass
69
+
70
+ def _build_text(self) -> Text:
71
+ """Render status + path + stats with named styles (ANSI-safe)."""
72
+ item = self.item
73
+ status = (item.status or "M")[:1]
74
+ path = (item.path or "").replace("\\", "/")
75
+ if len(path) > 34:
76
+ path = "…" + path[-33:]
77
+
78
+ if item.is_untracked or status == "?":
79
+ st_style = "dim"
80
+ elif status == "A":
81
+ st_style = "bold green"
82
+ elif status == "D":
83
+ st_style = "bold red"
84
+ else:
85
+ st_style = "bold yellow"
86
+
87
+ out = Text()
88
+ out.append(f"{status} ", style=st_style)
89
+ out.append(path, style="default")
90
+ if item.is_untracked:
91
+ out.append(" ?", style="dim")
92
+ elif item.lines_added or item.lines_deleted:
93
+ out.append(" ")
94
+ if item.lines_added:
95
+ out.append(f"+{item.lines_added}", style="green")
96
+ if item.lines_deleted:
97
+ if item.lines_added:
98
+ out.append(" ")
99
+ out.append(f"-{item.lines_deleted}", style="red")
100
+ elif status == "D":
101
+ out.append(" del", style="red")
102
+ elif status == "A":
103
+ out.append(" new", style="green")
104
+ return out
105
+
106
+
107
+ class GitExploreScreen(ModalScreen[None]):
108
+ """Near-full-screen git changes explorer."""
109
+
110
+ class OpenRequested(Message):
111
+ def __init__(self, path: str | None = None) -> None:
112
+ super().__init__()
113
+ self.path = path
114
+
115
+ DEFAULT_CSS = """
116
+ GitExploreScreen {
117
+ align: center middle;
118
+ background: $theme-bg 60%;
119
+ }
120
+ GitExploreScreen > #ge-window {
121
+ width: 96%;
122
+ height: 92%;
123
+ max-width: 160;
124
+ max-height: 48;
125
+ background: $theme-bg;
126
+ border: round $theme-user;
127
+ border-title-color: $theme-fg;
128
+ border-title-background: $theme-top;
129
+ border-title-style: bold;
130
+ border-title-align: left;
131
+ border-subtitle-color: $theme-muted;
132
+ border-subtitle-align: right;
133
+ layout: vertical;
134
+ padding: 0;
135
+ }
136
+ #ge-header {
137
+ height: 1;
138
+ width: 1fr;
139
+ padding: 0 1;
140
+ color: $theme-dim;
141
+ background: $theme-bar;
142
+ }
143
+ #ge-body {
144
+ height: 1fr;
145
+ width: 1fr;
146
+ layout: horizontal;
147
+ }
148
+ #ge-file-list {
149
+ width: 42;
150
+ min-width: 28;
151
+ max-width: 52;
152
+ height: 1fr;
153
+ border-right: solid $theme-border;
154
+ padding: 0 0;
155
+ overflow-y: auto;
156
+ overflow-x: hidden;
157
+ background: $theme-bg;
158
+ scrollbar-size: 1 1;
159
+ }
160
+ #ge-file-list ExploreFileRow {
161
+ height: 1;
162
+ width: 1fr;
163
+ padding: 0 1;
164
+ overflow: hidden;
165
+ text-overflow: ellipsis;
166
+ }
167
+ #ge-file-list ExploreFileRow.-selected {
168
+ background: $theme-bar;
169
+ text-style: bold;
170
+ }
171
+ #ge-diff-scroll {
172
+ width: 1fr;
173
+ height: 1fr;
174
+ padding: 0 0;
175
+ overflow-y: auto;
176
+ overflow-x: auto;
177
+ background: $theme-bg;
178
+ scrollbar-size: 1 1;
179
+ }
180
+ #ge-diff-scroll DiffView {
181
+ width: 1fr;
182
+ height: auto;
183
+ min-height: 1fr;
184
+ }
185
+ #ge-diff-body {
186
+ height: auto;
187
+ width: auto;
188
+ color: $theme-fg;
189
+ padding: 0 1;
190
+ }
191
+ """
192
+
193
+ BINDINGS = [
194
+ Binding("escape", "close", "Close", show=False, priority=True),
195
+ Binding("q", "close", "Close", show=False, priority=True),
196
+ Binding("up", "file_prev", "Prev", show=False, priority=True),
197
+ Binding("k", "file_prev", "Prev", show=False, priority=True),
198
+ Binding("down", "file_next", "Next", show=False, priority=True),
199
+ Binding("j", "file_next", "Next", show=False, priority=True),
200
+ Binding("left", "mode_prev", "Mode-", show=False, priority=True),
201
+ Binding("right", "mode_next", "Mode+", show=False, priority=True),
202
+ Binding("1", "mode_working", "Working", show=False, priority=True),
203
+ Binding("2", "mode_staged", "Staged", show=False, priority=True),
204
+ Binding("3", "mode_unstaged", "Unstaged", show=False, priority=True),
205
+ Binding("r", "reload", "Reload", show=False, priority=True),
206
+ Binding("u", "toggle_split", "Split", show=False, priority=True),
207
+ Binding("space", "toggle_split", "Split", show=False, priority=True),
208
+ Binding("a", "toggle_annotations", "Annot", show=False, priority=True),
209
+ Binding("pageup", "diff_page_up", "PgUp", show=False, priority=True),
210
+ Binding("pagedown", "diff_page_down", "PgDn", show=False, priority=True),
211
+ ]
212
+
213
+ def __init__(
214
+ self,
215
+ workspace: Path | str,
216
+ *,
217
+ initial_path: str | None = None,
218
+ branch_label: str | None = None,
219
+ colors: dict[str, str] | None = None,
220
+ split: bool = True,
221
+ annotations: bool = True,
222
+ ) -> None:
223
+ super().__init__()
224
+ self._workspace = Path(workspace)
225
+ self._initial_path = (initial_path or "").strip() or None
226
+ self._branch_label = (branch_label or "").strip()
227
+ self._colors = colors or {}
228
+ self._mode: DiffMode = "working"
229
+ self._files: list[GitChangedFile] = []
230
+ self._selected_idx: int = 0
231
+ self._load_token: int = 0
232
+ self._split = bool(split)
233
+ self._annotations = bool(annotations)
234
+ self._last_payload: DiffPayload | None = None
235
+ self._closing_diff_widgets: list[Any] = []
236
+ # Keep this distinct from Textual MessagePump._closed.
237
+ self._dismiss_started: bool = False
238
+
239
+ @property
240
+ def title_text(self) -> str:
241
+ branch = self._branch_label or "git"
242
+ n = len(self._files)
243
+ return f"Git Explore · {branch} · {n} file{'s' if n != 1 else ''}"
244
+
245
+ def compose(self) -> ComposeResult:
246
+ with Vertical(id="ge-window"):
247
+ yield Static("", id="ge-header")
248
+ with Horizontal(id="ge-body"):
249
+ yield VerticalScroll(id="ge-file-list")
250
+ with VerticalScroll(id="ge-diff-scroll"):
251
+ yield Static("select a file", id="ge-diff-body")
252
+
253
+ def on_mount(self) -> None:
254
+ win = self.query_one("#ge-window")
255
+ win.border_title = f"◆ {self.title_text}"
256
+ win.border_subtitle = (
257
+ "j/k files · ←/→ mode · u split/unified · a ann · r reload · esc"
258
+ )
259
+ self._reload_files()
260
+ self.set_focus(self.query_one("#ge-file-list"))
261
+
262
+ def _mode_tabs(self) -> str:
263
+ parts: list[str] = []
264
+ for m in DIFF_MODES:
265
+ if m == self._mode:
266
+ parts.append(f"[{m}]")
267
+ else:
268
+ parts.append(f" {m} ")
269
+ return " ".join(parts)
270
+
271
+ def _view_flags_text(self) -> Text:
272
+ out = Text()
273
+ if self._split:
274
+ out.append("[split]", style="bold cyan")
275
+ out.append(" unified", style="dim")
276
+ else:
277
+ out.append(" split", style="dim")
278
+ out.append("[unified]", style="bold cyan")
279
+ out.append(" ")
280
+ if self._annotations:
281
+ out.append("[ann]", style="bold")
282
+ else:
283
+ out.append("ann", style="dim")
284
+ engine = "diff-view" if HAS_DIFF_VIEW else "fallback"
285
+ out.append(f" · {engine}", style="dim")
286
+ return out
287
+
288
+ def _paint_header(self) -> None:
289
+ header = self.query_one("#ge-header", Static)
290
+ n = len(self._files)
291
+ line = Text()
292
+ line.append(self._mode_tabs())
293
+ line.append(" · ")
294
+ if n == 0:
295
+ line.append("working tree clean", style="dim")
296
+ else:
297
+ item = self._files[self._selected_idx] if self._files else None
298
+ focus = item.path if item else ""
299
+ line.append(f"{n} changed")
300
+ if focus:
301
+ line.append(" · ")
302
+ line.append(focus, style="bold")
303
+ line.append(" · ")
304
+ line.append_text(self._view_flags_text())
305
+ header.update(line)
306
+
307
+ def _reload_files(self) -> None:
308
+ self._files = probe_git_changed_files(self._workspace, limit=200)
309
+ if self._initial_path:
310
+ for i, f in enumerate(self._files):
311
+ if f.path == self._initial_path:
312
+ self._selected_idx = i
313
+ break
314
+ else:
315
+ self._selected_idx = 0
316
+ self._initial_path = None
317
+ else:
318
+ self._selected_idx = min(self._selected_idx, max(0, len(self._files) - 1))
319
+ self._mount_file_rows()
320
+ self._paint_header()
321
+ self._update_title()
322
+ if self._files:
323
+ self._request_diff()
324
+ else:
325
+ self._last_payload = None
326
+ self._show_fallback(Text("working tree clean", style="dim"))
327
+
328
+ def _update_title(self) -> None:
329
+ try:
330
+ win = self.query_one("#ge-window")
331
+ win.border_title = f"◆ {self.title_text}"
332
+ except Exception: # noqa: BLE001
333
+ pass
334
+
335
+ def _alive(self) -> bool:
336
+ """False after dismiss/unmount starts; blocks late mounts/workers."""
337
+ if self._dismiss_started:
338
+ return False
339
+ try:
340
+ return bool(self.is_attached)
341
+ except Exception: # noqa: BLE001
342
+ return False
343
+
344
+ def _release_heavy_state(self) -> None:
345
+ """Drop file texts / lists held on the screen instance."""
346
+ self._last_payload = None
347
+ self._files = []
348
+ self._selected_idx = 0
349
+ self._load_token += 1 # invalidate in-flight apply callbacks
350
+
351
+ def _clear_diff_view_caches(self, widget: Any) -> None:
352
+ """Best-effort wipe of an unmounted DiffView's large code caches."""
353
+ if widget is None:
354
+ return
355
+ for attr, empty in (
356
+ ("code_original", ""),
357
+ ("code_modified", ""),
358
+ ("_highlighted_code_lines", None),
359
+ ("_grouped_opcodes", None),
360
+ ):
361
+ try:
362
+ if hasattr(widget, attr):
363
+ setattr(widget, attr, empty)
364
+ except Exception: # noqa: BLE001
365
+ pass
366
+ for attr in (
367
+ "_number_styles",
368
+ "_annotation_styles",
369
+ "_line_styles",
370
+ "_edge_styles",
371
+ ):
372
+ try:
373
+ bucket = getattr(widget, attr, None)
374
+ if isinstance(bucket, dict):
375
+ bucket.clear()
376
+ except Exception: # noqa: BLE001
377
+ pass
378
+
379
+ def _begin_close(self) -> None:
380
+ """Invalidate late work and release payloads before Textual removes the screen."""
381
+ if self._dismiss_started:
382
+ return
383
+ try:
384
+ self._closing_diff_widgets = list(self._diff_host().children)
385
+ except Exception: # noqa: BLE001
386
+ self._closing_diff_widgets = []
387
+ self._dismiss_started = True
388
+ self._release_heavy_state()
389
+
390
+ def dismiss(self, result: Any = None) -> AwaitComplete:
391
+ # Double-click / repeated outside clicks can re-enter dismiss after the
392
+ # screen is already gone; Textual then raises ScreenStackError.
393
+ if self._dismiss_started or not self.is_attached:
394
+ return AwaitComplete()
395
+ try:
396
+ if self.app.screen is not self:
397
+ self._begin_close()
398
+ return AwaitComplete()
399
+ except Exception: # noqa: BLE001
400
+ self._begin_close()
401
+ return AwaitComplete()
402
+ self._begin_close()
403
+ return super().dismiss(result)
404
+
405
+ def on_unmount(self) -> None:
406
+ """Final safety net when the screen leaves the stack."""
407
+ self._begin_close()
408
+ for widget in self._closing_diff_widgets:
409
+ self._clear_diff_view_caches(widget)
410
+ self._closing_diff_widgets.clear()
411
+ _clear_textual_style_cache_refs()
412
+ try:
413
+ asyncio.get_running_loop().call_soon(gc.collect)
414
+ except RuntimeError:
415
+ gc.collect()
416
+
417
+ def _mount_file_rows(self) -> None:
418
+ if not self._alive():
419
+ return
420
+ self.run_worker(
421
+ self._replace_file_rows(),
422
+ exclusive=True,
423
+ group="ge-file-rows",
424
+ )
425
+
426
+ async def _replace_file_rows(self) -> None:
427
+ if not self._alive():
428
+ return
429
+ panel = self.query_one("#ge-file-list", VerticalScroll)
430
+ await panel.remove_children()
431
+ _clear_textual_style_cache_refs()
432
+ if not self._alive():
433
+ return
434
+ if not self._files:
435
+ await panel.mount(Static(Text("no changes", style="dim")))
436
+ return
437
+ rows = [
438
+ ExploreFileRow(item, selected=(i == self._selected_idx))
439
+ for i, item in enumerate(self._files)
440
+ ]
441
+ if rows and self._alive():
442
+ await panel.mount_all(rows)
443
+
444
+ def _sync_selection(self) -> None:
445
+ if not self._alive():
446
+ return
447
+ panel = self.query_one("#ge-file-list", VerticalScroll)
448
+ rows = list(panel.query(ExploreFileRow))
449
+ for i, row in enumerate(rows):
450
+ row.set_selected(i == self._selected_idx)
451
+ if 0 <= self._selected_idx < len(rows):
452
+ try:
453
+ panel.scroll_to_widget(rows[self._selected_idx], animate=False)
454
+ except Exception: # noqa: BLE001
455
+ pass
456
+ self._paint_header()
457
+ self._request_diff()
458
+
459
+ def _diff_host(self) -> VerticalScroll:
460
+ return self.query_one("#ge-diff-scroll", VerticalScroll)
461
+
462
+ async def _replace_diff_children(self, *widgets: Any) -> None:
463
+ if not self._alive():
464
+ for widget in widgets:
465
+ self._clear_diff_view_caches(widget)
466
+ return
467
+ host = self._diff_host()
468
+ outgoing = list(host.children)
469
+ await host.remove_children()
470
+
471
+ # Textual 8.2.8 retains each Widget StylesCache as an lru_cache key.
472
+ # Once the old tree is detached, release its DiffView data and LRU keys.
473
+ for child in outgoing:
474
+ self._clear_diff_view_caches(child)
475
+ _clear_textual_style_cache_refs()
476
+
477
+ if not self._alive():
478
+ for widget in widgets:
479
+ self._clear_diff_view_caches(widget)
480
+ return
481
+ if widgets:
482
+ await host.mount(*widgets)
483
+ try:
484
+ host.scroll_home(animate=False)
485
+ except Exception: # noqa: BLE001
486
+ pass
487
+
488
+ def _show_fallback(self, content: Any) -> None:
489
+ if not self._alive():
490
+ return
491
+ host = self._diff_host()
492
+ try:
493
+ body = host.query_one("#ge-diff-body", Static)
494
+ except Exception: # noqa: BLE001
495
+ body = None
496
+ if body is not None and len(host.children) == 1:
497
+ body.update(content)
498
+ try:
499
+ host.scroll_home(animate=False)
500
+ except Exception: # noqa: BLE001
501
+ pass
502
+ return
503
+ self.run_worker(
504
+ self._replace_diff_children(Static(content, id="ge-diff-body")),
505
+ exclusive=True,
506
+ group="ge-diff-mount",
507
+ )
508
+
509
+ def _show_diff_view(self, view: Any) -> None:
510
+ if not self._alive():
511
+ self._clear_diff_view_caches(view)
512
+ return
513
+ self.run_worker(
514
+ self._replace_diff_children(view),
515
+ exclusive=True,
516
+ group="ge-diff-mount",
517
+ )
518
+
519
+ def _mount_payload(self, payload: DiffPayload) -> None:
520
+ if not self._alive():
521
+ return
522
+ # Keep only the active file payload; previous one is replaced (not stacked).
523
+ self._last_payload = payload
524
+ if payload.error or payload.binary:
525
+ self._show_fallback(fallback_renderable(payload, colors=self._colors))
526
+ return
527
+ view = make_diff_view(
528
+ payload,
529
+ split=self._split,
530
+ annotations=self._annotations,
531
+ colors=self._colors,
532
+ )
533
+ if view is None:
534
+ self._show_fallback(fallback_renderable(payload, colors=self._colors))
535
+ return
536
+ self._show_diff_view(view)
537
+
538
+ def _request_diff(self) -> None:
539
+ if not self._alive() or not self._files:
540
+ return
541
+ idx = self._selected_idx
542
+ if not (0 <= idx < len(self._files)):
543
+ return
544
+ item = self._files[idx]
545
+ self._load_token += 1
546
+ token = self._load_token
547
+ # Drop previous payload early when switching files to free memory sooner.
548
+ self._last_payload = None
549
+ self._show_fallback(Text(f"loading {item.path}…", style="dim"))
550
+ self._load_diff_worker(
551
+ token,
552
+ item.path,
553
+ item.is_untracked,
554
+ self._mode,
555
+ )
556
+
557
+ @work(thread=True, exclusive=True, group="git-explore-diff")
558
+ def _load_diff_worker(
559
+ self,
560
+ token: int,
561
+ path: str,
562
+ is_untracked: bool,
563
+ mode: DiffMode,
564
+ ) -> None:
565
+ if self._dismiss_started or token != self._load_token:
566
+ return
567
+ try:
568
+ payload = load_file_diff(
569
+ self._workspace,
570
+ path,
571
+ mode=mode,
572
+ is_untracked=is_untracked,
573
+ )
574
+ except Exception as exc: # noqa: BLE001
575
+ payload = DiffPayload(
576
+ path=path,
577
+ text_a="",
578
+ text_b="",
579
+ mode=mode,
580
+ error=f"diff failed: {exc}",
581
+ )
582
+ if self._dismiss_started or token != self._load_token:
583
+ return
584
+ try:
585
+ self.app.call_from_thread(self._apply_payload, token, payload)
586
+ except Exception: # noqa: BLE001
587
+ # App/screen already gone — drop payload reference.
588
+ del payload
589
+
590
+ def _apply_payload(self, token: int, payload: DiffPayload) -> None:
591
+ if self._dismiss_started or token != self._load_token or not self._alive():
592
+ return
593
+ try:
594
+ self._mount_payload(payload)
595
+ except Exception as exc: # noqa: BLE001
596
+ if not self._alive():
597
+ return
598
+ self._show_fallback(
599
+ Text(
600
+ f"diff failed: {exc}",
601
+ style=self._colors.get("orange", "#f4b183"),
602
+ )
603
+ )
604
+
605
+ def _set_mode(self, mode: DiffMode) -> None:
606
+ if not self._alive() or mode == self._mode:
607
+ return
608
+ self._mode = mode
609
+ self._paint_header()
610
+ self._request_diff()
611
+
612
+ def _remount_last_payload(self) -> None:
613
+ if not self._alive():
614
+ return
615
+ if self._last_payload is None:
616
+ self._request_diff()
617
+ return
618
+ self._paint_header()
619
+ self._mount_payload(self._last_payload)
620
+
621
+ def action_close(self) -> None:
622
+ self.dismiss(None)
623
+
624
+ def action_file_prev(self) -> None:
625
+ if not self._files:
626
+ return
627
+ if self._selected_idx > 0:
628
+ self._selected_idx -= 1
629
+ self._sync_selection()
630
+
631
+ def action_file_next(self) -> None:
632
+ if not self._files:
633
+ return
634
+ if self._selected_idx < len(self._files) - 1:
635
+ self._selected_idx += 1
636
+ self._sync_selection()
637
+
638
+ def action_mode_prev(self) -> None:
639
+ i = DIFF_MODES.index(self._mode)
640
+ self._set_mode(DIFF_MODES[(i - 1) % len(DIFF_MODES)])
641
+
642
+ def action_mode_next(self) -> None:
643
+ i = DIFF_MODES.index(self._mode)
644
+ self._set_mode(DIFF_MODES[(i + 1) % len(DIFF_MODES)])
645
+
646
+ def action_mode_working(self) -> None:
647
+ self._set_mode("working")
648
+
649
+ def action_mode_staged(self) -> None:
650
+ self._set_mode("staged")
651
+
652
+ def action_mode_unstaged(self) -> None:
653
+ self._set_mode("unstaged")
654
+
655
+ def action_reload(self) -> None:
656
+ self._reload_files()
657
+
658
+ def action_toggle_split(self) -> None:
659
+ self._split = not self._split
660
+ self._remount_last_payload()
661
+
662
+ def action_toggle_annotations(self) -> None:
663
+ self._annotations = not self._annotations
664
+ self._remount_last_payload()
665
+
666
+ def action_diff_page_up(self) -> None:
667
+ try:
668
+ self._diff_host().scroll_page_up(animate=False)
669
+ except Exception: # noqa: BLE001
670
+ pass
671
+
672
+ def action_diff_page_down(self) -> None:
673
+ try:
674
+ self._diff_host().scroll_page_down(animate=False)
675
+ except Exception: # noqa: BLE001
676
+ pass
677
+
678
+ def on_click(self, event: Click) -> None:
679
+ widget = event.widget
680
+ if isinstance(widget, ExploreFileRow):
681
+ panel = self.query_one("#ge-file-list", VerticalScroll)
682
+ rows = list(panel.query(ExploreFileRow))
683
+ try:
684
+ idx = rows.index(widget)
685
+ except ValueError:
686
+ return
687
+ if idx != self._selected_idx:
688
+ self._selected_idx = idx
689
+ self._sync_selection()
690
+ event.stop()
691
+ return
692
+ try:
693
+ win = self.query_one("#ge-window")
694
+ except Exception: # noqa: BLE001
695
+ return
696
+ node = widget
697
+ while node is not None:
698
+ if node is win:
699
+ return
700
+ node = getattr(node, "parent", None)
701
+ if widget is self:
702
+ self.dismiss(None)