tunacode-cli 0.1.21__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,294 @@
1
+ """NeXTSTEP-style panel renderer for research_codebase tool output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ from rich.console import Group, RenderableType
12
+ from rich.panel import Panel
13
+ from rich.style import Style
14
+ from rich.text import Text
15
+
16
+ from tunacode.constants import (
17
+ MAX_PANEL_LINE_WIDTH,
18
+ MIN_VIEWPORT_LINES,
19
+ TOOL_PANEL_WIDTH,
20
+ TOOL_VIEWPORT_LINES,
21
+ UI_COLORS,
22
+ )
23
+
24
+ BOX_HORIZONTAL = "\u2500"
25
+ SEPARATOR_WIDTH = 52
26
+
27
+ # Symbolic constants for magic values
28
+ DEFAULT_DIRECTORY = "."
29
+ DEFAULT_MAX_FILES = 3
30
+ ELLIPSIS_LENGTH = 3
31
+ MAX_QUERY_DISPLAY_LENGTH = 60
32
+ MAX_DIRECTORIES_DISPLAY = 3
33
+ MIN_LINES_FOR_RECOMMENDATIONS = 2
34
+ MAX_FALLBACK_RESULT_LENGTH = 500
35
+
36
+
37
+ @dataclass
38
+ class ResearchData:
39
+ """Parsed research_codebase result for structured display."""
40
+
41
+ query: str
42
+ directories: list[str]
43
+ max_files: int
44
+ relevant_files: list[str] = field(default_factory=list)
45
+ key_findings: list[str] = field(default_factory=list)
46
+ code_examples: list[dict[str, str]] = field(default_factory=list)
47
+ recommendations: list[str] = field(default_factory=list)
48
+ is_error: bool = False
49
+ error_message: str | None = None
50
+
51
+
52
+ def _parse_dict_result(result: str) -> dict[str, Any] | None:
53
+ """Parse dict result from string representation."""
54
+ if not result:
55
+ return None
56
+
57
+ # Try direct ast.literal_eval first
58
+ try:
59
+ parsed = ast.literal_eval(result.strip())
60
+ if isinstance(parsed, dict):
61
+ return parsed
62
+ except (ValueError, SyntaxError):
63
+ pass
64
+
65
+ # Try to find dict pattern in the result
66
+ dict_pattern = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", result, re.DOTALL)
67
+ if dict_pattern:
68
+ try:
69
+ parsed = ast.literal_eval(dict_pattern.group())
70
+ if isinstance(parsed, dict):
71
+ return parsed
72
+ except (ValueError, SyntaxError):
73
+ pass
74
+
75
+ return None
76
+
77
+
78
+ def parse_result(args: dict[str, Any] | None, result: str) -> ResearchData | None:
79
+ """Extract structured data from research_codebase output.
80
+
81
+ Expected output format (dict):
82
+ {
83
+ "relevant_files": ["path1", "path2"],
84
+ "key_findings": ["finding1", "finding2"],
85
+ "code_examples": [{"file": "path", "code": "...", "explanation": "..."}],
86
+ "recommendations": ["rec1", "rec2"]
87
+ }
88
+ """
89
+ args = args or {}
90
+ query = args.get("query", "")
91
+ directories = args.get("directories", [DEFAULT_DIRECTORY])
92
+ max_files = args.get("max_files", DEFAULT_MAX_FILES)
93
+
94
+ if isinstance(directories, str):
95
+ directories = [directories]
96
+
97
+ parsed = _parse_dict_result(result)
98
+ if not parsed:
99
+ # Could not parse - return error state with raw result as finding
100
+ return ResearchData(
101
+ query=query,
102
+ directories=directories,
103
+ max_files=max_files,
104
+ key_findings=[result[:MAX_FALLBACK_RESULT_LENGTH] if result else "No results"],
105
+ is_error=True,
106
+ error_message="Could not parse structured result",
107
+ )
108
+
109
+ is_error = parsed.get("error", False)
110
+ error_message = parsed.get("error_message") if is_error else None
111
+
112
+ return ResearchData(
113
+ query=query,
114
+ directories=directories,
115
+ max_files=max_files,
116
+ relevant_files=parsed.get("relevant_files", []),
117
+ key_findings=parsed.get("key_findings", []),
118
+ code_examples=parsed.get("code_examples", []),
119
+ recommendations=parsed.get("recommendations", []),
120
+ is_error=is_error,
121
+ error_message=error_message,
122
+ )
123
+
124
+
125
+ def _truncate_line(line: str) -> str:
126
+ """Truncate a single line if too wide."""
127
+ if len(line) > MAX_PANEL_LINE_WIDTH:
128
+ return line[: MAX_PANEL_LINE_WIDTH - ELLIPSIS_LENGTH] + "..."
129
+ return line
130
+
131
+
132
+ def render_research_codebase(
133
+ args: dict[str, Any] | None,
134
+ result: str,
135
+ duration_ms: float | None = None,
136
+ ) -> RenderableType | None:
137
+ """Render research_codebase with NeXTSTEP zoned layout.
138
+
139
+ Zones:
140
+ - Header: query summary
141
+ - Selection context: directories, max_files
142
+ - Primary viewport: files, findings, recommendations
143
+ - Status: file count, duration
144
+ """
145
+ data = parse_result(args, result)
146
+ if not data:
147
+ return None
148
+
149
+ # Zone 1: Query header
150
+ header = Text()
151
+ header.append("Query: ", style="dim")
152
+ query_too_long = len(data.query) > MAX_QUERY_DISPLAY_LENGTH
153
+ query_display = data.query[:MAX_QUERY_DISPLAY_LENGTH] + "..." if query_too_long else data.query
154
+ header.append(f'"{query_display}"', style="bold")
155
+
156
+ # Zone 2: Parameters
157
+ dirs_display = ", ".join(data.directories[:MAX_DIRECTORIES_DISPLAY])
158
+ if len(data.directories) > MAX_DIRECTORIES_DISPLAY:
159
+ dirs_display += f" (+{len(data.directories) - MAX_DIRECTORIES_DISPLAY})"
160
+
161
+ params = Text()
162
+ params.append("dirs:", style="dim")
163
+ params.append(f" {dirs_display}", style="dim bold")
164
+ params.append(" max_files:", style="dim")
165
+ params.append(f" {data.max_files}", style="dim bold")
166
+
167
+ separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
168
+
169
+ # Zone 3: Primary viewport
170
+ viewport_lines: list[Text] = []
171
+ lines_used = 0
172
+ max_viewport_lines = TOOL_VIEWPORT_LINES
173
+
174
+ # Error state
175
+ if data.is_error:
176
+ error_text = Text()
177
+ error_text.append("Error: ", style="bold red")
178
+ error_text.append(data.error_message or "Unknown error", style="red")
179
+ viewport_lines.append(error_text)
180
+ lines_used += 1
181
+
182
+ # Relevant files section
183
+ if data.relevant_files and lines_used < max_viewport_lines:
184
+ files_header = Text()
185
+ files_header.append("Relevant Files", style="bold")
186
+ files_header.append(f" ({len(data.relevant_files)})", style="dim")
187
+ viewport_lines.append(files_header)
188
+ lines_used += 1
189
+
190
+ for filepath in data.relevant_files:
191
+ if lines_used >= max_viewport_lines:
192
+ break
193
+ file_line = Text()
194
+ file_line.append(" - ", style="dim")
195
+ file_line.append(_truncate_line(filepath), style="cyan")
196
+ viewport_lines.append(file_line)
197
+ lines_used += 1
198
+
199
+ viewport_lines.append(Text(""))
200
+ lines_used += 1
201
+
202
+ # Key findings section
203
+ if data.key_findings and lines_used < max_viewport_lines:
204
+ findings_header = Text()
205
+ findings_header.append("Key Findings", style="bold")
206
+ viewport_lines.append(findings_header)
207
+ lines_used += 1
208
+
209
+ for i, finding in enumerate(data.key_findings, 1):
210
+ if lines_used >= max_viewport_lines:
211
+ remaining = len(data.key_findings) - i + 1
212
+ viewport_lines.append(Text(f" (+{remaining} more)", style="dim italic"))
213
+ lines_used += 1
214
+ break
215
+ finding_line = Text()
216
+ finding_line.append(f" {i}. ", style="dim")
217
+ # Wrap long findings
218
+ finding_text = _truncate_line(finding)
219
+ finding_line.append(finding_text)
220
+ viewport_lines.append(finding_line)
221
+ lines_used += 1
222
+
223
+ viewport_lines.append(Text(""))
224
+ lines_used += 1
225
+
226
+ # Recommendations section (if space)
227
+ if data.recommendations and lines_used < max_viewport_lines - MIN_LINES_FOR_RECOMMENDATIONS:
228
+ rec_header = Text()
229
+ rec_header.append("Recommendations", style="bold")
230
+ viewport_lines.append(rec_header)
231
+ lines_used += 1
232
+
233
+ for rec in data.recommendations:
234
+ if lines_used >= max_viewport_lines:
235
+ break
236
+ rec_line = Text()
237
+ rec_line.append(" > ", style="dim")
238
+ rec_line.append(_truncate_line(rec), style="italic")
239
+ viewport_lines.append(rec_line)
240
+ lines_used += 1
241
+
242
+ # Pad viewport to minimum height for visual consistency
243
+ while lines_used < MIN_VIEWPORT_LINES:
244
+ viewport_lines.append(Text(""))
245
+ lines_used += 1
246
+
247
+ # Combine viewport
248
+ viewport = Text("\n").join(viewport_lines) if viewport_lines else Text("(no findings)")
249
+
250
+ # Zone 4: Status footer
251
+ status_items: list[str] = []
252
+ status_items.append(f"files: {len(data.relevant_files)}")
253
+ status_items.append(f"findings: {len(data.key_findings)}")
254
+ if duration_ms is not None:
255
+ status_items.append(f"{duration_ms:.0f}ms")
256
+
257
+ status = Text(" ".join(status_items), style="dim")
258
+
259
+ # Compose all zones
260
+ content = Group(
261
+ header,
262
+ Text("\n"),
263
+ params,
264
+ Text("\n"),
265
+ separator,
266
+ Text("\n"),
267
+ viewport,
268
+ Text("\n"),
269
+ separator,
270
+ Text("\n"),
271
+ status,
272
+ )
273
+
274
+ timestamp = datetime.now().strftime("%H:%M:%S")
275
+
276
+ # Use error styling if error occurred
277
+ if data.is_error:
278
+ border_color = UI_COLORS["error"]
279
+ title_color = UI_COLORS["error"]
280
+ status_suffix = "error"
281
+ else:
282
+ border_color = UI_COLORS["accent"]
283
+ title_color = UI_COLORS["accent"]
284
+ status_suffix = "done"
285
+
286
+ return Panel(
287
+ content,
288
+ title=f"[{title_color}]research_codebase[/] [{status_suffix}]",
289
+ subtitle=f"[{UI_COLORS['muted']}]{timestamp}[/]",
290
+ border_style=Style(color=border_color),
291
+ padding=(0, 1),
292
+ expand=True,
293
+ width=TOOL_PANEL_WIDTH,
294
+ )
@@ -0,0 +1,237 @@
1
+ """NeXTSTEP-style panel renderer for update_file tool output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rich.console import Group, RenderableType
12
+ from rich.panel import Panel
13
+ from rich.style import Style
14
+ from rich.syntax import Syntax
15
+ from rich.text import Text
16
+
17
+ from tunacode.constants import (
18
+ MAX_PANEL_LINE_WIDTH,
19
+ MIN_VIEWPORT_LINES,
20
+ TOOL_PANEL_WIDTH,
21
+ TOOL_VIEWPORT_LINES,
22
+ UI_COLORS,
23
+ )
24
+
25
+ BOX_HORIZONTAL = "\u2500"
26
+ SEPARATOR_WIDTH = 52
27
+ LINE_TRUNCATION_SUFFIX: str = "..."
28
+
29
+
30
+ @dataclass
31
+ class UpdateFileData:
32
+ """Parsed update_file result for structured display."""
33
+
34
+ filepath: str
35
+ filename: str
36
+ message: str
37
+ diff_content: str
38
+ additions: int
39
+ deletions: int
40
+ hunks: int
41
+ diagnostics_block: str | None = None
42
+
43
+
44
+ def parse_result(args: dict[str, Any] | None, result: str) -> UpdateFileData | None:
45
+ """Extract structured data from update_file output.
46
+
47
+ Expected format:
48
+ File 'path/to/file.py' updated successfully.
49
+
50
+ --- a/path/to/file.py
51
+ +++ b/path/to/file.py
52
+ @@ -10,5 +10,7 @@
53
+ ...diff content...
54
+
55
+ <file_diagnostics>
56
+ Error (line 10): type mismatch
57
+ </file_diagnostics>
58
+ """
59
+ if not result:
60
+ return None
61
+
62
+ # Extract diagnostics block before parsing diff
63
+ from tunacode.ui.renderers.tools.diagnostics import extract_diagnostics_from_result
64
+
65
+ result_clean, diagnostics_block = extract_diagnostics_from_result(result)
66
+
67
+ # Split message from diff
68
+ if "\n--- a/" not in result_clean:
69
+ return None
70
+
71
+ parts = result_clean.split("\n--- a/", 1)
72
+ message = parts[0].strip()
73
+ diff_content = "--- a/" + parts[1]
74
+
75
+ # Extract filepath from diff header
76
+ filepath_match = re.search(r"--- a/(.+)", diff_content)
77
+ if not filepath_match:
78
+ # Try from args
79
+ args = args or {}
80
+ filepath = args.get("filepath", "unknown")
81
+ else:
82
+ filepath = filepath_match.group(1).strip()
83
+
84
+ # Count additions and deletions
85
+ additions = 0
86
+ deletions = 0
87
+ hunks = 0
88
+
89
+ for line in diff_content.splitlines():
90
+ if line.startswith("+") and not line.startswith("+++"):
91
+ additions += 1
92
+ elif line.startswith("-") and not line.startswith("---"):
93
+ deletions += 1
94
+ elif line.startswith("@@"):
95
+ hunks += 1
96
+
97
+ return UpdateFileData(
98
+ filepath=filepath,
99
+ filename=Path(filepath).name,
100
+ message=message,
101
+ diff_content=diff_content,
102
+ additions=additions,
103
+ deletions=deletions,
104
+ hunks=hunks,
105
+ diagnostics_block=diagnostics_block,
106
+ )
107
+
108
+
109
+ def _truncate_line_width(line: str, max_line_width: int) -> str:
110
+ if len(line) <= max_line_width:
111
+ return line
112
+ line_prefix = line[:max_line_width]
113
+ return f"{line_prefix}{LINE_TRUNCATION_SUFFIX}"
114
+
115
+
116
+ def _truncate_diff(diff: str) -> tuple[str, int, int]:
117
+ """Truncate diff content, return (truncated, shown, total)."""
118
+ lines = diff.splitlines()
119
+ total = len(lines)
120
+ max_content = TOOL_VIEWPORT_LINES
121
+ max_line_width = MAX_PANEL_LINE_WIDTH
122
+
123
+ capped_lines = [_truncate_line_width(line, max_line_width) for line in lines[:max_content]]
124
+
125
+ if total <= max_content:
126
+ return "\n".join(capped_lines), total, total
127
+ return "\n".join(capped_lines), max_content, total
128
+
129
+
130
+ def render_update_file(
131
+ args: dict[str, Any] | None,
132
+ result: str,
133
+ duration_ms: float | None = None,
134
+ ) -> RenderableType | None:
135
+ """Render update_file with NeXTSTEP zoned layout.
136
+
137
+ Zones:
138
+ - Header: filename + change summary
139
+ - Selection context: full filepath
140
+ - Primary viewport: syntax-highlighted diff
141
+ - Status: hunks, truncation info, duration
142
+ """
143
+ data = parse_result(args, result)
144
+ if not data:
145
+ return None
146
+
147
+ # Zone 1: Filename + change stats
148
+ header = Text()
149
+ header.append(data.filename, style="bold")
150
+ header.append(" ", style="")
151
+ header.append(f"+{data.additions}", style="green")
152
+ header.append(" ", style="")
153
+ header.append(f"-{data.deletions}", style="red")
154
+
155
+ # Zone 2: Full filepath
156
+ params = Text()
157
+ params.append("path:", style="dim")
158
+ params.append(f" {data.filepath}", style="dim bold")
159
+
160
+ separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
161
+
162
+ # Zone 3: Diff viewport with syntax highlighting
163
+ truncated_diff, shown, total = _truncate_diff(data.diff_content)
164
+
165
+ # Pad viewport to minimum height for visual consistency
166
+ diff_lines = truncated_diff.split("\n")
167
+ while len(diff_lines) < MIN_VIEWPORT_LINES:
168
+ diff_lines.append("")
169
+ truncated_diff = "\n".join(diff_lines)
170
+
171
+ diff_syntax = Syntax(truncated_diff, "diff", theme="monokai", word_wrap=True)
172
+
173
+ # Zone 4: Status
174
+ status_items: list[str] = []
175
+
176
+ hunk_word = "hunk" if data.hunks == 1 else "hunks"
177
+ status_items.append(f"{data.hunks} {hunk_word}")
178
+
179
+ if shown < total:
180
+ status_items.append(f"[{shown}/{total} lines]")
181
+
182
+ if duration_ms is not None:
183
+ status_items.append(f"{duration_ms:.0f}ms")
184
+
185
+ status = Text(" ".join(status_items), style="dim") if status_items else Text("")
186
+
187
+ # Zone 5: Diagnostics (if present)
188
+ diagnostics_content: RenderableType | None = None
189
+ if data.diagnostics_block:
190
+ from tunacode.ui.renderers.tools.diagnostics import (
191
+ parse_diagnostics_block,
192
+ render_diagnostics_inline,
193
+ )
194
+
195
+ diag_data = parse_diagnostics_block(data.diagnostics_block)
196
+ if diag_data and diag_data.items:
197
+ diagnostics_content = render_diagnostics_inline(diag_data)
198
+
199
+ # Compose
200
+ content_parts: list[RenderableType] = [
201
+ header,
202
+ Text("\n"),
203
+ params,
204
+ Text("\n"),
205
+ separator,
206
+ Text("\n"),
207
+ diff_syntax,
208
+ Text("\n"),
209
+ separator,
210
+ Text("\n"),
211
+ status,
212
+ ]
213
+
214
+ # Add diagnostics zone if present
215
+ if diagnostics_content:
216
+ content_parts.extend(
217
+ [
218
+ Text("\n"),
219
+ separator,
220
+ Text("\n"),
221
+ diagnostics_content,
222
+ ]
223
+ )
224
+
225
+ content = Group(*content_parts)
226
+
227
+ timestamp = datetime.now().strftime("%H:%M:%S")
228
+
229
+ return Panel(
230
+ content,
231
+ title=f"[{UI_COLORS['success']}]update_file[/] [done]",
232
+ subtitle=f"[{UI_COLORS['muted']}]{timestamp}[/]",
233
+ border_style=Style(color=UI_COLORS["success"]),
234
+ padding=(0, 1),
235
+ expand=True,
236
+ width=TOOL_PANEL_WIDTH,
237
+ )
@@ -0,0 +1,182 @@
1
+ """NeXTSTEP-style panel renderer for web_fetch tool output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from rich.console import Group, RenderableType
11
+ from rich.panel import Panel
12
+ from rich.style import Style
13
+ from rich.text import Text
14
+
15
+ from tunacode.constants import (
16
+ MAX_PANEL_LINE_WIDTH,
17
+ MIN_VIEWPORT_LINES,
18
+ TOOL_PANEL_WIDTH,
19
+ TOOL_VIEWPORT_LINES,
20
+ UI_COLORS,
21
+ URL_DISPLAY_MAX_LENGTH,
22
+ )
23
+
24
+ BOX_HORIZONTAL = "\u2500"
25
+ SEPARATOR_WIDTH = 52
26
+
27
+
28
+ @dataclass
29
+ class WebFetchData:
30
+ """Parsed web_fetch result for structured display."""
31
+
32
+ url: str
33
+ domain: str
34
+ content: str
35
+ content_lines: int
36
+ is_truncated: bool
37
+ timeout: int
38
+
39
+
40
+ def parse_result(args: dict[str, Any] | None, result: str) -> WebFetchData | None:
41
+ """Extract structured data from web_fetch output.
42
+
43
+ The result is simply the text content from the fetched page.
44
+ """
45
+ if not result:
46
+ return None
47
+
48
+ args = args or {}
49
+ url = args.get("url", "")
50
+ timeout = args.get("timeout", 60)
51
+
52
+ # Extract domain from URL
53
+ domain = ""
54
+ if url:
55
+ try:
56
+ parsed = urlparse(url)
57
+ domain = parsed.netloc or parsed.hostname or ""
58
+ except Exception:
59
+ domain = url[:30]
60
+
61
+ # Check for truncation
62
+ is_truncated = "[Content truncated due to size]" in result
63
+
64
+ content_lines = len(result.splitlines())
65
+
66
+ return WebFetchData(
67
+ url=url,
68
+ domain=domain,
69
+ content=result,
70
+ content_lines=content_lines,
71
+ is_truncated=is_truncated,
72
+ timeout=timeout,
73
+ )
74
+
75
+
76
+ def _truncate_line(line: str) -> str:
77
+ """Truncate a single line if too wide."""
78
+ if len(line) > MAX_PANEL_LINE_WIDTH:
79
+ return line[: MAX_PANEL_LINE_WIDTH - 3] + "..."
80
+ return line
81
+
82
+
83
+ def _truncate_content(content: str) -> tuple[str, int, int]:
84
+ """Truncate content, return (truncated, shown, total)."""
85
+ lines = content.splitlines()
86
+ total = len(lines)
87
+
88
+ max_lines = TOOL_VIEWPORT_LINES
89
+
90
+ if total <= max_lines:
91
+ return "\n".join(_truncate_line(ln) for ln in lines), total, total
92
+
93
+ truncated = [_truncate_line(ln) for ln in lines[:max_lines]]
94
+ return "\n".join(truncated), max_lines, total
95
+
96
+
97
+ def render_web_fetch(
98
+ args: dict[str, Any] | None,
99
+ result: str,
100
+ duration_ms: float | None = None,
101
+ ) -> RenderableType | None:
102
+ """Render web_fetch with NeXTSTEP zoned layout.
103
+
104
+ Zones:
105
+ - Header: domain + content summary
106
+ - Selection context: full URL, timeout
107
+ - Primary viewport: content preview
108
+ - Status: truncation info, duration
109
+ """
110
+ data = parse_result(args, result)
111
+ if not data:
112
+ return None
113
+
114
+ # Zone 1: Domain + content summary
115
+ header = Text()
116
+ header.append(data.domain or "web", style="bold")
117
+ header.append(f" {data.content_lines} lines", style="dim")
118
+
119
+ # Zone 2: Full URL + parameters
120
+ params = Text()
121
+ url_display = data.url
122
+ if len(url_display) > URL_DISPLAY_MAX_LENGTH:
123
+ url_display = url_display[: URL_DISPLAY_MAX_LENGTH - 3] + "..."
124
+ params.append("url:", style="dim")
125
+ params.append(f" {url_display}", style="dim bold")
126
+ params.append("\n", style="")
127
+ params.append("timeout:", style="dim")
128
+ params.append(f" {data.timeout}s", style="dim bold")
129
+
130
+ separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
131
+
132
+ # Zone 3: Content viewport
133
+ truncated_content, shown, total = _truncate_content(data.content)
134
+
135
+ # Pad viewport to minimum height for visual consistency
136
+ content_lines = truncated_content.split("\n")
137
+ while len(content_lines) < MIN_VIEWPORT_LINES:
138
+ content_lines.append("")
139
+ truncated_content = "\n".join(content_lines)
140
+
141
+ viewport = Text(truncated_content)
142
+
143
+ # Zone 4: Status
144
+ status_items: list[str] = []
145
+
146
+ if data.is_truncated:
147
+ status_items.append("(content truncated)")
148
+
149
+ if shown < total:
150
+ status_items.append(f"[{shown}/{total} lines]")
151
+
152
+ if duration_ms is not None:
153
+ status_items.append(f"{duration_ms:.0f}ms")
154
+
155
+ status = Text(" ".join(status_items), style="dim") if status_items else Text("")
156
+
157
+ # Compose
158
+ content = Group(
159
+ header,
160
+ Text("\n"),
161
+ params,
162
+ Text("\n"),
163
+ separator,
164
+ Text("\n"),
165
+ viewport,
166
+ Text("\n"),
167
+ separator,
168
+ Text("\n"),
169
+ status,
170
+ )
171
+
172
+ timestamp = datetime.now().strftime("%H:%M:%S")
173
+
174
+ return Panel(
175
+ content,
176
+ title=f"[{UI_COLORS['success']}]web_fetch[/] [done]",
177
+ subtitle=f"[{UI_COLORS['muted']}]{timestamp}[/]",
178
+ border_style=Style(color=UI_COLORS["success"]),
179
+ padding=(0, 1),
180
+ expand=True,
181
+ width=TOOL_PANEL_WIDTH,
182
+ )