superqode 0.1.5__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 (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
superqode/diff_view.py ADDED
@@ -0,0 +1,919 @@
1
+ """
2
+ SuperQode Diff View - Beautiful Code Diff Display
3
+
4
+ A unique diff visualization with:
5
+ - Gradient-styled headers
6
+ - Side-by-side and unified views
7
+ - Syntax highlighting
8
+ - Line-level change indicators
9
+ - Textual widget with synchronized scrolling
10
+ - Auto-detection of split/unified based on terminal width
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import difflib
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import List, Tuple, Optional
20
+
21
+ from rich.console import Console, Group
22
+ from rich.panel import Panel
23
+ from rich.syntax import Syntax
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+ from rich.box import ROUNDED, SIMPLE, MINIMAL
27
+
28
+ # Textual imports for widget-based diff view
29
+ from textual.app import ComposeResult
30
+ from textual.containers import Container, Horizontal, ScrollableContainer
31
+ from textual.widgets import Static
32
+ from textual.reactive import reactive, var
33
+ from textual.message import Message
34
+ from textual.binding import Binding
35
+ from textual import on
36
+
37
+
38
+ class DiffMode(Enum):
39
+ """Diff display mode."""
40
+
41
+ UNIFIED = "unified"
42
+ SPLIT = "split"
43
+ COMPACT = "compact"
44
+
45
+
46
+ @dataclass
47
+ class DiffLine:
48
+ """A single line in a diff."""
49
+
50
+ line_no_old: Optional[int]
51
+ line_no_new: Optional[int]
52
+ content: str
53
+ change_type: str # '+', '-', ' ', '~' (modified)
54
+
55
+
56
+ @dataclass
57
+ class DiffHunk:
58
+ """A group of related changes."""
59
+
60
+ old_start: int
61
+ old_count: int
62
+ new_start: int
63
+ new_count: int
64
+ lines: List[DiffLine]
65
+
66
+
67
+ @dataclass
68
+ class FileDiff:
69
+ """Complete diff for a file."""
70
+
71
+ path: str
72
+ old_content: str
73
+ new_content: str
74
+ hunks: List[DiffHunk]
75
+ additions: int
76
+ deletions: int
77
+ is_new: bool
78
+ is_deleted: bool
79
+
80
+
81
+ # SuperQode gradient colors
82
+ DIFF_COLORS = {
83
+ "header_gradient": ["#a855f7", "#ec4899", "#f97316"],
84
+ "addition": "#22c55e",
85
+ "addition_bg": "#22c55e15",
86
+ "deletion": "#ef4444",
87
+ "deletion_bg": "#ef444415",
88
+ "context": "#71717a",
89
+ "line_no": "#52525b",
90
+ "border": "#2a2a2a",
91
+ "highlight_add": "#22c55e30",
92
+ "highlight_del": "#ef444430",
93
+ }
94
+
95
+ # Icons for diff display
96
+ DIFF_ICONS = {
97
+ "file": "📄",
98
+ "new_file": "✨",
99
+ "deleted_file": "🗑️",
100
+ "modified": "📝",
101
+ "addition": "➕",
102
+ "deletion": "➖",
103
+ "unchanged": "│",
104
+ "hunk": "┄",
105
+ }
106
+
107
+
108
+ def compute_diff(old_content: str, new_content: str, path: str = "file") -> FileDiff:
109
+ """
110
+ Compute the diff between two versions of content.
111
+
112
+ Args:
113
+ old_content: Original content
114
+ new_content: New content
115
+ path: File path for display
116
+
117
+ Returns:
118
+ FileDiff object with all diff information
119
+ """
120
+ old_lines = old_content.splitlines(keepends=True)
121
+ new_lines = new_content.splitlines(keepends=True)
122
+
123
+ # Handle edge cases
124
+ is_new = not old_content.strip()
125
+ is_deleted = not new_content.strip()
126
+
127
+ # Get unified diff
128
+ differ = difflib.unified_diff(
129
+ old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""
130
+ )
131
+
132
+ hunks: List[DiffHunk] = []
133
+ current_hunk: Optional[DiffHunk] = None
134
+ additions = 0
135
+ deletions = 0
136
+
137
+ old_line_no = 0
138
+ new_line_no = 0
139
+
140
+ for line in differ:
141
+ if line.startswith("@@"):
142
+ # Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
143
+ if current_hunk:
144
+ hunks.append(current_hunk)
145
+
146
+ parts = line.split()
147
+ old_info = parts[1][1:].split(",")
148
+ new_info = parts[2][1:].split(",")
149
+
150
+ old_start = int(old_info[0])
151
+ old_count = int(old_info[1]) if len(old_info) > 1 else 1
152
+ new_start = int(new_info[0])
153
+ new_count = int(new_info[1]) if len(new_info) > 1 else 1
154
+
155
+ current_hunk = DiffHunk(
156
+ old_start=old_start,
157
+ old_count=old_count,
158
+ new_start=new_start,
159
+ new_count=new_count,
160
+ lines=[],
161
+ )
162
+ old_line_no = old_start
163
+ new_line_no = new_start
164
+
165
+ elif line.startswith("---") or line.startswith("+++"):
166
+ continue
167
+
168
+ elif current_hunk is not None:
169
+ content = line[1:] if len(line) > 1 else ""
170
+
171
+ if line.startswith("+"):
172
+ current_hunk.lines.append(
173
+ DiffLine(
174
+ line_no_old=None,
175
+ line_no_new=new_line_no,
176
+ content=content.rstrip("\n"),
177
+ change_type="+",
178
+ )
179
+ )
180
+ new_line_no += 1
181
+ additions += 1
182
+
183
+ elif line.startswith("-"):
184
+ current_hunk.lines.append(
185
+ DiffLine(
186
+ line_no_old=old_line_no,
187
+ line_no_new=None,
188
+ content=content.rstrip("\n"),
189
+ change_type="-",
190
+ )
191
+ )
192
+ old_line_no += 1
193
+ deletions += 1
194
+
195
+ else:
196
+ current_hunk.lines.append(
197
+ DiffLine(
198
+ line_no_old=old_line_no,
199
+ line_no_new=new_line_no,
200
+ content=content.rstrip("\n"),
201
+ change_type=" ",
202
+ )
203
+ )
204
+ old_line_no += 1
205
+ new_line_no += 1
206
+
207
+ if current_hunk:
208
+ hunks.append(current_hunk)
209
+
210
+ return FileDiff(
211
+ path=path,
212
+ old_content=old_content,
213
+ new_content=new_content,
214
+ hunks=hunks,
215
+ additions=additions,
216
+ deletions=deletions,
217
+ is_new=is_new,
218
+ is_deleted=is_deleted,
219
+ )
220
+
221
+
222
+ def render_diff_header(diff: FileDiff, console: Console) -> None:
223
+ """Render a beautiful diff header."""
224
+ # Determine icon and status
225
+ if diff.is_new:
226
+ icon = DIFF_ICONS["new_file"]
227
+ status = "New File"
228
+ status_color = DIFF_COLORS["addition"]
229
+ elif diff.is_deleted:
230
+ icon = DIFF_ICONS["deleted_file"]
231
+ status = "Deleted"
232
+ status_color = DIFF_COLORS["deletion"]
233
+ else:
234
+ icon = DIFF_ICONS["modified"]
235
+ status = "Modified"
236
+ status_color = "#f97316"
237
+
238
+ # Build header text
239
+ header = Text()
240
+ header.append(f" {icon} ", style="bold")
241
+ header.append(diff.path, style="bold white")
242
+ header.append(" ", style="")
243
+ header.append(f"[{status}]", style=f"bold {status_color}")
244
+ header.append(" ", style="")
245
+ header.append(f"+{diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
246
+ header.append(" / ", style="dim")
247
+ header.append(f"-{diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
248
+
249
+ console.print(Panel(header, border_style=DIFF_COLORS["border"], box=ROUNDED, padding=(0, 1)))
250
+
251
+
252
+ def render_diff_unified(diff: FileDiff, console: Console, context_lines: int = 3) -> None:
253
+ """Render diff in unified format."""
254
+ render_diff_header(diff, console)
255
+
256
+ if not diff.hunks:
257
+ console.print(" [dim]No changes[/dim]")
258
+ return
259
+
260
+ for hunk in diff.hunks:
261
+ # Hunk separator
262
+ hunk_header = Text()
263
+ hunk_header.append(f" {DIFF_ICONS['hunk']} ", style="dim cyan")
264
+ hunk_header.append(
265
+ f"@@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@",
266
+ style="dim cyan",
267
+ )
268
+ console.print(hunk_header)
269
+
270
+ # Render lines
271
+ for line in hunk.lines:
272
+ line_text = Text()
273
+
274
+ # Line numbers
275
+ old_no = f"{line.line_no_old:>4}" if line.line_no_old else " "
276
+ new_no = f"{line.line_no_new:>4}" if line.line_no_new else " "
277
+ line_text.append(f" {old_no} {new_no} ", style=DIFF_COLORS["line_no"])
278
+
279
+ # Change indicator and content
280
+ if line.change_type == "+":
281
+ line_text.append("│", style=DIFF_COLORS["addition"])
282
+ line_text.append(f" {line.content}", style=f"on {DIFF_COLORS['addition_bg']}")
283
+ elif line.change_type == "-":
284
+ line_text.append("│", style=DIFF_COLORS["deletion"])
285
+ line_text.append(f" {line.content}", style=f"on {DIFF_COLORS['deletion_bg']}")
286
+ else:
287
+ line_text.append("│", style="dim")
288
+ line_text.append(f" {line.content}", style="")
289
+
290
+ console.print(line_text)
291
+
292
+ console.print()
293
+
294
+
295
+ def render_diff_split(diff: FileDiff, console: Console, width: int = 80) -> None:
296
+ """Render diff in side-by-side split format."""
297
+ render_diff_header(diff, console)
298
+
299
+ if not diff.hunks:
300
+ console.print(" [dim]No changes[/dim]")
301
+ return
302
+
303
+ half_width = (width - 10) // 2
304
+
305
+ for hunk in diff.hunks:
306
+ # Collect old and new lines separately
307
+ old_lines: List[Tuple[Optional[int], str, str]] = []
308
+ new_lines: List[Tuple[Optional[int], str, str]] = []
309
+
310
+ for line in hunk.lines:
311
+ if line.change_type == "-":
312
+ old_lines.append((line.line_no_old, line.content, "-"))
313
+ elif line.change_type == "+":
314
+ new_lines.append((line.line_no_new, line.content, "+"))
315
+ else:
316
+ old_lines.append((line.line_no_old, line.content, " "))
317
+ new_lines.append((line.line_no_new, line.content, " "))
318
+
319
+ # Pad to same length
320
+ max_len = max(len(old_lines), len(new_lines))
321
+ while len(old_lines) < max_len:
322
+ old_lines.append((None, "", "/"))
323
+ while len(new_lines) < max_len:
324
+ new_lines.append((None, "", "/"))
325
+
326
+ # Render side by side
327
+ for (old_no, old_content, old_type), (new_no, new_content, new_type) in zip(
328
+ old_lines, new_lines
329
+ ):
330
+ line_text = Text()
331
+
332
+ # Old side
333
+ old_no_str = f"{old_no:>4}" if old_no else " "
334
+ line_text.append(f" {old_no_str} ", style=DIFF_COLORS["line_no"])
335
+
336
+ if old_type == "-":
337
+ line_text.append("─", style=DIFF_COLORS["deletion"])
338
+ content = old_content[:half_width].ljust(half_width)
339
+ line_text.append(content, style=f"on {DIFF_COLORS['deletion_bg']}")
340
+ elif old_type == "/":
341
+ line_text.append("╲", style="dim")
342
+ line_text.append("╲" * half_width, style="dim")
343
+ else:
344
+ line_text.append("│", style="dim")
345
+ line_text.append(old_content[:half_width].ljust(half_width), style="")
346
+
347
+ line_text.append(" │ ", style="dim")
348
+
349
+ # New side
350
+ new_no_str = f"{new_no:>4}" if new_no else " "
351
+ line_text.append(f"{new_no_str} ", style=DIFF_COLORS["line_no"])
352
+
353
+ if new_type == "+":
354
+ line_text.append("─", style=DIFF_COLORS["addition"])
355
+ content = new_content[:half_width].ljust(half_width)
356
+ line_text.append(content, style=f"on {DIFF_COLORS['addition_bg']}")
357
+ elif new_type == "/":
358
+ line_text.append("╲", style="dim")
359
+ line_text.append("╲" * half_width, style="dim")
360
+ else:
361
+ line_text.append("│", style="dim")
362
+ line_text.append(new_content[:half_width].ljust(half_width), style="")
363
+
364
+ console.print(line_text)
365
+
366
+ console.print()
367
+
368
+
369
+ def render_diff_compact(diff: FileDiff, console: Console) -> None:
370
+ """Render a compact summary of changes."""
371
+ # Determine icon and status
372
+ if diff.is_new:
373
+ icon = DIFF_ICONS["new_file"]
374
+ status_style = f"bold {DIFF_COLORS['addition']}"
375
+ elif diff.is_deleted:
376
+ icon = DIFF_ICONS["deleted_file"]
377
+ status_style = f"bold {DIFF_COLORS['deletion']}"
378
+ else:
379
+ icon = DIFF_ICONS["modified"]
380
+ status_style = "bold #f97316"
381
+
382
+ line = Text()
383
+ line.append(f" {icon} ", style="")
384
+ line.append(diff.path, style=status_style)
385
+ line.append(" ", style="")
386
+ line.append(f"+{diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
387
+ line.append("/", style="dim")
388
+ line.append(f"-{diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
389
+
390
+ console.print(line)
391
+
392
+
393
+ def render_diff(
394
+ diff: FileDiff, console: Console, mode: DiffMode = DiffMode.UNIFIED, width: int = 80
395
+ ) -> None:
396
+ """Render a diff with the specified mode."""
397
+ if mode == DiffMode.UNIFIED:
398
+ render_diff_unified(diff, console)
399
+ elif mode == DiffMode.SPLIT:
400
+ render_diff_split(diff, console, width)
401
+ else:
402
+ render_diff_compact(diff, console)
403
+
404
+
405
+ class DiffViewer:
406
+ """Interactive diff viewer for multiple files."""
407
+
408
+ def __init__(self, console: Console):
409
+ self.console = console
410
+ self.diffs: List[FileDiff] = []
411
+ self.current_index = 0
412
+ self.mode = DiffMode.UNIFIED
413
+
414
+ def add_diff(self, old_content: str, new_content: str, path: str) -> FileDiff:
415
+ """Add a file diff to the viewer."""
416
+ diff = compute_diff(old_content, new_content, path)
417
+ self.diffs.append(diff)
418
+ return diff
419
+
420
+ def render_all(self) -> None:
421
+ """Render all diffs."""
422
+ if not self.diffs:
423
+ self.console.print(" [dim]No changes to display[/dim]")
424
+ return
425
+
426
+ # Summary header
427
+ total_additions = sum(d.additions for d in self.diffs)
428
+ total_deletions = sum(d.deletions for d in self.diffs)
429
+
430
+ header = Text()
431
+ header.append(f" 📊 ", style="bold")
432
+ header.append(f"{len(self.diffs)} file(s) changed", style="bold white")
433
+ header.append(" ", style="")
434
+ header.append(f"+{total_additions}", style=f"bold {DIFF_COLORS['addition']}")
435
+ header.append(" / ", style="dim")
436
+ header.append(f"-{total_deletions}", style=f"bold {DIFF_COLORS['deletion']}")
437
+
438
+ self.console.print(Panel(header, border_style="#a855f7", box=ROUNDED, padding=(0, 1)))
439
+ self.console.print()
440
+
441
+ # Render each diff
442
+ for diff in self.diffs:
443
+ render_diff(diff, self.console, self.mode)
444
+
445
+ def render_summary(self) -> None:
446
+ """Render a compact summary of all changes."""
447
+ if not self.diffs:
448
+ self.console.print(" [dim]No changes[/dim]")
449
+ return
450
+
451
+ for diff in self.diffs:
452
+ render_diff_compact(diff, self.console)
453
+
454
+ def set_mode(self, mode: DiffMode) -> None:
455
+ """Set the display mode."""
456
+ self.mode = mode
457
+
458
+
459
+ # ============================================================================
460
+ # TEXTUAL WIDGET-BASED DIFF VIEW WITH SYNCHRONIZED SCROLLING
461
+ # ============================================================================
462
+
463
+
464
+ class DiffScrollPane(ScrollableContainer):
465
+ """Scrollable pane for diff content with scroll synchronization."""
466
+
467
+ DEFAULT_CSS = """
468
+ DiffScrollPane {
469
+ width: 1fr;
470
+ height: 100%;
471
+ background: #000000;
472
+ scrollbar-size: 1 1;
473
+ overflow-x: auto;
474
+ overflow-y: scroll;
475
+ }
476
+ """
477
+
478
+ scroll_link: var[Optional["DiffScrollPane"]] = var(None)
479
+
480
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
481
+ """Synchronize vertical scroll with linked pane."""
482
+ super().watch_scroll_y(old_value, new_value)
483
+ if self.scroll_link and self.scroll_link.scroll_y != new_value:
484
+ self.scroll_link.scroll_y = new_value
485
+
486
+ def watch_scroll_x(self, old_value: float, new_value: float) -> None:
487
+ """Synchronize horizontal scroll with linked pane."""
488
+ super().watch_scroll_x(old_value, new_value)
489
+ if self.scroll_link and self.scroll_link.scroll_x != new_value:
490
+ self.scroll_link.scroll_x = new_value
491
+
492
+
493
+ class DiffLineNumbers(Static):
494
+ """Widget showing line numbers for a diff pane."""
495
+
496
+ DEFAULT_CSS = """
497
+ DiffLineNumbers {
498
+ width: 5;
499
+ height: auto;
500
+ background: #0a0a0a;
501
+ padding: 0;
502
+ }
503
+ """
504
+
505
+ def __init__(self, numbers: List[Optional[int]], styles: List[str], **kwargs):
506
+ super().__init__(**kwargs)
507
+ self.numbers = numbers
508
+ self.styles = styles
509
+
510
+ def render(self) -> Text:
511
+ """Render line numbers with appropriate colors."""
512
+ t = Text()
513
+ for i, (num, style) in enumerate(zip(self.numbers, self.styles)):
514
+ if num is None:
515
+ t.append(" \n", style="#1a1a1a")
516
+ else:
517
+ if style == "+":
518
+ t.append(f"{num:>4} \n", style=f"bold on {DIFF_COLORS['addition_bg']}")
519
+ elif style == "-":
520
+ t.append(f"{num:>4} \n", style=f"bold on {DIFF_COLORS['deletion_bg']}")
521
+ else:
522
+ t.append(f"{num:>4} \n", style=DIFF_COLORS["line_no"])
523
+ return t
524
+
525
+
526
+ class DiffAnnotations(Static):
527
+ """Widget showing +/- annotations for diff lines."""
528
+
529
+ DEFAULT_CSS = """
530
+ DiffAnnotations {
531
+ width: 3;
532
+ height: auto;
533
+ background: #000000;
534
+ padding: 0;
535
+ }
536
+ """
537
+
538
+ def __init__(self, annotations: List[str], **kwargs):
539
+ super().__init__(**kwargs)
540
+ self.annotations = annotations
541
+
542
+ def render(self) -> Text:
543
+ """Render annotations with colors."""
544
+ t = Text()
545
+ for ann in self.annotations:
546
+ if ann == "+":
547
+ t.append(f" {ann} \n", style=f"bold {DIFF_COLORS['addition']}")
548
+ elif ann == "-":
549
+ t.append(f" {ann} \n", style=f"bold {DIFF_COLORS['deletion']}")
550
+ elif ann == "/":
551
+ t.append(" ╲ \n", style="#1a1a1a")
552
+ else:
553
+ t.append(" \n", style="")
554
+ return t
555
+
556
+
557
+ class DiffCodeContent(Static):
558
+ """Widget showing code content for a diff pane."""
559
+
560
+ DEFAULT_CSS = """
561
+ DiffCodeContent {
562
+ width: 1fr;
563
+ height: auto;
564
+ background: #000000;
565
+ padding: 0;
566
+ }
567
+ """
568
+
569
+ def __init__(self, lines: List[Tuple[str, str]], **kwargs):
570
+ """
571
+ Args:
572
+ lines: List of (content, change_type) tuples
573
+ """
574
+ super().__init__(**kwargs)
575
+ self.lines = lines
576
+
577
+ def render(self) -> Text:
578
+ """Render code lines with appropriate styling."""
579
+ t = Text()
580
+ for content, change_type in self.lines:
581
+ if change_type == "+":
582
+ t.append(f"{content}\n", style=f"on {DIFF_COLORS['addition_bg']}")
583
+ elif change_type == "-":
584
+ t.append(f"{content}\n", style=f"on {DIFF_COLORS['deletion_bg']}")
585
+ elif change_type == "/":
586
+ # Hatch pattern for missing lines
587
+ hatch = "╲" * max(1, len(content) if content else 40)
588
+ t.append(f"{hatch}\n", style="#1a1a1a")
589
+ else:
590
+ t.append(f"{content}\n", style="")
591
+ return t
592
+
593
+
594
+ class SplitDiffWidget(Container):
595
+ """
596
+ Interactive split diff view with synchronized scrolling.
597
+
598
+ Features:
599
+ - Side-by-side comparison
600
+ - Synchronized scroll between panes
601
+ - Line annotations with colors (+/-)
602
+ - Auto-detection of split vs unified based on width
603
+ - Toggle between split and unified modes
604
+ """
605
+
606
+ DEFAULT_CSS = """
607
+ SplitDiffWidget {
608
+ width: 100%;
609
+ height: auto;
610
+ min-height: 5;
611
+ max-height: 30;
612
+ background: #000000;
613
+ border: solid #1a1a1a;
614
+ padding: 0;
615
+ }
616
+
617
+ SplitDiffWidget #diff-header {
618
+ height: 2;
619
+ background: #0a0a0a;
620
+ border-bottom: solid #1a1a1a;
621
+ padding: 0 1;
622
+ }
623
+
624
+ SplitDiffWidget #diff-content {
625
+ height: 1fr;
626
+ layout: horizontal;
627
+ }
628
+
629
+ SplitDiffWidget .diff-pane {
630
+ width: 1fr;
631
+ height: 100%;
632
+ layout: horizontal;
633
+ }
634
+
635
+ SplitDiffWidget .diff-pane-left {
636
+ border-right: solid #1a1a1a;
637
+ }
638
+
639
+ SplitDiffWidget #unified-content {
640
+ width: 100%;
641
+ height: 100%;
642
+ }
643
+ """
644
+
645
+ BINDINGS = [
646
+ Binding("d", "toggle_mode", "Toggle split/unified", show=True),
647
+ ]
648
+
649
+ split_mode: reactive[bool] = reactive(True)
650
+ auto_detect: var[bool] = var(True)
651
+
652
+ class ModeToggled(Message):
653
+ """Message when mode is toggled."""
654
+
655
+ def __init__(self, is_split: bool) -> None:
656
+ self.is_split = is_split
657
+ super().__init__()
658
+
659
+ def __init__(self, old_content: str, new_content: str, path: str = "file", **kwargs):
660
+ super().__init__(**kwargs)
661
+ self.old_content = old_content
662
+ self.new_content = new_content
663
+ self.path = path
664
+ self._diff = compute_diff(old_content, new_content, path)
665
+
666
+ def compose(self) -> ComposeResult:
667
+ """Compose the diff widget."""
668
+ # Header
669
+ yield Static(self._render_header(), id="diff-header")
670
+
671
+ # Content area
672
+ with Container(id="diff-content"):
673
+ if self.split_mode:
674
+ yield from self._compose_split()
675
+ else:
676
+ yield from self._compose_unified()
677
+
678
+ def _render_header(self) -> Text:
679
+ """Render the diff header."""
680
+ t = Text()
681
+ t.append("\n", style="")
682
+
683
+ # File icon based on status
684
+ if self._diff.is_new:
685
+ t.append(" ✨ ", style="bold #22c55e")
686
+ status = "New File"
687
+ status_color = "#22c55e"
688
+ elif self._diff.is_deleted:
689
+ t.append(" 🗑️ ", style="bold #ef4444")
690
+ status = "Deleted"
691
+ status_color = "#ef4444"
692
+ else:
693
+ t.append(" 📝 ", style="bold #f97316")
694
+ status = "Modified"
695
+ status_color = "#f97316"
696
+
697
+ t.append(self._diff.path, style="bold white")
698
+ t.append(f" [{status}]", style=f"bold {status_color}")
699
+ t.append(" ", style="")
700
+ t.append(f"+{self._diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
701
+ t.append(" / ", style="#52525b")
702
+ t.append(f"-{self._diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
703
+ t.append(" ", style="")
704
+ mode_text = "split" if self.split_mode else "unified"
705
+ t.append(f"[{mode_text}]", style="#52525b")
706
+ t.append(" ", style="")
707
+ t.append("d", style="bold #a855f7")
708
+ t.append(" toggle", style="#3f3f46")
709
+
710
+ return t
711
+
712
+ def _compose_split(self) -> ComposeResult:
713
+ """Compose split view with synchronized scroll."""
714
+ # Collect lines for old and new content
715
+ old_lines: List[Tuple[Optional[int], str, str]] = []
716
+ new_lines: List[Tuple[Optional[int], str, str]] = []
717
+
718
+ for hunk in self._diff.hunks:
719
+ for line in hunk.lines:
720
+ if line.change_type == "-":
721
+ old_lines.append((line.line_no_old, line.content, "-"))
722
+ elif line.change_type == "+":
723
+ new_lines.append((line.line_no_new, line.content, "+"))
724
+ else:
725
+ old_lines.append((line.line_no_old, line.content, " "))
726
+ new_lines.append((line.line_no_new, line.content, " "))
727
+
728
+ # Pad to same length
729
+ max_len = max(len(old_lines), len(new_lines))
730
+ while len(old_lines) < max_len:
731
+ old_lines.append((None, "", "/"))
732
+ while len(new_lines) < max_len:
733
+ new_lines.append((None, "", "/"))
734
+
735
+ # Build the panes
736
+ old_numbers = [x[0] for x in old_lines]
737
+ old_annotations = [x[2] for x in old_lines]
738
+ old_code = [(x[1], x[2]) for x in old_lines]
739
+
740
+ new_numbers = [x[0] for x in new_lines]
741
+ new_annotations = [x[2] for x in new_lines]
742
+ new_code = [(x[1], x[2]) for x in new_lines]
743
+
744
+ # Left pane (old)
745
+ with DiffScrollPane(id="left-pane", classes="diff-pane diff-pane-left") as left_pane:
746
+ yield DiffLineNumbers(old_numbers, old_annotations, id="left-numbers")
747
+ yield DiffAnnotations(old_annotations, id="left-annotations")
748
+ yield DiffCodeContent(old_code, id="left-code")
749
+
750
+ # Right pane (new)
751
+ with DiffScrollPane(id="right-pane", classes="diff-pane") as right_pane:
752
+ yield DiffLineNumbers(new_numbers, new_annotations, id="right-numbers")
753
+ yield DiffAnnotations(new_annotations, id="right-annotations")
754
+ yield DiffCodeContent(new_code, id="right-code")
755
+
756
+ def _compose_unified(self) -> ComposeResult:
757
+ """Compose unified view."""
758
+ lines: List[Tuple[Optional[int], Optional[int], str, str]] = []
759
+
760
+ for hunk in self._diff.hunks:
761
+ for line in hunk.lines:
762
+ lines.append((line.line_no_old, line.line_no_new, line.content, line.change_type))
763
+
764
+ # Render unified content
765
+ t = Text()
766
+ for old_no, new_no, content, change_type in lines:
767
+ # Line numbers
768
+ old_str = f"{old_no:>4}" if old_no else " "
769
+ new_str = f"{new_no:>4}" if new_no else " "
770
+ t.append(f" {old_str} {new_str} ", style=DIFF_COLORS["line_no"])
771
+
772
+ # Change indicator and content
773
+ if change_type == "+":
774
+ t.append("│", style=DIFF_COLORS["addition"])
775
+ t.append(f" {content}\n", style=f"on {DIFF_COLORS['addition_bg']}")
776
+ elif change_type == "-":
777
+ t.append("│", style=DIFF_COLORS["deletion"])
778
+ t.append(f" {content}\n", style=f"on {DIFF_COLORS['deletion_bg']}")
779
+ else:
780
+ t.append("│", style="#3f3f46")
781
+ t.append(f" {content}\n", style="")
782
+
783
+ with ScrollableContainer(id="unified-content"):
784
+ yield Static(t, id="unified-text")
785
+
786
+ def on_mount(self) -> None:
787
+ """Link the scroll panes after mount."""
788
+ if self.split_mode:
789
+ self._link_scroll_panes()
790
+
791
+ def _link_scroll_panes(self) -> None:
792
+ """Link the two scroll panes for synchronized scrolling."""
793
+ try:
794
+ left = self.query_one("#left-pane", DiffScrollPane)
795
+ right = self.query_one("#right-pane", DiffScrollPane)
796
+ left.scroll_link = right
797
+ right.scroll_link = left
798
+ except Exception:
799
+ pass
800
+
801
+ def watch_split_mode(self, split_mode: bool) -> None:
802
+ """Recompose when mode changes."""
803
+ # Remove old content and recompose
804
+ try:
805
+ content = self.query_one("#diff-content", Container)
806
+ content.remove_children()
807
+ if split_mode:
808
+ for widget in self._compose_split():
809
+ content.mount(widget)
810
+ self._link_scroll_panes()
811
+ else:
812
+ for widget in self._compose_unified():
813
+ content.mount(widget)
814
+ # Update header
815
+ self.query_one("#diff-header", Static).update(self._render_header())
816
+ except Exception:
817
+ pass
818
+
819
+ def action_toggle_mode(self) -> None:
820
+ """Toggle between split and unified mode."""
821
+ self.split_mode = not self.split_mode
822
+ self.post_message(self.ModeToggled(self.split_mode))
823
+
824
+ def on_resize(self, event) -> None:
825
+ """Auto-detect best mode based on width."""
826
+ if self.auto_detect:
827
+ # If terminal is narrow, use unified mode
828
+ if event.size.width < 100:
829
+ if self.split_mode:
830
+ self.split_mode = False
831
+ else:
832
+ if not self.split_mode:
833
+ self.split_mode = True
834
+
835
+
836
+ class UnifiedDiffWidget(Container):
837
+ """Simpler unified diff widget for display in conversation."""
838
+
839
+ DEFAULT_CSS = """
840
+ UnifiedDiffWidget {
841
+ width: 100%;
842
+ height: auto;
843
+ max-height: 25;
844
+ background: #0a0a0a;
845
+ border: solid #1a1a1a;
846
+ padding: 0;
847
+ }
848
+
849
+ UnifiedDiffWidget #unified-header {
850
+ height: 2;
851
+ background: #0a0a0a;
852
+ border-bottom: solid #1a1a1a;
853
+ padding: 0 1;
854
+ }
855
+
856
+ UnifiedDiffWidget #unified-body {
857
+ height: 1fr;
858
+ overflow-y: auto;
859
+ padding: 0;
860
+ }
861
+ """
862
+
863
+ def __init__(self, diff: FileDiff, **kwargs):
864
+ super().__init__(**kwargs)
865
+ self._diff = diff
866
+
867
+ def compose(self) -> ComposeResult:
868
+ """Compose the unified diff widget."""
869
+ yield Static(self._render_header(), id="unified-header")
870
+ with ScrollableContainer(id="unified-body"):
871
+ yield Static(self._render_content(), id="unified-content")
872
+
873
+ def _render_header(self) -> Text:
874
+ """Render header."""
875
+ t = Text()
876
+ t.append("\n", style="")
877
+
878
+ if self._diff.is_new:
879
+ t.append(" ✨ ", style="bold #22c55e")
880
+ elif self._diff.is_deleted:
881
+ t.append(" 🗑️ ", style="bold #ef4444")
882
+ else:
883
+ t.append(" 📝 ", style="bold #f97316")
884
+
885
+ t.append(self._diff.path, style="bold white")
886
+ t.append(" ", style="")
887
+ t.append(f"+{self._diff.additions}", style=f"bold {DIFF_COLORS['addition']}")
888
+ t.append("/", style="#52525b")
889
+ t.append(f"-{self._diff.deletions}", style=f"bold {DIFF_COLORS['deletion']}")
890
+
891
+ return t
892
+
893
+ def _render_content(self) -> Text:
894
+ """Render unified diff content."""
895
+ t = Text()
896
+
897
+ for hunk in self._diff.hunks:
898
+ # Hunk header
899
+ t.append(
900
+ f" @@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@\n",
901
+ style="#06b6d4",
902
+ )
903
+
904
+ for line in hunk.lines:
905
+ old_str = f"{line.line_no_old:>4}" if line.line_no_old else " "
906
+ new_str = f"{line.line_no_new:>4}" if line.line_no_new else " "
907
+ t.append(f" {old_str} {new_str} ", style=DIFF_COLORS["line_no"])
908
+
909
+ if line.change_type == "+":
910
+ t.append("│", style=DIFF_COLORS["addition"])
911
+ t.append(f" {line.content}\n", style=f"on {DIFF_COLORS['addition_bg']}")
912
+ elif line.change_type == "-":
913
+ t.append("│", style=DIFF_COLORS["deletion"])
914
+ t.append(f" {line.content}\n", style=f"on {DIFF_COLORS['deletion_bg']}")
915
+ else:
916
+ t.append("│", style="#3f3f46")
917
+ t.append(f" {line.content}\n", style="")
918
+
919
+ return t