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,550 @@
1
+ """Rich Panel Renderer for Textual TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ from rich.console import Group, RenderableType
11
+ from rich.panel import Panel
12
+ from rich.style import Style
13
+ from rich.syntax import Syntax
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from tunacode.constants import (
18
+ MAX_PANEL_LINE_WIDTH,
19
+ MAX_PANEL_LINES,
20
+ MAX_SEARCH_RESULTS_DISPLAY,
21
+ TOOL_PANEL_WIDTH,
22
+ UI_COLORS,
23
+ )
24
+
25
+
26
+ class PanelType(str, Enum):
27
+ TOOL = "tool"
28
+ ERROR = "error"
29
+ SEARCH = "search"
30
+ INFO = "info"
31
+ SUCCESS = "success"
32
+ WARNING = "warning"
33
+
34
+
35
+ PANEL_STYLES: dict[PanelType, dict[str, str]] = {
36
+ PanelType.TOOL: {
37
+ "border": UI_COLORS["primary"],
38
+ "title": UI_COLORS["primary"],
39
+ "subtitle": UI_COLORS["muted"],
40
+ },
41
+ PanelType.ERROR: {
42
+ "border": UI_COLORS["error"],
43
+ "title": UI_COLORS["error"],
44
+ "subtitle": UI_COLORS["muted"],
45
+ },
46
+ PanelType.SEARCH: {
47
+ "border": UI_COLORS["accent"],
48
+ "title": UI_COLORS["accent"],
49
+ "subtitle": UI_COLORS["muted"],
50
+ },
51
+ PanelType.INFO: {
52
+ "border": UI_COLORS["muted"],
53
+ "title": UI_COLORS["text"],
54
+ "subtitle": UI_COLORS["muted"],
55
+ },
56
+ PanelType.SUCCESS: {
57
+ "border": UI_COLORS["success"],
58
+ "title": UI_COLORS["success"],
59
+ "subtitle": UI_COLORS["muted"],
60
+ },
61
+ PanelType.WARNING: {
62
+ "border": UI_COLORS["warning"],
63
+ "title": UI_COLORS["warning"],
64
+ "subtitle": UI_COLORS["muted"],
65
+ },
66
+ }
67
+
68
+
69
+ @dataclass
70
+ class ToolDisplayData:
71
+ tool_name: str
72
+ status: str
73
+ arguments: dict[str, Any]
74
+ result: str | None = None
75
+ duration_ms: float | None = None
76
+ timestamp: datetime | None = None
77
+
78
+
79
+ @dataclass
80
+ class ErrorDisplayData:
81
+ error_type: str
82
+ message: str
83
+ suggested_fix: str | None = None
84
+ recovery_commands: list[str] | None = None
85
+ context: dict[str, Any] | None = None
86
+ severity: str = "error"
87
+
88
+
89
+ @dataclass
90
+ class SearchResultData:
91
+ query: str
92
+ results: list[dict[str, Any]]
93
+ total_count: int
94
+ current_page: int = 1
95
+ page_size: int = 10
96
+ search_time_ms: float | None = None
97
+ source: str | None = None # "index" or "filesystem" for glob cache status
98
+
99
+
100
+ class RichPanelRenderer:
101
+ @staticmethod
102
+ def render_tool(data: ToolDisplayData) -> RenderableType:
103
+ status_map = {
104
+ "running": (PanelType.TOOL, "..."),
105
+ "completed": (PanelType.SUCCESS, "done"),
106
+ "failed": (PanelType.ERROR, "fail"),
107
+ }
108
+ panel_type, status_suffix = status_map.get(data.status, (PanelType.INFO, data.status))
109
+ styles = PANEL_STYLES[panel_type]
110
+
111
+ content_parts: list[RenderableType] = []
112
+ footer_parts: list[str] = []
113
+
114
+ if data.arguments:
115
+ args_table = Table.grid(padding=(0, 1))
116
+ args_table.add_column(style="dim")
117
+ args_table.add_column()
118
+ for key, value in data.arguments.items():
119
+ display_value = _truncate_value(value, max_length=60)
120
+ args_table.add_row(f"{key}:", display_value)
121
+ content_parts.append(args_table)
122
+
123
+ if data.result:
124
+ truncated_result, shown, total = _truncate_content(data.result)
125
+ result_text = Text()
126
+ result_text.append("\n")
127
+ result_text.append(truncated_result)
128
+ content_parts.append(result_text)
129
+
130
+ # Add line count to footer if truncated
131
+ if shown < total:
132
+ footer_parts.append(f"{shown}/{total} lines")
133
+
134
+ if data.duration_ms is not None:
135
+ footer_parts.append(f"{data.duration_ms:.0f}ms")
136
+
137
+ # Build footer from parts
138
+ if footer_parts:
139
+ footer_text = Text("\n" + " • ".join(footer_parts), style="dim")
140
+ content_parts.append(footer_text)
141
+
142
+ content = Group(*content_parts) if content_parts else Text("...")
143
+
144
+ subtitle = None
145
+ if data.timestamp:
146
+ time_str = data.timestamp.strftime("%H:%M:%S")
147
+ subtitle = f"[{styles['subtitle']}]{time_str}[/]"
148
+
149
+ return Panel(
150
+ content,
151
+ title=f"[{styles['title']}]{data.tool_name}[/] [{status_suffix}]",
152
+ subtitle=subtitle,
153
+ border_style=Style(color=styles["border"]),
154
+ padding=(0, 1),
155
+ expand=True,
156
+ width=TOOL_PANEL_WIDTH,
157
+ )
158
+
159
+ @staticmethod
160
+ def render_diff_tool(
161
+ tool_name: str,
162
+ message: str,
163
+ diff: str,
164
+ args: dict[str, Any] | None = None,
165
+ duration_ms: float | None = None,
166
+ timestamp: datetime | None = None,
167
+ ) -> RenderableType:
168
+ styles = PANEL_STYLES[PanelType.SUCCESS]
169
+
170
+ content_parts: list[RenderableType] = []
171
+
172
+ if args:
173
+ args_table = Table.grid(padding=(0, 1))
174
+ args_table.add_column(style="dim")
175
+ args_table.add_column()
176
+ for key, value in args.items():
177
+ display_value = _truncate_value(value, max_length=60)
178
+ args_table.add_row(f"{key}:", display_value)
179
+ content_parts.append(args_table)
180
+ content_parts.append(Text("\n"))
181
+
182
+ if message:
183
+ content_parts.append(Text(message))
184
+ content_parts.append(Text("\n"))
185
+
186
+ # Use Syntax highlighter for the diff
187
+ diff_syntax = Syntax(diff, "diff", theme="monokai", word_wrap=True)
188
+ content_parts.append(diff_syntax)
189
+
190
+ if duration_ms is not None:
191
+ footer = Text(f"\n{duration_ms:.0f}ms", style="dim")
192
+ content_parts.append(footer)
193
+
194
+ content = Group(*content_parts)
195
+
196
+ subtitle = None
197
+ if timestamp:
198
+ time_str = timestamp.strftime("%H:%M:%S")
199
+ subtitle = f"[{styles['subtitle']}]{time_str}[/]"
200
+
201
+ return Panel(
202
+ content,
203
+ title=f"[{styles['title']}]{tool_name}[/] [done]",
204
+ subtitle=subtitle,
205
+ border_style=Style(color=styles["border"]),
206
+ padding=(0, 1),
207
+ expand=True,
208
+ width=TOOL_PANEL_WIDTH,
209
+ )
210
+
211
+ @staticmethod
212
+ def render_error(data: ErrorDisplayData) -> RenderableType:
213
+ severity_map = {
214
+ "error": PanelType.ERROR,
215
+ "warning": PanelType.WARNING,
216
+ "info": PanelType.INFO,
217
+ }
218
+ panel_type = severity_map.get(data.severity, PanelType.ERROR)
219
+ styles = PANEL_STYLES[panel_type]
220
+
221
+ content_parts: list[RenderableType] = []
222
+
223
+ message_text = Text(data.message, style=styles["title"])
224
+ content_parts.append(message_text)
225
+
226
+ if data.suggested_fix:
227
+ fix_text = Text()
228
+ fix_text.append("\n\nFix: ", style="bold")
229
+ fix_text.append(data.suggested_fix, style=UI_COLORS["success"])
230
+ content_parts.append(fix_text)
231
+
232
+ if data.recovery_commands:
233
+ commands_text = Text("\n\nRecovery:\n", style="bold")
234
+ for cmd in data.recovery_commands:
235
+ commands_text.append(f" {cmd}\n", style=UI_COLORS["primary"])
236
+ content_parts.append(commands_text)
237
+
238
+ if data.context:
239
+ ctx_table = Table.grid(padding=(0, 1))
240
+ ctx_table.add_column(style="dim")
241
+ ctx_table.add_column()
242
+ for key, value in data.context.items():
243
+ ctx_table.add_row(f"{key}:", str(value))
244
+ content_parts.append(Text("\n"))
245
+ content_parts.append(ctx_table)
246
+
247
+ content = Group(*content_parts)
248
+
249
+ return Panel(
250
+ content,
251
+ title=f"[{styles['title']}]{data.error_type}[/]",
252
+ border_style=Style(color=styles["border"]),
253
+ padding=(0, 1),
254
+ expand=True,
255
+ width=TOOL_PANEL_WIDTH,
256
+ )
257
+
258
+ @staticmethod
259
+ def render_search_results(data: SearchResultData) -> RenderableType:
260
+ styles = PANEL_STYLES[PanelType.SEARCH]
261
+ content_parts: list[RenderableType] = []
262
+
263
+ query_text = Text()
264
+ query_text.append("Query: ", style="dim")
265
+ query_text.append(data.query, style="bold")
266
+ content_parts.append(query_text)
267
+
268
+ # Apply search result limiting
269
+ display_results, shown_count, actual_total = _truncate_search_results(data.results)
270
+ total_count = max(data.total_count, actual_total)
271
+
272
+ start_idx = (data.current_page - 1) * data.page_size + 1
273
+ end_idx = min(start_idx + shown_count - 1, total_count)
274
+ total_pages = (total_count + data.page_size - 1) // data.page_size
275
+
276
+ stats_text = Text()
277
+ stats_text.append(f"\nShowing {start_idx}-{end_idx} of {total_count}", style="dim")
278
+ if total_pages > 1:
279
+ stats_text.append(f" (page {data.current_page}/{total_pages})", style="dim")
280
+ content_parts.append(stats_text)
281
+
282
+ if data.search_time_ms is not None:
283
+ time_text = Text(f" in {data.search_time_ms:.0f}ms", style="dim")
284
+ content_parts.append(time_text)
285
+
286
+ content_parts.append(Text("\n"))
287
+
288
+ results_table = Table.grid(padding=(0, 1))
289
+ results_table.add_column(width=3, style="dim")
290
+ results_table.add_column()
291
+
292
+ for i, result in enumerate(display_results, start=start_idx):
293
+ title = result.get("title", result.get("file", result.get("name", "...")))
294
+ snippet = result.get("snippet", result.get("content", ""))
295
+ relevance = result.get("relevance", result.get("score"))
296
+
297
+ result_text = Text()
298
+ result_text.append(str(title), style="bold")
299
+ if relevance is not None:
300
+ result_text.append(f" ({relevance:.0%})", style="dim")
301
+ if snippet:
302
+ result_text.append(f"\n {_truncate_value(snippet, 80)}", style="dim")
303
+
304
+ results_table.add_row(f"{i}.", result_text)
305
+
306
+ # Show "+N more" indicator if truncated
307
+ if shown_count < actual_total:
308
+ more_count = actual_total - shown_count
309
+ results_table.add_row("", Text(f"+{more_count} more results", style="dim italic"))
310
+
311
+ content_parts.append(results_table)
312
+
313
+ content = Group(*content_parts)
314
+
315
+ subtitle = None
316
+ if total_pages > 1:
317
+ subtitle = f"[{styles['subtitle']}]Use arrows to navigate[/]"
318
+ elif data.source == "index":
319
+ subtitle = f"[{UI_COLORS['success']}]Indexed[/]"
320
+ elif data.source == "filesystem":
321
+ subtitle = f"[{styles['subtitle']}]Scanned[/]"
322
+
323
+ return Panel(
324
+ content,
325
+ title=f"[{styles['title']}]Search Results[/]",
326
+ subtitle=subtitle,
327
+ border_style=Style(color=styles["border"]),
328
+ padding=(0, 1),
329
+ expand=True,
330
+ width=TOOL_PANEL_WIDTH,
331
+ )
332
+
333
+ @staticmethod
334
+ def render_info(title: str, content: str | RenderableType) -> RenderableType:
335
+ styles = PANEL_STYLES[PanelType.INFO]
336
+
337
+ if isinstance(content, str):
338
+ content = Text(content)
339
+
340
+ return Panel(
341
+ content,
342
+ title=f"[{styles['title']}]{title}[/]",
343
+ border_style=Style(color=styles["border"]),
344
+ padding=(0, 1),
345
+ expand=True,
346
+ width=TOOL_PANEL_WIDTH,
347
+ )
348
+
349
+ @staticmethod
350
+ def render_success(title: str, message: str) -> RenderableType:
351
+ styles = PANEL_STYLES[PanelType.SUCCESS]
352
+
353
+ return Panel(
354
+ Text(message, style=styles["title"]),
355
+ title=f"[{styles['title']}]{title}[/]",
356
+ border_style=Style(color=styles["border"]),
357
+ padding=(0, 1),
358
+ expand=True,
359
+ width=TOOL_PANEL_WIDTH,
360
+ )
361
+
362
+ @staticmethod
363
+ def render_warning(title: str, message: str) -> RenderableType:
364
+ styles = PANEL_STYLES[PanelType.WARNING]
365
+
366
+ return Panel(
367
+ Text(message, style=styles["title"]),
368
+ title=f"[{styles['title']}]{title}[/]",
369
+ border_style=Style(color=styles["border"]),
370
+ padding=(0, 1),
371
+ expand=True,
372
+ width=TOOL_PANEL_WIDTH,
373
+ )
374
+
375
+
376
+ def _truncate_value(value: Any, max_length: int = 50) -> str:
377
+ str_value = str(value)
378
+ if len(str_value) <= max_length:
379
+ return str_value
380
+ return str_value[: max_length - 3] + "..."
381
+
382
+
383
+ def _truncate_content(
384
+ content: str,
385
+ max_lines: int = MAX_PANEL_LINES,
386
+ max_line_width: int = MAX_PANEL_LINE_WIDTH,
387
+ ) -> tuple[str, int, int]:
388
+ """
389
+ Line-aware truncation preserving structure.
390
+
391
+ Returns: (truncated_content, shown_lines, total_lines)
392
+ """
393
+ lines = content.splitlines()
394
+ total = len(lines)
395
+
396
+ if total <= max_lines:
397
+ # Still truncate individual long lines
398
+ truncated = []
399
+ for line in lines:
400
+ if len(line) > max_line_width:
401
+ truncated.append(line[:max_line_width] + "...")
402
+ else:
403
+ truncated.append(line)
404
+ return "\n".join(truncated), total, total
405
+
406
+ # Take first max_lines, truncate individual lines if too wide
407
+ truncated = []
408
+ for line in lines[:max_lines]:
409
+ if len(line) > max_line_width:
410
+ truncated.append(line[:max_line_width] + "...")
411
+ else:
412
+ truncated.append(line)
413
+
414
+ return "\n".join(truncated), max_lines, total
415
+
416
+
417
+ def _truncate_search_results(
418
+ results: list[dict[str, Any]],
419
+ max_display: int = MAX_SEARCH_RESULTS_DISPLAY,
420
+ ) -> tuple[list[dict[str, Any]], int, int]:
421
+ """
422
+ Truncate search results list with count info.
423
+
424
+ Returns: (truncated_results, shown_count, total_count)
425
+ """
426
+ total = len(results)
427
+ if total <= max_display:
428
+ return results, total, total
429
+ return results[:max_display], max_display, total
430
+
431
+
432
+ def tool_panel(
433
+ name: str,
434
+ status: str,
435
+ args: dict[str, Any] | None = None,
436
+ result: str | None = None,
437
+ duration_ms: float | None = None,
438
+ ) -> RenderableType:
439
+ data = ToolDisplayData(
440
+ tool_name=name,
441
+ status=status,
442
+ arguments=args or {},
443
+ result=result,
444
+ duration_ms=duration_ms,
445
+ timestamp=datetime.now(),
446
+ )
447
+ return RichPanelRenderer.render_tool(data)
448
+
449
+
450
+ def error_panel(
451
+ error_type: str,
452
+ message: str,
453
+ suggested_fix: str | None = None,
454
+ recovery_commands: list[str] | None = None,
455
+ severity: str = "error",
456
+ ) -> RenderableType:
457
+ data = ErrorDisplayData(
458
+ error_type=error_type,
459
+ message=message,
460
+ suggested_fix=suggested_fix,
461
+ recovery_commands=recovery_commands,
462
+ severity=severity,
463
+ )
464
+ return RichPanelRenderer.render_error(data)
465
+
466
+
467
+ def search_panel(
468
+ query: str,
469
+ results: list[dict[str, Any]],
470
+ total_count: int | None = None,
471
+ page: int = 1,
472
+ search_time_ms: float | None = None,
473
+ ) -> RenderableType:
474
+ data = SearchResultData(
475
+ query=query,
476
+ results=results,
477
+ total_count=total_count or len(results),
478
+ current_page=page,
479
+ search_time_ms=search_time_ms,
480
+ )
481
+ return RichPanelRenderer.render_search_results(data)
482
+
483
+
484
+ def tool_panel_smart(
485
+ name: str,
486
+ status: str,
487
+ args: dict[str, Any] | None = None,
488
+ result: str | None = None,
489
+ duration_ms: float | None = None,
490
+ ) -> RenderableType:
491
+ """Route tool output to NeXTSTEP-style renderers.
492
+
493
+ Each tool has a dedicated renderer with 4-zone layout:
494
+ - Header: identifier + summary
495
+ - Parameters: selection context
496
+ - Viewport: primary content
497
+ - Status: metrics, truncation info
498
+ """
499
+ # Only apply custom renderers for completed tools with results
500
+ if status == "completed" and result:
501
+ from tunacode.ui.renderers.tools import (
502
+ render_bash,
503
+ render_glob,
504
+ render_grep,
505
+ render_list_dir,
506
+ render_read_file,
507
+ render_research_codebase,
508
+ render_update_file,
509
+ render_web_fetch,
510
+ )
511
+
512
+ # Map tool names to their renderers
513
+ renderer_map = {
514
+ "list_dir": render_list_dir,
515
+ "grep": render_grep,
516
+ "glob": render_glob,
517
+ "read_file": render_read_file,
518
+ "update_file": render_update_file,
519
+ "bash": render_bash,
520
+ "web_fetch": render_web_fetch,
521
+ "research_codebase": render_research_codebase,
522
+ }
523
+
524
+ renderer = renderer_map.get(name.lower())
525
+ if renderer:
526
+ panel = renderer(args, result, duration_ms)
527
+ if panel:
528
+ return panel
529
+
530
+ # Fallback to generic panel for unsupported tools or failed renders
531
+ return tool_panel(name, status, args, result, duration_ms)
532
+
533
+
534
+ def _try_parse_search_result(
535
+ tool_name: str,
536
+ args: dict[str, Any] | None,
537
+ result: str,
538
+ ) -> SearchResultData | None:
539
+ from tunacode.ui.renderers.search import SearchDisplayRenderer
540
+
541
+ query = None
542
+ if args:
543
+ query = args.get("pattern") or args.get("query")
544
+
545
+ if tool_name.lower() == "grep":
546
+ return SearchDisplayRenderer.parse_grep_output(result, query)
547
+ elif tool_name.lower() == "glob":
548
+ return SearchDisplayRenderer.parse_glob_output(result, query)
549
+
550
+ return None