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,314 @@
1
+ """Search Result Display for Textual TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, TypeVar
9
+
10
+ from rich.console import 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 TOOL_PANEL_WIDTH, UI_COLORS
16
+ from tunacode.ui.renderers.panels import RichPanelRenderer, SearchResultData
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ def _paginate(items: list[T], page: int, page_size: int) -> tuple[list[T], int, int]:
22
+ start_idx = (page - 1) * page_size
23
+ end_idx = start_idx + page_size
24
+ total_pages = (len(items) + page_size - 1) // page_size if items else 1
25
+ return items[start_idx:end_idx], start_idx, total_pages
26
+
27
+
28
+ @dataclass
29
+ class FileSearchResult:
30
+ file_path: str
31
+ line_number: int | None = None
32
+ content: str = ""
33
+ match_start: int | None = None
34
+ match_end: int | None = None
35
+ relevance: float | None = None
36
+
37
+
38
+ @dataclass
39
+ class CodeSearchResult:
40
+ file_path: str
41
+ symbol_name: str
42
+ symbol_type: str
43
+ line_number: int
44
+ context: str = ""
45
+ relevance: float | None = None
46
+
47
+
48
+ class SearchDisplayRenderer:
49
+ @staticmethod
50
+ def render_file_results(
51
+ query: str,
52
+ results: list[FileSearchResult],
53
+ page: int = 1,
54
+ page_size: int = 10,
55
+ search_time_ms: float | None = None,
56
+ ) -> RenderableType:
57
+ generic_results = []
58
+ for r in results:
59
+ result_dict: dict[str, Any] = {
60
+ "file": r.file_path,
61
+ "content": r.content,
62
+ }
63
+ if r.line_number is not None:
64
+ result_dict["title"] = f"{r.file_path}:{r.line_number}"
65
+ else:
66
+ result_dict["title"] = r.file_path
67
+
68
+ if r.relevance is not None:
69
+ result_dict["relevance"] = r.relevance
70
+
71
+ if r.match_start is not None and r.match_end is not None:
72
+ before = r.content[: r.match_start]
73
+ match = r.content[r.match_start : r.match_end]
74
+ after = r.content[r.match_end :]
75
+ result_dict["snippet"] = f"{before}[{match}]{after}"
76
+ else:
77
+ result_dict["snippet"] = r.content
78
+
79
+ generic_results.append(result_dict)
80
+
81
+ page_results, _, _ = _paginate(generic_results, page, page_size)
82
+
83
+ data = SearchResultData(
84
+ query=query,
85
+ results=page_results,
86
+ total_count=len(results),
87
+ current_page=page,
88
+ page_size=page_size,
89
+ search_time_ms=search_time_ms,
90
+ )
91
+
92
+ return RichPanelRenderer.render_search_results(data)
93
+
94
+ @staticmethod
95
+ def render_code_results(
96
+ query: str,
97
+ results: list[CodeSearchResult],
98
+ page: int = 1,
99
+ page_size: int = 10,
100
+ search_time_ms: float | None = None,
101
+ ) -> RenderableType:
102
+ generic_results = []
103
+ for r in results:
104
+ type_icons = {
105
+ "function": "fn",
106
+ "class": "cls",
107
+ "variable": "var",
108
+ "method": "mtd",
109
+ "constant": "const",
110
+ }
111
+ type_label = type_icons.get(r.symbol_type, r.symbol_type[:3])
112
+
113
+ result_dict: dict[str, Any] = {
114
+ "title": f"[{type_label}] {r.symbol_name}",
115
+ "file": f"{r.file_path}:{r.line_number}",
116
+ "snippet": r.context,
117
+ }
118
+ if r.relevance is not None:
119
+ result_dict["relevance"] = r.relevance
120
+
121
+ generic_results.append(result_dict)
122
+
123
+ page_results, _, _ = _paginate(generic_results, page, page_size)
124
+
125
+ data = SearchResultData(
126
+ query=query,
127
+ results=page_results,
128
+ total_count=len(results),
129
+ current_page=page,
130
+ page_size=page_size,
131
+ search_time_ms=search_time_ms,
132
+ )
133
+
134
+ return RichPanelRenderer.render_search_results(data)
135
+
136
+ @staticmethod
137
+ def render_inline_results(
138
+ results: list[dict[str, Any]],
139
+ max_display: int = 5,
140
+ ) -> RenderableType:
141
+ if not results:
142
+ return Text("No results found", style="dim")
143
+
144
+ content = Text()
145
+ display_results = results[:max_display]
146
+
147
+ for i, result in enumerate(display_results):
148
+ title = result.get("title", result.get("file", "..."))
149
+ content.append(f"{i + 1}. ", style="dim")
150
+ content.append(str(title), style=UI_COLORS["primary"])
151
+ content.append("\n")
152
+
153
+ if len(results) > max_display:
154
+ remaining = len(results) - max_display
155
+ content.append(f" ...and {remaining} more", style="dim")
156
+
157
+ return content
158
+
159
+ @staticmethod
160
+ def render_empty_results(query: str) -> RenderableType:
161
+ content = Text()
162
+ content.append("No results found for: ", style="dim")
163
+ content.append(query, style="bold")
164
+ content.append("\n\nSuggestions:\n", style="dim")
165
+ content.append(" - Try a different search term\n", style="dim")
166
+ content.append(" - Use broader patterns\n", style="dim")
167
+ content.append(" - Check file paths and extensions", style="dim")
168
+
169
+ return Panel(
170
+ content,
171
+ title=f"[{UI_COLORS['warning']}]No Results[/]",
172
+ border_style=Style(color=UI_COLORS["warning"]),
173
+ padding=(0, 1),
174
+ expand=True,
175
+ width=TOOL_PANEL_WIDTH,
176
+ )
177
+
178
+ @staticmethod
179
+ def parse_grep_output(text: str, query: str | None = None) -> SearchResultData | None:
180
+ if not text or "Found" not in text:
181
+ return None
182
+
183
+ header_match = re.match(r"Found (\d+) match(?:es)? for pattern: (.+)", text)
184
+ if not header_match:
185
+ return None
186
+
187
+ total_count = int(header_match.group(1))
188
+ detected_query = header_match.group(2).strip()
189
+ final_query = query or detected_query
190
+
191
+ results: list[dict[str, Any]] = []
192
+ file_pattern = re.compile(r"📁 (.+?):(\d+)")
193
+ match_pattern = re.compile(r"▶\s*(\d+)│\s*(.*?)⟨(.+?)⟩(.*)")
194
+
195
+ current_file: str | None = None
196
+
197
+ for line in text.split("\n"):
198
+ file_match = file_pattern.search(line)
199
+ if file_match:
200
+ current_file = file_match.group(1)
201
+ continue
202
+
203
+ match_line = match_pattern.search(line)
204
+ if match_line and current_file:
205
+ line_num = int(match_line.group(1))
206
+ before = match_line.group(2)
207
+ match_text = match_line.group(3)
208
+ after = match_line.group(4)
209
+
210
+ results.append(
211
+ {
212
+ "title": f"{current_file}:{line_num}",
213
+ "file": current_file,
214
+ "snippet": f"{before}[{match_text}]{after}",
215
+ "line_number": line_num,
216
+ }
217
+ )
218
+
219
+ if not results:
220
+ return None
221
+
222
+ return SearchResultData(
223
+ query=final_query,
224
+ results=results,
225
+ total_count=total_count,
226
+ current_page=1,
227
+ page_size=len(results),
228
+ )
229
+
230
+ @staticmethod
231
+ def parse_glob_output(text: str, pattern: str | None = None) -> SearchResultData | None:
232
+ if not text:
233
+ return None
234
+
235
+ # Extract source marker if present
236
+ source: str | None = None
237
+ lines = text.split("\n")
238
+ if lines and lines[0].startswith("[source:"):
239
+ marker = lines[0]
240
+ source = marker[8:-1] # Extract between "[source:" and "]"
241
+ text = "\n".join(lines[1:]) # Remove marker from text
242
+
243
+ if "Found" not in text:
244
+ return None
245
+
246
+ header_match = re.match(r"Found (\d+) files? matching pattern: (.+)", text)
247
+ if not header_match:
248
+ return None
249
+
250
+ total_count = int(header_match.group(1))
251
+ detected_pattern = header_match.group(2).strip()
252
+ final_pattern = pattern or detected_pattern
253
+
254
+ results: list[dict[str, Any]] = []
255
+
256
+ # Parse file paths - supports both plain paths and formatted output
257
+ for line in text.split("\n"):
258
+ line = line.strip()
259
+ # Skip header line, empty lines, and truncation notice
260
+ if not line or line.startswith("Found ") or line.startswith("(truncated"):
261
+ continue
262
+
263
+ # Handle plain file paths (new format)
264
+ if line.startswith("/") or line.startswith("./") or "/" in line:
265
+ path = Path(line)
266
+ results.append(
267
+ {
268
+ "title": line,
269
+ "file": line,
270
+ "snippet": path.name,
271
+ }
272
+ )
273
+
274
+ if not results:
275
+ return None
276
+
277
+ return SearchResultData(
278
+ query=final_pattern,
279
+ results=results,
280
+ total_count=total_count,
281
+ current_page=1,
282
+ page_size=len(results),
283
+ source=source,
284
+ )
285
+
286
+
287
+ def file_search_panel(
288
+ query: str,
289
+ results: list[FileSearchResult],
290
+ page: int = 1,
291
+ search_time_ms: float | None = None,
292
+ ) -> RenderableType:
293
+ if not results:
294
+ return SearchDisplayRenderer.render_empty_results(query)
295
+ return SearchDisplayRenderer.render_file_results(
296
+ query, results, page, search_time_ms=search_time_ms
297
+ )
298
+
299
+
300
+ def code_search_panel(
301
+ query: str,
302
+ results: list[CodeSearchResult],
303
+ page: int = 1,
304
+ search_time_ms: float | None = None,
305
+ ) -> RenderableType:
306
+ if not results:
307
+ return SearchDisplayRenderer.render_empty_results(query)
308
+ return SearchDisplayRenderer.render_code_results(
309
+ query, results, page, search_time_ms=search_time_ms
310
+ )
311
+
312
+
313
+ def quick_results(results: list[dict[str, Any]], max_display: int = 5) -> RenderableType:
314
+ return SearchDisplayRenderer.render_inline_results(results, max_display)
@@ -0,0 +1,21 @@
1
+ """Tool-specific panel renderers following NeXTSTEP UI principles."""
2
+
3
+ from tunacode.ui.renderers.tools.bash import render_bash
4
+ from tunacode.ui.renderers.tools.glob import render_glob
5
+ from tunacode.ui.renderers.tools.grep import render_grep
6
+ from tunacode.ui.renderers.tools.list_dir import render_list_dir
7
+ from tunacode.ui.renderers.tools.read_file import render_read_file
8
+ from tunacode.ui.renderers.tools.research import render_research_codebase
9
+ from tunacode.ui.renderers.tools.update_file import render_update_file
10
+ from tunacode.ui.renderers.tools.web_fetch import render_web_fetch
11
+
12
+ __all__ = [
13
+ "render_bash",
14
+ "render_glob",
15
+ "render_grep",
16
+ "render_list_dir",
17
+ "render_read_file",
18
+ "render_research_codebase",
19
+ "render_update_file",
20
+ "render_web_fetch",
21
+ ]
@@ -0,0 +1,247 @@
1
+ """NeXTSTEP-style panel renderer for bash tool output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
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.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
+ )
22
+
23
+ BOX_HORIZONTAL = "\u2500"
24
+ SEPARATOR_WIDTH = 52
25
+
26
+
27
+ @dataclass
28
+ class BashData:
29
+ """Parsed bash result for structured display."""
30
+
31
+ command: str
32
+ exit_code: int
33
+ working_dir: str
34
+ stdout: str
35
+ stderr: str
36
+ is_truncated: bool
37
+
38
+
39
+ def parse_result(args: dict[str, Any] | None, result: str) -> BashData | None:
40
+ """Extract structured data from bash output.
41
+
42
+ Expected format:
43
+ Command: <command>
44
+ Exit Code: <code>
45
+ Working Directory: <path>
46
+
47
+ STDOUT:
48
+ <output>
49
+
50
+ STDERR:
51
+ <errors>
52
+ """
53
+ if not result:
54
+ return None
55
+
56
+ # Parse structured output
57
+ command_match = re.search(r"Command: (.+)", result)
58
+ exit_match = re.search(r"Exit Code: (\d+)", result)
59
+ cwd_match = re.search(r"Working Directory: (.+)", result)
60
+
61
+ if not command_match or not exit_match:
62
+ return None
63
+
64
+ command = command_match.group(1).strip()
65
+ exit_code = int(exit_match.group(1))
66
+ working_dir = cwd_match.group(1).strip() if cwd_match else "."
67
+
68
+ # Extract stdout and stderr
69
+ stdout = ""
70
+ stderr = ""
71
+
72
+ stdout_match = re.search(r"STDOUT:\n(.*?)(?=\n\nSTDERR:|\Z)", result, re.DOTALL)
73
+ if stdout_match:
74
+ stdout = stdout_match.group(1).strip()
75
+ if stdout == "(no output)":
76
+ stdout = ""
77
+
78
+ stderr_match = re.search(r"STDERR:\n(.*?)(?:\Z)", result, re.DOTALL)
79
+ if stderr_match:
80
+ stderr = stderr_match.group(1).strip()
81
+ if stderr == "(no errors)":
82
+ stderr = ""
83
+
84
+ # Check for truncation marker
85
+ is_truncated = "[truncated]" in result
86
+
87
+ return BashData(
88
+ command=command,
89
+ exit_code=exit_code,
90
+ working_dir=working_dir,
91
+ stdout=stdout,
92
+ stderr=stderr,
93
+ is_truncated=is_truncated,
94
+ )
95
+
96
+
97
+ def _truncate_line(line: str) -> str:
98
+ """Truncate a single line if too wide."""
99
+ if len(line) > MAX_PANEL_LINE_WIDTH:
100
+ return line[: MAX_PANEL_LINE_WIDTH - 3] + "..."
101
+ return line
102
+
103
+
104
+ def _truncate_output(output: str) -> tuple[str, int, int]:
105
+ """Truncate output, return (truncated, shown, total)."""
106
+ lines = output.splitlines()
107
+ total = len(lines)
108
+
109
+ max_lines = TOOL_VIEWPORT_LINES
110
+
111
+ if total <= max_lines:
112
+ return "\n".join(_truncate_line(ln) for ln in lines), total, total
113
+
114
+ truncated = [_truncate_line(ln) for ln in lines[:max_lines]]
115
+ return "\n".join(truncated), max_lines, total
116
+
117
+
118
+ def render_bash(
119
+ args: dict[str, Any] | None,
120
+ result: str,
121
+ duration_ms: float | None = None,
122
+ ) -> RenderableType | None:
123
+ """Render bash with NeXTSTEP zoned layout.
124
+
125
+ Zones:
126
+ - Header: command + exit code indicator
127
+ - Selection context: working directory, timeout
128
+ - Primary viewport: stdout/stderr output
129
+ - Status: truncation info, duration
130
+ """
131
+ data = parse_result(args, result)
132
+ if not data:
133
+ return None
134
+
135
+ # Zone 1: Command + exit status
136
+ header = Text()
137
+
138
+ # Truncate command for header
139
+ cmd_display = data.command
140
+ if len(cmd_display) > 50:
141
+ cmd_display = cmd_display[:47] + "..."
142
+ header.append(f"$ {cmd_display}", style="bold")
143
+
144
+ # Exit code indicator
145
+ if data.exit_code == 0:
146
+ header.append(" ", style="")
147
+ header.append("ok", style="green bold")
148
+ else:
149
+ header.append(" ", style="")
150
+ header.append(f"exit {data.exit_code}", style="red bold")
151
+
152
+ # Zone 2: Parameters
153
+ args = args or {}
154
+ timeout = args.get("timeout", 30)
155
+ params = Text()
156
+ params.append("cwd:", style="dim")
157
+ params.append(f" {data.working_dir}", style="dim bold")
158
+ params.append(" timeout:", style="dim")
159
+ params.append(f" {timeout}s", style="dim bold")
160
+
161
+ separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
162
+
163
+ # Zone 3: Output viewport
164
+ viewport_parts: list[Text] = []
165
+
166
+ if data.stdout:
167
+ truncated_stdout, shown_stdout, total_stdout = _truncate_output(data.stdout)
168
+ stdout_text = Text()
169
+ stdout_text.append("stdout:\n", style="dim")
170
+ stdout_text.append(truncated_stdout)
171
+ viewport_parts.append(stdout_text)
172
+
173
+ if data.stderr:
174
+ if viewport_parts:
175
+ viewport_parts.append(Text("\n"))
176
+ truncated_stderr, shown_stderr, total_stderr = _truncate_output(data.stderr)
177
+ stderr_text = Text()
178
+ stderr_text.append("stderr:\n", style="dim")
179
+ stderr_text.append(truncated_stderr, style="red")
180
+ viewport_parts.append(stderr_text)
181
+
182
+ if not viewport_parts:
183
+ viewport_parts.append(Text("(no output)", style="dim"))
184
+
185
+ # Pad viewport to minimum height for visual consistency
186
+ viewport_line_count = sum(
187
+ 1 + str(part).count("\n") for part in viewport_parts if isinstance(part, Text)
188
+ )
189
+ while viewport_line_count < MIN_VIEWPORT_LINES:
190
+ viewport_parts.append(Text(""))
191
+ viewport_line_count += 1
192
+
193
+ viewport = Group(*viewport_parts)
194
+
195
+ # Zone 4: Status
196
+ status_items: list[str] = []
197
+
198
+ if data.is_truncated:
199
+ status_items.append("(truncated)")
200
+
201
+ if data.stdout:
202
+ stdout_lines = len(data.stdout.splitlines())
203
+ status_items.append(f"stdout: {stdout_lines} lines")
204
+
205
+ if data.stderr:
206
+ stderr_lines = len(data.stderr.splitlines())
207
+ status_items.append(f"stderr: {stderr_lines} lines")
208
+
209
+ if duration_ms is not None:
210
+ status_items.append(f"{duration_ms:.0f}ms")
211
+
212
+ status = Text(" ".join(status_items), style="dim") if status_items else Text("")
213
+
214
+ # Compose
215
+ content = Group(
216
+ header,
217
+ Text("\n"),
218
+ params,
219
+ Text("\n"),
220
+ separator,
221
+ Text("\n"),
222
+ viewport,
223
+ Text("\n"),
224
+ separator,
225
+ Text("\n"),
226
+ status,
227
+ )
228
+
229
+ timestamp = datetime.now().strftime("%H:%M:%S")
230
+
231
+ # Use different colors based on exit code
232
+ if data.exit_code == 0:
233
+ border_color = UI_COLORS["success"]
234
+ status_text = "done"
235
+ else:
236
+ border_color = UI_COLORS["warning"]
237
+ status_text = f"exit {data.exit_code}"
238
+
239
+ return Panel(
240
+ content,
241
+ title=f"[{border_color}]bash[/] [{status_text}]",
242
+ subtitle=f"[{UI_COLORS['muted']}]{timestamp}[/]",
243
+ border_style=Style(color=border_color),
244
+ padding=(0, 1),
245
+ expand=True,
246
+ width=TOOL_PANEL_WIDTH,
247
+ )