yycode 0.3.2__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. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/tui/runner.py ADDED
@@ -0,0 +1,439 @@
1
+ """Async bridge between Session and the TUI state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import subprocess
7
+ from argparse import Namespace
8
+ from pathlib import Path
9
+ from typing import Awaitable, Callable, Optional
10
+
11
+ from agent.approval import ApprovalRequest
12
+ from agent.cancellation import CancellationController, CancelResult
13
+ from agent.change_snapshot import (
14
+ build_changed_files_snapshot,
15
+ changed_files_as_dicts,
16
+ extract_diff_text,
17
+ merge_changed_paths,
18
+ turn_had_successful_write,
19
+ )
20
+ from agent.message_context_manager import MessageContextSummary
21
+ from agent.session import Session
22
+ from agent.streaming import StreamEvent
23
+ from agent.tui.commands import CommandRegistry
24
+ from agent.tui.commands.base import CommandContext, CommandResult
25
+ from tools.git_diff import git_diff
26
+
27
+ from .approval import TuiApprovalAdapter
28
+ from .state import GitHeader, TuiState
29
+
30
+
31
+ StateChangeCallback = Callable[[StreamEvent], Awaitable[None]]
32
+
33
+
34
+ async def auto_approval_callback(_request: ApprovalRequest) -> bool:
35
+ """Approve all requests immediately."""
36
+ return True
37
+
38
+
39
+ class AgentTuiRunner:
40
+ """Own the session lifecycle and feed stream events into TUI state."""
41
+
42
+ def __init__(
43
+ self,
44
+ args: Namespace,
45
+ *,
46
+ state: Optional[TuiState] = None,
47
+ on_state_change: Optional[StateChangeCallback] = None,
48
+ ) -> None:
49
+ self.args = args
50
+ self.state = state or TuiState()
51
+ self.on_state_change = on_state_change
52
+ self.approval_adapter = TuiApprovalAdapter()
53
+ self.session: Session | None = None
54
+ self.cancel_controller = CancellationController()
55
+
56
+ @property
57
+ def current_task(self) -> asyncio.Task | None:
58
+ """Return the active task for backwards compatibility."""
59
+ return self.cancel_controller.current_task
60
+
61
+ async def start(self) -> None:
62
+ """Create the underlying agent session."""
63
+ silent_mode = bool(getattr(self.args, "auto", False))
64
+ approval_callback = auto_approval_callback if silent_mode else self.approval_adapter.callback
65
+ self.session = Session.from_config(
66
+ workdir=getattr(self.args, "workdir", None),
67
+ approval_callback=approval_callback,
68
+ session_id=getattr(self.args, "session_id", None),
69
+ persist_messages=not bool(getattr(self.args, "temp", False)),
70
+ resume=bool(getattr(self.args, "session_id", None)),
71
+ )
72
+ self.session.stream_callback = self.handle_stream_event
73
+ self.state.set_startup_info(
74
+ session_id=self.session.id,
75
+ model_name=getattr(self.session.provider, "model", "(unknown)"),
76
+ skills_text=self._skills_text(),
77
+ workspace_path=str(self.session.workdir),
78
+ context_window_tokens=self.session.context_window_tokens,
79
+ restored_message_count=self.session.restored_message_count,
80
+ auto_mode=silent_mode,
81
+ todo_manager=self.session.todo_manager,
82
+ )
83
+ await self.refresh_message_context_header()
84
+ await self.refresh_git_header()
85
+ if warning := getattr(self.session, "_session_persistence_warning", None):
86
+ event = StreamEvent(
87
+ source="tui",
88
+ session_id=self.session.id,
89
+ event_type="session_warning",
90
+ title="Session persistence disabled",
91
+ content=f"Session history is memory-only for this run: {warning}",
92
+ status="warning",
93
+ phase="planning",
94
+ )
95
+ self.state.apply_event(event)
96
+ if self.on_state_change is not None:
97
+ await self.on_state_change(event)
98
+
99
+ async def close(self) -> None:
100
+ """Close the session and cancel in-flight work."""
101
+ await self.cancel_current_task()
102
+ if self.session is not None:
103
+ await self.session.close()
104
+ self.session = None
105
+
106
+ async def submit(self, text: str) -> None:
107
+ """Run one user request."""
108
+ await self.submit_nowait(text)
109
+ if self.current_task is not None:
110
+ await self.current_task
111
+
112
+ async def submit_nowait(self, text: str) -> None:
113
+ """Record user input immediately and run the request in the background."""
114
+ if self.session is None:
115
+ raise RuntimeError("TUI runner has not been started")
116
+ if self.current_task and not self.current_task.done():
117
+ raise RuntimeError("A task is already running")
118
+ self.state.add_user_input(self.session.id, text)
119
+ if self.on_state_change is not None:
120
+ await self.on_state_change(
121
+ StreamEvent(
122
+ source="user",
123
+ session_id=self.session.id,
124
+ event_type="user_message",
125
+ content=text,
126
+ title="You",
127
+ detail=text,
128
+ phase="planning",
129
+ )
130
+ )
131
+ thinking_event = StreamEvent(
132
+ source="main",
133
+ session_id=self.session.id,
134
+ event_type="agent_thinking",
135
+ title="Task running",
136
+ detail=text,
137
+ phase="executing",
138
+ status="running",
139
+ metadata={"intent": text},
140
+ )
141
+ self.state.apply_event(thinking_event)
142
+ if self.on_state_change is not None:
143
+ await self.on_state_change(thinking_event)
144
+ self.cancel_controller.set_task(asyncio.create_task(self._send_current(text)))
145
+
146
+ async def _send_current(self, text: str) -> None:
147
+ start_index = self._latest_user_message_index()
148
+ try:
149
+ if self.session is None:
150
+ return
151
+ response = await self.session.send(text)
152
+ await self._emit_final_response_if_missing(response, start_index)
153
+ finally:
154
+ await self._emit_changed_files_summary(start_index)
155
+ # 任务完成时移除临时状态项并结束任务跟踪
156
+ if self.session is not None:
157
+ await self.refresh_message_context_header()
158
+ await self.refresh_git_header()
159
+ self.state._remove_transient_items()
160
+ self.state.end_active_task()
161
+ if self.on_state_change is not None:
162
+ # 创建一个空事件来触发 UI 刷新
163
+ await self.on_state_change(
164
+ StreamEvent(
165
+ source="main",
166
+ session_id=self.session.id if self.session else "",
167
+ event_type="task_finished",
168
+ title="Task finished",
169
+ )
170
+ )
171
+ self.cancel_controller.clear_task(asyncio.current_task())
172
+
173
+ async def _emit_final_response_if_missing(self, response, start_index: int) -> None:
174
+ """Show final assistant content when the provider did not stream text deltas."""
175
+ if self.session is None or response is None:
176
+ return
177
+ content = getattr(response, "content", "")
178
+ if not isinstance(content, str) or not content.strip():
179
+ return
180
+ streamed_text = any(
181
+ item.event_type == "text_delta"
182
+ and item.session_id == self.session.id
183
+ and bool(item.content.strip())
184
+ for item in self.state.timeline[start_index:]
185
+ )
186
+ if streamed_text:
187
+ return
188
+ event = StreamEvent(
189
+ source="main",
190
+ session_id=self.session.id,
191
+ event_type="text_delta",
192
+ content=content,
193
+ title="Assistant response",
194
+ phase="responding",
195
+ status="completed",
196
+ )
197
+ self.state.apply_event(event)
198
+ if self.on_state_change is not None:
199
+ await self.on_state_change(event)
200
+
201
+ async def _emit_changed_files_summary(self, start_index: int) -> None:
202
+ """Show one end-of-task file summary when the turn changed files."""
203
+ if self.session is None:
204
+ return
205
+ turn_items = self.state.timeline[start_index:]
206
+ diffs = [
207
+ extract_diff_text(item.content)
208
+ for item in turn_items
209
+ if (
210
+ item.event_type == "tool_result"
211
+ and not item.metadata.get("approval_preview")
212
+ and extract_diff_text(item.content)
213
+ )
214
+ ]
215
+ if not diffs:
216
+ changed_paths = merge_changed_paths(turn_items)
217
+ if changed_paths or turn_had_successful_write(turn_items):
218
+ final_diff = git_diff(paths=changed_paths or None)
219
+ if final_diff and not final_diff.startswith(("Error:", "No diff.")):
220
+ diffs.append(final_diff)
221
+ if not diffs:
222
+ return
223
+ diff_text = "\n".join(diffs)
224
+ snapshot = build_changed_files_snapshot(diff_text, source="task")
225
+ changed_files = changed_files_as_dicts(snapshot)
226
+ if not changed_files:
227
+ return
228
+ self.state.set_changed_file_diffs(changed_files)
229
+ event = StreamEvent(
230
+ source="main",
231
+ session_id=self.session.id,
232
+ event_type="files_changed_summary",
233
+ content=diff_text,
234
+ title="Files changed",
235
+ phase="summarizing",
236
+ status="completed",
237
+ metadata={"files": changed_files},
238
+ )
239
+ self.state.apply_event(event)
240
+ if self.on_state_change is not None:
241
+ await self.on_state_change(event)
242
+
243
+ async def cancel_current_task_result(self) -> CancelResult:
244
+ """Cancel the active request and return a stable result."""
245
+ self.approval_adapter.cancel_pending()
246
+ return await self.cancel_controller.cancel()
247
+
248
+ async def cancel_current_task(self) -> bool:
249
+ """Cancel the active request, if any."""
250
+ return (await self.cancel_current_task_result()).status == "cancelled"
251
+
252
+ async def handle_stream_event(self, event: StreamEvent) -> None:
253
+ """Update state and forward the event to the UI."""
254
+ self.state.apply_event(event)
255
+ if event.event_type in {"context_compressed", "context_summarized"}:
256
+ await self.refresh_message_context_header()
257
+ if self.on_state_change is not None:
258
+ await self.on_state_change(event)
259
+
260
+ def resolve_approval(self, approval_id: str, approved: bool) -> bool:
261
+ """Resolve a pending approval decision."""
262
+ return self.approval_adapter.resolve(approval_id, approved)
263
+
264
+ async def analyze_message_context(self) -> MessageContextSummary:
265
+ """Return current message token summary."""
266
+ if self.session is None:
267
+ raise RuntimeError("TUI runner has not been started")
268
+ return await self.session.analyze_message_context()
269
+
270
+ async def refresh_message_context_header(self) -> MessageContextSummary | None:
271
+ """Refresh top-panel message count and context token summary."""
272
+ if self.session is None:
273
+ return None
274
+ message_count = len(getattr(self.session, "messages", []) or [])
275
+ self.state.update_message_context_header(message_count=message_count, refreshing=True)
276
+ if not hasattr(self.session, "analyze_message_context"):
277
+ self.state.update_message_context_header(message_count=message_count, refreshing=False)
278
+ return None
279
+ summary = await self.session.analyze_message_context()
280
+ self.state.update_message_context_header(message_count=message_count, summary=summary, refreshing=False)
281
+ return summary
282
+
283
+ async def refresh_git_header(self) -> GitHeader:
284
+ """Refresh compact git branch/dirty status for the top panel."""
285
+ workdir = getattr(self.session, "workdir", None) if self.session is not None else None
286
+ header = await asyncio.to_thread(_read_git_header, workdir)
287
+ self.state.update_git_header(branch=header.branch, dirty=header.dirty, available=header.available)
288
+ return header
289
+
290
+ async def compress_message_context(self, indexes: list[int]) -> int:
291
+ """Compress selected old tool outputs and refresh state."""
292
+ if self.session is None:
293
+ raise RuntimeError("TUI runner has not been started")
294
+ self._message_context_backup = list(self.session.messages)
295
+ compressed = await self.session.compress_message_context(indexes)
296
+ if compressed <= 0:
297
+ self._message_context_backup = None
298
+ await self.refresh_message_context_header()
299
+ return compressed
300
+
301
+ async def undo_message_context_compression(self) -> bool:
302
+ """Restore the most recent message context compression backup."""
303
+ if self.session is None:
304
+ raise RuntimeError("TUI runner has not been started")
305
+ backup = getattr(self, "_message_context_backup", None)
306
+ if not backup:
307
+ return False
308
+ self.session.messages = list(backup)
309
+ self.session._save_messages()
310
+ self._message_context_backup = None
311
+ await self.refresh_message_context_header()
312
+ return True
313
+
314
+ async def execute_command(
315
+ self,
316
+ command_line: str,
317
+ registry: CommandRegistry,
318
+ *,
319
+ emit_result: bool = True,
320
+ ) -> CommandResult:
321
+ """Execute a TUI-only command without sending it to the LLM."""
322
+ if self.session is None:
323
+ raise RuntimeError("TUI runner has not been started")
324
+ parsed = registry.parse(command_line)
325
+ if parsed is None:
326
+ result = CommandResult(
327
+ title="Unknown command",
328
+ content=f"Unknown command: {command_line.strip()}. Try :help.",
329
+ severity="warning",
330
+ status="warning",
331
+ )
332
+ if emit_result:
333
+ await self.emit_command_result(command_line, result)
334
+ return result
335
+ if parsed.command.destructive and self.current_task is not None and not self.current_task.done():
336
+ result = CommandResult(
337
+ title="Command blocked",
338
+ content=f"Cannot run :{parsed.command.name} while a task is running.",
339
+ severity="warning",
340
+ status="warning",
341
+ )
342
+ if emit_result:
343
+ await self.emit_command_result(command_line, result)
344
+ return result
345
+ ctx = CommandContext(
346
+ runner=self,
347
+ registry=registry,
348
+ confirmed=parsed.confirmed,
349
+ raw_text=parsed.raw_text,
350
+ )
351
+ result = await parsed.command.execute(ctx, parsed.args)
352
+ if emit_result:
353
+ await self.emit_command_result(parsed.raw_text, result)
354
+ return result
355
+
356
+ async def clear_session_history(self) -> None:
357
+ """Clear session messages and reset the TUI transcript view."""
358
+ if self.session is None:
359
+ raise RuntimeError("TUI runner has not been started")
360
+ if self.current_task is not None and not self.current_task.done():
361
+ raise RuntimeError("Cannot clear while a task is running")
362
+ self.session.clear()
363
+ self.state.clear_session_view()
364
+ await self.refresh_message_context_header()
365
+
366
+ async def emit_command_result(self, command_line: str, result: CommandResult) -> None:
367
+ """Append a local command result to the timeline."""
368
+ session_id = self.session.id if self.session is not None else self.state.session_id
369
+ event = StreamEvent(
370
+ source="tui",
371
+ session_id=session_id,
372
+ event_type="command_result",
373
+ title=result.title,
374
+ detail=command_line.strip(),
375
+ content=result.content,
376
+ status=result.status,
377
+ phase="planning",
378
+ metadata={"command": command_line.strip(), "severity": result.severity},
379
+ )
380
+ self.state.apply_event(event)
381
+ if self.on_state_change is not None:
382
+ await self.on_state_change(event)
383
+
384
+ def _skills_text(self) -> str:
385
+ if self.session is None:
386
+ return "(none)"
387
+ skill_names = [skill.name for skill in self.session.skill_registry.list_skills()]
388
+ return ", ".join(skill_names) if skill_names else "(none)"
389
+
390
+ def _latest_user_message_index(self) -> int:
391
+ for index in range(len(self.state.timeline) - 1, -1, -1):
392
+ if self.state.timeline[index].event_type == "user_message":
393
+ return index
394
+ return 0
395
+
396
+
397
+ def _read_git_header(workdir: Path | str | None) -> GitHeader:
398
+ """Read git branch and dirty state for a workspace."""
399
+ if workdir is None:
400
+ return GitHeader()
401
+ cwd = Path(workdir)
402
+ try:
403
+ branch = subprocess.run(
404
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
405
+ cwd=str(cwd),
406
+ capture_output=True,
407
+ text=True,
408
+ errors="backslashreplace",
409
+ timeout=2,
410
+ check=True,
411
+ ).stdout.strip()
412
+ if branch == "HEAD":
413
+ commit = subprocess.run(
414
+ ["git", "rev-parse", "--short", "HEAD"],
415
+ cwd=str(cwd),
416
+ capture_output=True,
417
+ text=True,
418
+ errors="backslashreplace",
419
+ timeout=2,
420
+ check=True,
421
+ ).stdout.strip()
422
+ branch = f"detached:{commit}" if commit else "detached"
423
+ status = subprocess.run(
424
+ ["git", "status", "--porcelain"],
425
+ cwd=str(cwd),
426
+ capture_output=True,
427
+ text=True,
428
+ errors="backslashreplace",
429
+ timeout=2,
430
+ check=True,
431
+ ).stdout
432
+ except (OSError, subprocess.SubprocessError):
433
+ return GitHeader()
434
+ return GitHeader(branch=branch, dirty=bool(status.strip()), available=bool(branch))
435
+
436
+
437
+ def _changed_files_from_diff(diff: str) -> list[dict]:
438
+ """Return per-file stats and diff sections from a unified diff blob."""
439
+ return changed_files_as_dicts(build_changed_files_snapshot(diff, source="diff"))