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.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|