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,113 @@
1
+ """Theme picker dialog — invoked by /theme."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.app import ComposeResult
8
+
9
+ from synapse.ui.dialogs.base import DialogBase, OptionItem, SectionHeader
10
+
11
+
12
+ class ThemePickerDialog(DialogBase):
13
+ """Browse built-in + custom themes with live preview."""
14
+
15
+ _title_icon = "◈"
16
+
17
+ def __init__(self, settings: Any, project_root: Any = None) -> None:
18
+ super().__init__()
19
+ self._settings = settings
20
+ self._project_root = project_root
21
+ try:
22
+ from synapse.ui.theme import (
23
+ get_theme,
24
+ list_themes,
25
+ reload_theme_catalog,
26
+ )
27
+
28
+ reload_theme_catalog(project_root)
29
+ self._themes = list_themes()
30
+ self._current = (getattr(settings, "theme", None) or get_theme().name)
31
+ except Exception: # noqa: BLE001
32
+ self._themes = []
33
+ self._current = "cursor-dark"
34
+
35
+ @property
36
+ def title_text(self) -> str:
37
+ return "Select Theme"
38
+
39
+ def compose_body(self) -> ComposeResult:
40
+ yield SectionHeader("Themes")
41
+ items: list[OptionItem] = []
42
+ for t in self._themes:
43
+ kind = _theme_meta(t)
44
+ items.append(
45
+ OptionItem(
46
+ key=t.name,
47
+ label=f"{t.name:22} {t.label}",
48
+ detail="",
49
+ selected=(t.name == self._current),
50
+ meta=kind,
51
+ )
52
+ )
53
+ self._items = items
54
+
55
+ def on_mount(self) -> None:
56
+ super().on_mount()
57
+ body = self.query_one("#dialog-body")
58
+ body.set_options(self._items, mark=" ")
59
+ # Apply the selected theme on mount as a preview.
60
+ if self._themes:
61
+ cur = self._current or self._themes[0].name
62
+ self.on_option_row_clicked(cur)
63
+
64
+ def _on_selected(self, key: str | None) -> None:
65
+ if key:
66
+ self.dismiss(("theme", key))
67
+ else:
68
+ self.dismiss(None)
69
+
70
+ def on_option_row_clicked(self, key: str) -> None:
71
+ """Preview on hover / click before applying."""
72
+ try:
73
+ from synapse.ui.theme import set_theme
74
+ except Exception: # noqa: BLE001
75
+ return
76
+ # Apply preview only (no persist).
77
+ try:
78
+ set_theme(key, workspace=self._project_root, persist=False)
79
+ # Notify app to refresh CSS.
80
+ app = self.app
81
+ if hasattr(app, "apply_theme"):
82
+ app.apply_theme(key, persist=False, announce=False) # type: ignore[union-attr]
83
+ except Exception: # noqa: BLE001
84
+ pass
85
+
86
+
87
+ def _theme_meta(theme: object) -> str:
88
+ """Right-side hint: ansi | light | dark."""
89
+ try:
90
+ from synapse.ui.theme import theme_kind
91
+
92
+ return theme_kind(theme) # type: ignore[arg-type]
93
+ except Exception: # noqa: BLE001
94
+ pass
95
+ if bool(getattr(theme, "ansi", False)):
96
+ return "ansi"
97
+ bg = str(getattr(theme, "bg", "") or "")
98
+ if bg.strip().casefold() in {"transparent", "ansi_default", "default"}:
99
+ return "ansi"
100
+ return "light" if _is_light(bg) else "dark"
101
+
102
+
103
+ def _is_light(hex_color: str) -> bool:
104
+ """Rough luminance check: light bg > 0.5."""
105
+ c = hex_color.lstrip("#")
106
+ if len(c) < 6:
107
+ return False
108
+ try:
109
+ r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
110
+ except ValueError:
111
+ return False
112
+ lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
113
+ return lum > 0.5
@@ -0,0 +1,31 @@
1
+ """Git explore: file list + textual-diff-view (Rich fallback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from synapse.ui.git_explore.engine import (
6
+ HAS_DIFF_VIEW,
7
+ fallback_renderable,
8
+ make_diff_view,
9
+ status_line,
10
+ )
11
+ from synapse.ui.git_explore.provider import (
12
+ DIFF_MODES,
13
+ DiffMode,
14
+ DiffPayload,
15
+ language_hint_for_path,
16
+ load_file_diff,
17
+ )
18
+ from synapse.ui.git_explore.unified import render_unified_diff
19
+
20
+ __all__ = [
21
+ "DIFF_MODES",
22
+ "DiffMode",
23
+ "DiffPayload",
24
+ "HAS_DIFF_VIEW",
25
+ "fallback_renderable",
26
+ "language_hint_for_path",
27
+ "load_file_diff",
28
+ "make_diff_view",
29
+ "render_unified_diff",
30
+ "status_line",
31
+ ]
@@ -0,0 +1,82 @@
1
+ """Diff engine: textual-diff-view primary, Rich unified fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.text import Text
8
+
9
+ from synapse.ui.git_explore.provider import DiffPayload
10
+ from synapse.ui.git_explore.unified import render_unified_diff
11
+
12
+ try:
13
+ from textual_diff_view import DiffView as _DiffView
14
+
15
+ HAS_DIFF_VIEW = True
16
+ except Exception: # noqa: BLE001
17
+ _DiffView = None # type: ignore[assignment,misc]
18
+ HAS_DIFF_VIEW = False
19
+
20
+
21
+ def make_diff_view(
22
+ payload: DiffPayload,
23
+ *,
24
+ split: bool = True,
25
+ annotations: bool = True,
26
+ colors: dict[str, str] | None = None,
27
+ widget_id: str = "ge-diff-view",
28
+ ) -> Any:
29
+ """Build a mountable widget for ``payload``.
30
+
31
+ Prefer ``textual_diff_view.DiffView`` when available and content is text.
32
+ Otherwise return a Rich-backed ``Static``-ready renderable wrapped as payload
33
+ metadata for the caller to place in a Static (or a prebuilt Static).
34
+ """
35
+ colors = colors or {}
36
+ if payload.error or payload.binary or not HAS_DIFF_VIEW or _DiffView is None:
37
+ return None
38
+
39
+ # Paths are labels only; content comes from strings.
40
+ path_a = f"a/{payload.path}"
41
+ path_b = f"b/{payload.path}"
42
+ if payload.mode == "staged":
43
+ path_a = f"HEAD/{payload.path}"
44
+ path_b = f"index/{payload.path}"
45
+ elif payload.mode == "unstaged":
46
+ path_a = f"index/{payload.path}"
47
+ path_b = f"worktree/{payload.path}"
48
+ else:
49
+ path_a = f"HEAD/{payload.path}"
50
+ path_b = f"worktree/{payload.path}"
51
+
52
+ return _DiffView(
53
+ path_a,
54
+ path_b,
55
+ payload.text_a or "",
56
+ payload.text_b or "",
57
+ split=bool(split),
58
+ annotations=bool(annotations),
59
+ auto_split=False,
60
+ wrap=False,
61
+ id=widget_id,
62
+ )
63
+
64
+
65
+ def fallback_renderable(payload: DiffPayload, *, colors: dict[str, str] | None = None) -> Any:
66
+ """Rich Group/Text for cases where DiffView is unavailable or unsuitable."""
67
+ colors = colors or {}
68
+ return render_unified_diff(
69
+ payload,
70
+ color_meta=colors.get("dim", "#9aa0a6"),
71
+ color_hunk=colors.get("hunk", "#8ab4f8"),
72
+ color_add=colors.get("added", "#81c995"),
73
+ color_del=colors.get("deleted", "#f28b82"),
74
+ color_ctx=colors.get("fg", "#e8eaed"),
75
+ color_warn=colors.get("orange", "#f4b183"),
76
+ )
77
+
78
+
79
+ def status_line(*, split: bool, annotations: bool, engine: str) -> Text:
80
+ layout = "split" if split else "unified"
81
+ ann = "ann:on" if annotations else "ann:off"
82
+ return Text(f"{layout} · {ann} · {engine}", style="dim")
@@ -0,0 +1,242 @@
1
+ """Load old/new text for a changed file (working / staged / unstaged)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ DiffMode = Literal["working", "staged", "unstaged"]
11
+ DIFF_MODES: tuple[DiffMode, ...] = ("working", "staged", "unstaged")
12
+
13
+ # Soft limits for TUI readability (bytes / lines of each side).
14
+ _MAX_BYTES = 512 * 1024
15
+ _MAX_LINES = 8_000
16
+
17
+ _EXT_LANG: dict[str, str] = {
18
+ ".py": "python",
19
+ ".pyi": "python",
20
+ ".js": "javascript",
21
+ ".jsx": "javascript",
22
+ ".ts": "typescript",
23
+ ".tsx": "typescript",
24
+ ".json": "json",
25
+ ".md": "markdown",
26
+ ".toml": "toml",
27
+ ".yaml": "yaml",
28
+ ".yml": "yaml",
29
+ ".rs": "rust",
30
+ ".go": "go",
31
+ ".java": "java",
32
+ ".c": "c",
33
+ ".h": "c",
34
+ ".cpp": "cpp",
35
+ ".cc": "cpp",
36
+ ".cs": "csharp",
37
+ ".rb": "ruby",
38
+ ".sh": "bash",
39
+ ".bash": "bash",
40
+ ".zsh": "bash",
41
+ ".css": "css",
42
+ ".html": "html",
43
+ ".xml": "xml",
44
+ ".sql": "sql",
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class DiffPayload:
50
+ """Old/new text for one path under a compare mode."""
51
+
52
+ path: str
53
+ text_a: str
54
+ text_b: str
55
+ mode: DiffMode = "working"
56
+ language_hint: str | None = None
57
+ binary: bool = False
58
+ truncated: bool = False
59
+ error: str | None = None
60
+ missing_a: bool = False
61
+ missing_b: bool = False
62
+
63
+
64
+ def language_hint_for_path(path: str) -> str | None:
65
+ ext = Path(path or "").suffix.lower()
66
+ return _EXT_LANG.get(ext)
67
+
68
+
69
+ def _run_git_bytes(args: list[str], *, cwd: Path, timeout: float = 2.0) -> bytes | None:
70
+ try:
71
+ proc = subprocess.run(
72
+ ["git", *args],
73
+ cwd=str(cwd),
74
+ capture_output=True,
75
+ timeout=timeout,
76
+ check=False,
77
+ )
78
+ except Exception: # noqa: BLE001
79
+ return None
80
+ if proc.returncode != 0:
81
+ return None
82
+ return proc.stdout or b""
83
+
84
+
85
+ def _run_git_text(args: list[str], *, cwd: Path, timeout: float = 2.0) -> str | None:
86
+ raw = _run_git_bytes(args, cwd=cwd, timeout=timeout)
87
+ if raw is None:
88
+ return None
89
+ try:
90
+ return raw.decode("utf-8")
91
+ except UnicodeDecodeError:
92
+ return raw.decode("utf-8", errors="replace")
93
+
94
+
95
+ def _is_binary(data: bytes) -> bool:
96
+ if not data:
97
+ return False
98
+ # NUL in first 8KiB is a strong binary signal.
99
+ sample = data[:8192]
100
+ return b"\x00" in sample
101
+
102
+
103
+ def _decode_and_cap(data: bytes | None) -> tuple[str, bool, bool]:
104
+ """Return ``(text, binary, truncated)``."""
105
+ if data is None:
106
+ return "", False, False
107
+ if _is_binary(data):
108
+ return "", True, False
109
+ truncated = False
110
+ body = data
111
+ if len(body) > _MAX_BYTES:
112
+ body = body[:_MAX_BYTES]
113
+ truncated = True
114
+ try:
115
+ text = body.decode("utf-8")
116
+ except UnicodeDecodeError:
117
+ text = body.decode("utf-8", errors="replace")
118
+ lines = text.splitlines(keepends=True)
119
+ if len(lines) > _MAX_LINES:
120
+ text = "".join(lines[:_MAX_LINES])
121
+ if not text.endswith("\n"):
122
+ text += "\n"
123
+ truncated = True
124
+ return text, False, truncated
125
+
126
+
127
+ def _read_worktree(cwd: Path, path: str) -> tuple[str, bool, bool, bool]:
128
+ """Return ``(text, binary, truncated, missing)``."""
129
+ full = cwd / path
130
+ try:
131
+ if not full.is_file():
132
+ return "", False, False, True
133
+ data = full.read_bytes()
134
+ except Exception: # noqa: BLE001
135
+ return "", False, False, True
136
+ text, binary, truncated = _decode_and_cap(data)
137
+ return text, binary, truncated, False
138
+
139
+
140
+ def _read_blob(cwd: Path, spec: str) -> tuple[str, bool, bool, bool]:
141
+ """Read ``git show <spec>``. Returns ``(text, binary, truncated, missing)``."""
142
+ raw = _run_git_bytes(["show", spec], cwd=cwd, timeout=2.0)
143
+ if raw is None:
144
+ return "", False, False, True
145
+ text, binary, truncated = _decode_and_cap(raw)
146
+ return text, binary, truncated, False
147
+
148
+
149
+ def load_file_diff(
150
+ cwd: Path | str,
151
+ path: str,
152
+ *,
153
+ mode: DiffMode = "working",
154
+ is_untracked: bool = False,
155
+ ) -> DiffPayload:
156
+ """Load left/right text for ``path`` under ``mode``.
157
+
158
+ Semantics:
159
+ - working: HEAD:path vs worktree
160
+ - staged: HEAD:path vs index (:path)
161
+ - unstaged: index (:path) vs worktree
162
+ - untracked: empty vs worktree (any mode)
163
+ """
164
+ root = Path(cwd)
165
+ rel = (path or "").replace("\\", "/").lstrip("./")
166
+ if not rel:
167
+ return DiffPayload(
168
+ path=path or "",
169
+ text_a="",
170
+ text_b="",
171
+ mode=mode,
172
+ error="empty path",
173
+ )
174
+
175
+ lang = language_hint_for_path(rel)
176
+ truncated = False
177
+ binary = False
178
+
179
+ if is_untracked:
180
+ text_b, bin_b, trunc_b, miss_b = _read_worktree(root, rel)
181
+ binary = bin_b
182
+ truncated = trunc_b
183
+ if binary:
184
+ return DiffPayload(
185
+ path=rel,
186
+ text_a="",
187
+ text_b="",
188
+ mode=mode,
189
+ language_hint=lang,
190
+ binary=True,
191
+ missing_a=True,
192
+ missing_b=miss_b,
193
+ )
194
+ return DiffPayload(
195
+ path=rel,
196
+ text_a="",
197
+ text_b=text_b,
198
+ mode=mode,
199
+ language_hint=lang,
200
+ truncated=truncated,
201
+ missing_a=True,
202
+ missing_b=miss_b,
203
+ )
204
+
205
+ if mode == "staged":
206
+ text_a, bin_a, trunc_a, miss_a = _read_blob(root, f"HEAD:{rel}")
207
+ text_b, bin_b, trunc_b, miss_b = _read_blob(root, f":{rel}")
208
+ elif mode == "unstaged":
209
+ text_a, bin_a, trunc_a, miss_a = _read_blob(root, f":{rel}")
210
+ # If not in index, fall back to HEAD as left side.
211
+ if miss_a:
212
+ text_a, bin_a, trunc_a, miss_a = _read_blob(root, f"HEAD:{rel}")
213
+ text_b, bin_b, trunc_b, miss_b = _read_worktree(root, rel)
214
+ else: # working
215
+ text_a, bin_a, trunc_a, miss_a = _read_blob(root, f"HEAD:{rel}")
216
+ text_b, bin_b, trunc_b, miss_b = _read_worktree(root, rel)
217
+
218
+ binary = bin_a or bin_b
219
+ truncated = trunc_a or trunc_b
220
+ if binary:
221
+ return DiffPayload(
222
+ path=rel,
223
+ text_a="",
224
+ text_b="",
225
+ mode=mode,
226
+ language_hint=lang,
227
+ binary=True,
228
+ truncated=truncated,
229
+ missing_a=miss_a,
230
+ missing_b=miss_b,
231
+ )
232
+
233
+ return DiffPayload(
234
+ path=rel,
235
+ text_a=text_a,
236
+ text_b=text_b,
237
+ mode=mode,
238
+ language_hint=lang,
239
+ truncated=truncated,
240
+ missing_a=miss_a,
241
+ missing_b=miss_b,
242
+ )
@@ -0,0 +1,85 @@
1
+ """Fallback unified-diff renderer (Rich Text)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+
7
+ from rich.console import Group
8
+ from rich.text import Text
9
+
10
+ from synapse.ui.git_explore.provider import DiffPayload
11
+
12
+
13
+ def render_unified_diff(
14
+ payload: DiffPayload,
15
+ *,
16
+ color_meta: str = "#9aa0a6",
17
+ color_hunk: str = "#8ab4f8",
18
+ color_add: str = "#81c995",
19
+ color_del: str = "#f28b82",
20
+ color_ctx: str = "#e8eaed",
21
+ color_warn: str = "#f4b183",
22
+ context: int = 3,
23
+ ) -> Group | Text:
24
+ """Render a ``DiffPayload`` as colored unified diff lines."""
25
+ if payload.error:
26
+ return Text(payload.error, style=color_warn)
27
+ if payload.binary:
28
+ return Text(f"binary file: {payload.path}", style=color_meta)
29
+
30
+ a_lines = (payload.text_a or "").splitlines(keepends=True)
31
+ b_lines = (payload.text_b or "").splitlines(keepends=True)
32
+
33
+ if not a_lines and not b_lines:
34
+ if payload.missing_a and payload.missing_b:
35
+ return Text("file not found on either side", style=color_meta)
36
+ return Text("no content", style=color_meta)
37
+
38
+ if payload.mode == "staged":
39
+ from_label = f"HEAD/{payload.path}"
40
+ to_label = f"index/{payload.path}"
41
+ elif payload.mode == "unstaged":
42
+ from_label = f"index/{payload.path}"
43
+ to_label = f"worktree/{payload.path}"
44
+ else:
45
+ from_label = f"HEAD/{payload.path}"
46
+ to_label = f"worktree/{payload.path}"
47
+
48
+ rows: list[Text] = []
49
+ if payload.truncated:
50
+ rows.append(Text("… truncated for display …", style=color_warn))
51
+
52
+ if payload.missing_a and not payload.missing_b:
53
+ rows.append(Text(f"new file · {payload.mode}", style=color_meta))
54
+ elif payload.missing_b and not payload.missing_a:
55
+ rows.append(Text(f"deleted file · {payload.mode}", style=color_meta))
56
+ else:
57
+ rows.append(Text(f"{payload.mode} · {payload.path}", style=color_meta))
58
+
59
+ diff_iter = difflib.unified_diff(
60
+ a_lines,
61
+ b_lines,
62
+ fromfile=from_label,
63
+ tofile=to_label,
64
+ n=max(0, int(context)),
65
+ lineterm="",
66
+ )
67
+ produced = False
68
+ for line in diff_iter:
69
+ produced = True
70
+ raw = line.rstrip("\n")
71
+ if raw.startswith("+++") or raw.startswith("---"):
72
+ rows.append(Text(raw, style=color_meta))
73
+ elif raw.startswith("@@"):
74
+ rows.append(Text(raw, style=color_hunk))
75
+ elif raw.startswith("+"):
76
+ rows.append(Text(raw, style=color_add))
77
+ elif raw.startswith("-"):
78
+ rows.append(Text(raw, style=color_del))
79
+ else:
80
+ rows.append(Text(raw, style=color_ctx))
81
+
82
+ if not produced:
83
+ rows.append(Text("no differences in this mode", style=color_meta))
84
+
85
+ return Group(*rows)