zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,190 @@
1
+ """Tool renderers for approval widgets - registry pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from deepagents_code.file_ops import build_approval_preview, format_display_path
9
+ from deepagents_code.tui.widgets.tool_widgets import (
10
+ EditFileApprovalWidget,
11
+ GenericApprovalWidget,
12
+ WriteFileApprovalWidget,
13
+ format_display_content,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from deepagents_code.tui.widgets.tool_widgets import ToolApprovalWidget
18
+
19
+
20
+ class ToolRenderer:
21
+ """Strategy for building a tool's HITL approval widget.
22
+
23
+ Each renderer maps a tool name to a `(widget_class, data)` pair that
24
+ controls what the user sees in the approval box. Tools not registered
25
+ in `_RENDERER_REGISTRY` fall through to the default, which dumps all
26
+ args as `key: value` lines via `GenericApprovalWidget`.
27
+ """
28
+
29
+ @staticmethod
30
+ def get_approval_widget(
31
+ tool_args: dict[str, Any],
32
+ assistant_id: str | None = None, # noqa: ARG004
33
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
34
+ """Get the approval widget class and data for this tool.
35
+
36
+ Args:
37
+ tool_args: The tool arguments from action_request.
38
+ assistant_id: Optional assistant identifier for resolving virtual paths.
39
+
40
+ Returns:
41
+ Tuple of (widget_class, data_dict)
42
+ """
43
+ return GenericApprovalWidget, tool_args
44
+
45
+
46
+ class WriteFileRenderer(ToolRenderer):
47
+ """Renderer for write_file tool - shows full file content."""
48
+
49
+ @staticmethod
50
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
51
+ tool_args: dict[str, Any],
52
+ assistant_id: str | None = None, # noqa: ARG004
53
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
54
+ # Extract file extension for syntax highlighting
55
+ file_path = tool_args.get("file_path", "")
56
+ content = format_display_content(tool_args.get("content", ""))
57
+
58
+ # Get file extension
59
+ file_extension = "text"
60
+ if "." in file_path:
61
+ file_extension = file_path.rsplit(".", 1)[-1]
62
+
63
+ data = {
64
+ "file_path": file_path,
65
+ "content": content,
66
+ "file_extension": file_extension,
67
+ }
68
+ return WriteFileApprovalWidget, data
69
+
70
+
71
+ class TaskRenderer(ToolRenderer):
72
+ """Renderer for task tool — interrupt description provides full context."""
73
+
74
+ @staticmethod
75
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
76
+ tool_args: dict[str, Any], # noqa: ARG004 # Unused; interrupt description already formats task args
77
+ assistant_id: str | None = None, # noqa: ARG004
78
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
79
+ return GenericApprovalWidget, {}
80
+
81
+
82
+ class DeleteFileRenderer(ToolRenderer):
83
+ """Renderer for delete tool - shows removed file content when available."""
84
+
85
+ @staticmethod
86
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
87
+ tool_args: dict[str, Any],
88
+ assistant_id: str | None = None,
89
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
90
+ path = str(tool_args.get("file_path") or tool_args.get("path") or "")
91
+ preview = build_approval_preview(
92
+ "delete", {"file_path": path}, assistant_id=assistant_id
93
+ )
94
+ if preview is None:
95
+ # `build_approval_preview` always returns a preview for "delete";
96
+ # this guards its `ApprovalPreview | None` contract defensively.
97
+ return GenericApprovalWidget, tool_args
98
+ if preview.diff:
99
+ return EditFileApprovalWidget, {
100
+ "file_path": format_display_path(path),
101
+ "diff_lines": preview.diff.splitlines(),
102
+ "old_string": "",
103
+ "new_string": "",
104
+ }
105
+ data: dict[str, Any] = {"file_path": format_display_path(path)}
106
+ details = [
107
+ detail for detail in preview.details if not detail.startswith("File:")
108
+ ]
109
+ if details:
110
+ data["details"] = "\n".join(details)
111
+ if preview.error:
112
+ data["error"] = preview.error
113
+ return GenericApprovalWidget, data
114
+
115
+
116
+ class EditFileRenderer(ToolRenderer):
117
+ """Renderer for edit_file tool - shows unified diff."""
118
+
119
+ @staticmethod
120
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
121
+ tool_args: dict[str, Any],
122
+ assistant_id: str | None = None, # noqa: ARG004
123
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
124
+ file_path = tool_args.get("file_path", "")
125
+ old_string = format_display_content(tool_args.get("old_string", ""))
126
+ new_string = format_display_content(tool_args.get("new_string", ""))
127
+
128
+ # Generate unified diff
129
+ diff_lines = EditFileRenderer._generate_diff(old_string, new_string)
130
+
131
+ data = {
132
+ "file_path": file_path,
133
+ "diff_lines": diff_lines,
134
+ "old_string": old_string,
135
+ "new_string": new_string,
136
+ }
137
+ return EditFileApprovalWidget, data
138
+
139
+ @staticmethod
140
+ def _generate_diff(old_string: str, new_string: str) -> list[str]:
141
+ """Generate unified diff lines from old and new strings.
142
+
143
+ Returns:
144
+ List of diff lines without the file headers.
145
+ """
146
+ if not old_string and not new_string:
147
+ return []
148
+
149
+ old_lines = old_string.split("\n") if old_string else []
150
+ new_lines = new_string.split("\n") if new_string else []
151
+
152
+ # Generate unified diff
153
+ diff = difflib.unified_diff(
154
+ old_lines,
155
+ new_lines,
156
+ fromfile="before",
157
+ tofile="after",
158
+ lineterm="",
159
+ n=3, # Context lines
160
+ )
161
+
162
+ # Skip the first two header lines (--- and +++)
163
+ diff_list = list(diff)
164
+ return diff_list[2:] if len(diff_list) > 2 else diff_list # noqa: PLR2004 # Column count threshold
165
+
166
+
167
+ _RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = {
168
+ "task": TaskRenderer,
169
+ "write_file": WriteFileRenderer,
170
+ "edit_file": EditFileRenderer,
171
+ "delete": DeleteFileRenderer,
172
+ }
173
+ """Registry mapping tool names to renderers
174
+
175
+ Note: bash/shell/execute use minimal approval (no renderer) — see
176
+ ApprovalMenu._MINIMAL_TOOLS
177
+ """
178
+
179
+
180
+ def get_renderer(tool_name: str) -> ToolRenderer:
181
+ """Get the renderer for a tool by name.
182
+
183
+ Args:
184
+ tool_name: The name of the tool
185
+
186
+ Returns:
187
+ The appropriate ToolRenderer instance
188
+ """
189
+ renderer_class = _RENDERER_REGISTRY.get(tool_name, ToolRenderer)
190
+ return renderer_class()
@@ -0,0 +1,304 @@
1
+ """Tool-specific approval widgets for HITL display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from textual.containers import Vertical
9
+ from textual.content import Content
10
+ from textual.widgets import Markdown, Static
11
+
12
+ from deepagents_code import theme
13
+ from deepagents_code.file_ops import is_sensitive_file_path
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.app import ComposeResult
17
+
18
+ _CREDENTIAL_NOTICE = "Contents hidden — file may contain credentials"
19
+
20
+ # Constants for display limits
21
+ _MAX_VALUE_LEN = 200
22
+ _MAX_LINES = 30
23
+ _MAX_DIFF_LINES = 50
24
+ _MAX_PREVIEW_LINES = 20
25
+
26
+
27
+ def format_display_content(content: object) -> str:
28
+ """Coerce arbitrary tool-arg content into a displayable string.
29
+
30
+ Strings pass through unchanged; other values are JSON-formatted for
31
+ readability, falling back to `str()` when serialization fails.
32
+
33
+ Returns:
34
+ A string safe to render in an approval widget.
35
+ """
36
+ if isinstance(content, str):
37
+ return content
38
+ try:
39
+ return json.dumps(content, ensure_ascii=False, indent=2)
40
+ except (TypeError, ValueError, RecursionError):
41
+ return str(content)
42
+
43
+
44
+ def _format_stats(additions: int, deletions: int) -> Content:
45
+ """Format addition/deletion stats as styled Content.
46
+
47
+ Args:
48
+ additions: Number of added lines.
49
+ deletions: Number of removed lines.
50
+
51
+ Returns:
52
+ Styled Content showing additions and deletions.
53
+ """
54
+ colors = theme.get_theme_colors()
55
+ parts: list[str | tuple[str, str] | Content] = []
56
+ if additions:
57
+ parts.append((f"+{additions}", colors.success))
58
+ if deletions:
59
+ if parts:
60
+ parts.append(" ")
61
+ parts.append((f"-{deletions}", colors.error))
62
+ return Content.assemble(*parts) if parts else Content("")
63
+
64
+
65
+ def _file_header(
66
+ file_path: str, additions: int = 0, deletions: int = 0
67
+ ) -> ComposeResult:
68
+ """Yield the `File:` path header with optional `+N -M` stats.
69
+
70
+ Args:
71
+ file_path: Path to the file being modified.
72
+ additions: Number of added lines.
73
+ deletions: Number of removed lines.
74
+
75
+ Yields:
76
+ Static widgets for the file path header and a spacer line.
77
+ """
78
+ stats = _format_stats(additions, deletions)
79
+ yield Static(
80
+ Content.assemble(
81
+ Content.from_markup("[bold cyan]File:[/bold cyan] $path ", path=file_path),
82
+ stats,
83
+ )
84
+ )
85
+ yield Static("")
86
+
87
+
88
+ def _count_diff_stats(
89
+ diff_lines: list[str], old_string: str, new_string: str
90
+ ) -> tuple[int, int]:
91
+ """Count additions and deletions from diff data.
92
+
93
+ Args:
94
+ diff_lines: Unified diff output lines.
95
+ old_string: Original text being replaced (fallback when no diff).
96
+ new_string: Replacement text (fallback when no diff).
97
+
98
+ Returns:
99
+ Tuple of (additions count, deletions count).
100
+ """
101
+ if diff_lines:
102
+ additions = sum(
103
+ 1
104
+ for line in diff_lines
105
+ if line.startswith("+") and not line.startswith("+++")
106
+ )
107
+ deletions = sum(
108
+ 1
109
+ for line in diff_lines
110
+ if line.startswith("-") and not line.startswith("---")
111
+ )
112
+ else:
113
+ additions = new_string.count("\n") + 1 if new_string else 0
114
+ deletions = old_string.count("\n") + 1 if old_string else 0
115
+ return additions, deletions
116
+
117
+
118
+ class ToolApprovalWidget(Vertical):
119
+ """Base class for tool approval widgets."""
120
+
121
+ def __init__(self, data: dict[str, Any]) -> None:
122
+ """Initialize the tool approval widget with data."""
123
+ super().__init__(classes="tool-approval-widget")
124
+ self.data = data
125
+
126
+ def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
127
+ """Default compose - override in subclasses.
128
+
129
+ Yields:
130
+ Static widget with placeholder message.
131
+ """
132
+ yield Static("Tool details not available", classes="approval-description")
133
+
134
+
135
+ class GenericApprovalWidget(ToolApprovalWidget):
136
+ """Generic approval widget for unknown tools."""
137
+
138
+ def compose(self) -> ComposeResult:
139
+ """Compose the generic tool display.
140
+
141
+ Yields:
142
+ Static widgets displaying each key-value pair from tool data.
143
+ """
144
+ for key, value in self.data.items():
145
+ if value is None:
146
+ continue
147
+ value_str = str(value)
148
+ if len(value_str) > _MAX_VALUE_LEN:
149
+ hidden = len(value_str) - _MAX_VALUE_LEN
150
+ value_str = value_str[:_MAX_VALUE_LEN] + f"... ({hidden} more chars)"
151
+ yield Static(
152
+ f"{key}: {value_str}", markup=False, classes="approval-description"
153
+ )
154
+
155
+
156
+ class WriteFileApprovalWidget(ToolApprovalWidget):
157
+ """Approval widget for write_file - shows file content with syntax highlighting."""
158
+
159
+ def compose(self) -> ComposeResult:
160
+ """Compose the file content display with syntax highlighting.
161
+
162
+ Yields:
163
+ Widgets displaying file path header and syntax-highlighted content.
164
+ """
165
+ file_path = self.data.get("file_path", "")
166
+ content = format_display_content(self.data.get("content", ""))
167
+ file_extension = self.data.get("file_extension", "text")
168
+
169
+ # Never render the contents of credential files (e.g. `.env`).
170
+ if is_sensitive_file_path(file_path):
171
+ yield from _file_header(file_path)
172
+ yield Static(Content.styled(_CREDENTIAL_NOTICE, "dim"))
173
+ else:
174
+ # Content with syntax highlighting via Markdown code block
175
+ lines = content.split("\n")
176
+ total_lines = len(lines)
177
+
178
+ # File header with line count
179
+ yield from _file_header(file_path, additions=total_lines if content else 0)
180
+
181
+ if total_lines > _MAX_LINES:
182
+ # Truncate for display
183
+ shown_lines = lines[:_MAX_LINES]
184
+ remaining = total_lines - _MAX_LINES
185
+ truncated_content = (
186
+ "\n".join(shown_lines) + f"\n... ({remaining} more lines)"
187
+ )
188
+ yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
189
+ else:
190
+ yield Markdown(f"```{file_extension}\n{content}\n```")
191
+
192
+
193
+ class EditFileApprovalWidget(ToolApprovalWidget):
194
+ """Approval widget for edit_file - shows clean diff with colors."""
195
+
196
+ def compose(self) -> ComposeResult:
197
+ """Compose the diff display with colored additions and deletions.
198
+
199
+ Yields:
200
+ Widgets displaying file path, stats, and colored diff lines.
201
+ """
202
+ file_path = self.data.get("file_path", "")
203
+ diff_lines = self.data.get("diff_lines", [])
204
+ old_string = format_display_content(self.data.get("old_string", ""))
205
+ new_string = format_display_content(self.data.get("new_string", ""))
206
+
207
+ additions, deletions = _count_diff_stats(diff_lines, old_string, new_string)
208
+ yield from _file_header(file_path, additions, deletions)
209
+
210
+ # Never render the diff of credential files (e.g. `.env`); the stats
211
+ # above still convey that a change happened without exposing content.
212
+ if is_sensitive_file_path(file_path):
213
+ yield Static(Content.styled(_CREDENTIAL_NOTICE, "dim"))
214
+ elif not diff_lines and not old_string and not new_string:
215
+ yield Static("No changes to display", classes="approval-description")
216
+ elif diff_lines:
217
+ # Render content
218
+ yield from self._render_diff_lines_only(diff_lines)
219
+ else:
220
+ yield from self._render_strings_only(old_string, new_string)
221
+
222
+ def _render_diff_lines_only(self, diff_lines: list[str]) -> ComposeResult:
223
+ """Render unified diff lines without returning stats.
224
+
225
+ Yields:
226
+ Static widgets for each diff line with appropriate styling.
227
+ """
228
+ lines_shown = 0
229
+
230
+ for line in diff_lines:
231
+ if lines_shown >= _MAX_DIFF_LINES:
232
+ yield Static(
233
+ Content.styled(
234
+ f"... ({len(diff_lines) - lines_shown} more lines)", "dim"
235
+ )
236
+ )
237
+ break
238
+
239
+ if line.startswith(("@@", "---", "+++")):
240
+ continue
241
+
242
+ widget = self._render_diff_line(line)
243
+ if widget:
244
+ yield widget
245
+ lines_shown += 1
246
+
247
+ def _render_strings_only(self, old_string: str, new_string: str) -> ComposeResult:
248
+ """Render old/new strings without returning stats.
249
+
250
+ Yields:
251
+ Static widgets showing removed and added content with styling.
252
+ """
253
+ colors = theme.get_theme_colors()
254
+ if old_string:
255
+ yield Static(Content.styled("Removing:", f"bold {colors.error}"))
256
+ yield from self._render_string_lines(old_string, is_addition=False)
257
+ yield Static("")
258
+
259
+ if new_string:
260
+ yield Static(Content.styled("Adding:", f"bold {colors.success}"))
261
+ yield from self._render_string_lines(new_string, is_addition=True)
262
+
263
+ @staticmethod
264
+ def _render_diff_line(line: str) -> Static | None:
265
+ """Render a single diff line with appropriate styling.
266
+
267
+ Returns:
268
+ Static widget with styled diff line, or None for empty/skipped lines.
269
+ """
270
+ raw = line[1:] if len(line) > 1 else ""
271
+
272
+ if line.startswith("-"):
273
+ return Static(
274
+ Content.from_markup("- $text", text=raw), classes="diff-removed"
275
+ )
276
+ if line.startswith("+"):
277
+ return Static(
278
+ Content.from_markup("+ $text", text=raw), classes="diff-added"
279
+ )
280
+ if line.startswith(" "):
281
+ return Static(
282
+ Content.from_markup(" $text", text=raw), classes="diff-context"
283
+ )
284
+ if line.strip():
285
+ return Static(line, markup=False)
286
+ return None
287
+
288
+ @staticmethod
289
+ def _render_string_lines(text: str, *, is_addition: bool) -> ComposeResult:
290
+ """Render lines from a string with appropriate styling.
291
+
292
+ Yields:
293
+ Static widgets for each line with addition or deletion styling.
294
+ """
295
+ lines = text.split("\n")
296
+ sign = "+" if is_addition else "-"
297
+ cls = "diff-added" if is_addition else "diff-removed"
298
+
299
+ for line in lines[:_MAX_PREVIEW_LINES]:
300
+ yield Static(Content.from_markup(f"{sign} $text", text=line), classes=cls)
301
+
302
+ if len(lines) > _MAX_PREVIEW_LINES:
303
+ remaining = len(lines) - _MAX_PREVIEW_LINES
304
+ yield Static(Content.styled(f"... ({remaining} more lines)", "dim"))