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,186 @@
|
|
|
1
|
+
"""NeXTSTEP-style renderer for LSP diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from rich.console import Group, RenderableType
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from tunacode.constants import UI_COLORS
|
|
12
|
+
from tunacode.lsp.diagnostics import truncate_diagnostic_message
|
|
13
|
+
|
|
14
|
+
MAX_DIAGNOSTICS_DISPLAY = 10
|
|
15
|
+
|
|
16
|
+
BOX_HORIZONTAL = "\u2500"
|
|
17
|
+
SEPARATOR_WIDTH = 52
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DiagnosticItem:
|
|
22
|
+
"""A single diagnostic for display."""
|
|
23
|
+
|
|
24
|
+
severity: str # "error", "warning", "info", "hint"
|
|
25
|
+
line: int
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DiagnosticsData:
|
|
31
|
+
"""Parsed diagnostics for structured display."""
|
|
32
|
+
|
|
33
|
+
items: list[DiagnosticItem]
|
|
34
|
+
error_count: int
|
|
35
|
+
warning_count: int
|
|
36
|
+
info_count: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_diagnostics_block(content: str) -> DiagnosticsData | None:
|
|
40
|
+
"""Extract diagnostics from <file_diagnostics> XML block.
|
|
41
|
+
|
|
42
|
+
Expected format:
|
|
43
|
+
<file_diagnostics>
|
|
44
|
+
Error (line 10): message text
|
|
45
|
+
Warning (line 15): another message
|
|
46
|
+
</file_diagnostics>
|
|
47
|
+
"""
|
|
48
|
+
if not content:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Extract content between tags
|
|
52
|
+
match = re.search(r"<file_diagnostics>(.*?)</file_diagnostics>", content, re.DOTALL)
|
|
53
|
+
if not match:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
block_content = match.group(1).strip()
|
|
57
|
+
if not block_content:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
items: list[DiagnosticItem] = []
|
|
61
|
+
error_count = 0
|
|
62
|
+
warning_count = 0
|
|
63
|
+
info_count = 0
|
|
64
|
+
|
|
65
|
+
# Parse each diagnostic line
|
|
66
|
+
# Format: "Error (line 10): message" or "Warning (line 15): message"
|
|
67
|
+
pattern = re.compile(r"^(Error|Warning|Info|Hint)\s+\(line\s+(\d+)\):\s*(.+)$", re.IGNORECASE)
|
|
68
|
+
|
|
69
|
+
for line in block_content.splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if not line or line.startswith("Summary:"):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
match = pattern.match(line)
|
|
75
|
+
if match:
|
|
76
|
+
severity = match.group(1).lower()
|
|
77
|
+
line_num = int(match.group(2))
|
|
78
|
+
message = match.group(3)
|
|
79
|
+
|
|
80
|
+
items.append(
|
|
81
|
+
DiagnosticItem(
|
|
82
|
+
severity=severity,
|
|
83
|
+
line=line_num,
|
|
84
|
+
message=message,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if severity == "error":
|
|
89
|
+
error_count += 1
|
|
90
|
+
elif severity == "warning":
|
|
91
|
+
warning_count += 1
|
|
92
|
+
else:
|
|
93
|
+
info_count += 1
|
|
94
|
+
|
|
95
|
+
if not items:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return DiagnosticsData(
|
|
99
|
+
items=items,
|
|
100
|
+
error_count=error_count,
|
|
101
|
+
warning_count=warning_count,
|
|
102
|
+
info_count=info_count,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_diagnostics_inline(data: DiagnosticsData) -> RenderableType:
|
|
107
|
+
"""Render diagnostics as inline zone within update_file panel.
|
|
108
|
+
|
|
109
|
+
NeXTSTEP 4-Zone Layout:
|
|
110
|
+
+----------------------------------+
|
|
111
|
+
| HEADER: 2 errors, 1 warning | <- color-coded counts
|
|
112
|
+
+----------------------------------+
|
|
113
|
+
| VIEWPORT: |
|
|
114
|
+
| L170: Type mismatch... | <- red
|
|
115
|
+
| L336: Argument type... | <- red
|
|
116
|
+
| L42: Unused import | <- yellow
|
|
117
|
+
+----------------------------------+
|
|
118
|
+
"""
|
|
119
|
+
content_parts: list[RenderableType] = []
|
|
120
|
+
|
|
121
|
+
# Zone 1: Header - counts with color coding
|
|
122
|
+
header = Text()
|
|
123
|
+
header.append("LSP Diagnostics", style="bold")
|
|
124
|
+
header.append(" ", style="")
|
|
125
|
+
|
|
126
|
+
if data.error_count > 0:
|
|
127
|
+
error_suffix = "s" if data.error_count > 1 else ""
|
|
128
|
+
header.append(f"{data.error_count} error{error_suffix}", style=UI_COLORS["error"])
|
|
129
|
+
if data.warning_count > 0 or data.info_count > 0:
|
|
130
|
+
header.append(", ", style="")
|
|
131
|
+
|
|
132
|
+
if data.warning_count > 0:
|
|
133
|
+
warning_suffix = "s" if data.warning_count > 1 else ""
|
|
134
|
+
header.append(f"{data.warning_count} warning{warning_suffix}", style=UI_COLORS["warning"])
|
|
135
|
+
if data.info_count > 0:
|
|
136
|
+
header.append(", ", style="")
|
|
137
|
+
|
|
138
|
+
if data.info_count > 0:
|
|
139
|
+
header.append(f"{data.info_count} info", style=UI_COLORS["muted"])
|
|
140
|
+
|
|
141
|
+
content_parts.append(header)
|
|
142
|
+
content_parts.append(Text("\n"))
|
|
143
|
+
|
|
144
|
+
separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
|
|
145
|
+
content_parts.append(separator)
|
|
146
|
+
content_parts.append(Text("\n"))
|
|
147
|
+
|
|
148
|
+
# Zone 2: Viewport - diagnostic list
|
|
149
|
+
for idx, item in enumerate(data.items):
|
|
150
|
+
if idx >= MAX_DIAGNOSTICS_DISPLAY:
|
|
151
|
+
remaining = len(data.items) - idx
|
|
152
|
+
content_parts.append(Text(f" +{remaining} more...", style="dim italic"))
|
|
153
|
+
content_parts.append(Text("\n"))
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
# Severity color
|
|
157
|
+
severity_style = UI_COLORS["error"] if item.severity == "error" else UI_COLORS["warning"]
|
|
158
|
+
if item.severity in ("info", "hint"):
|
|
159
|
+
severity_style = UI_COLORS["muted"]
|
|
160
|
+
|
|
161
|
+
line_text = Text()
|
|
162
|
+
line_text.append(f" L{item.line:<4}", style="dim")
|
|
163
|
+
line_text.append(truncate_diagnostic_message(item.message), style=severity_style)
|
|
164
|
+
content_parts.append(line_text)
|
|
165
|
+
content_parts.append(Text("\n"))
|
|
166
|
+
|
|
167
|
+
return Group(*content_parts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract_diagnostics_from_result(result: str) -> tuple[str, str | None]:
|
|
171
|
+
"""Extract and remove diagnostics block from tool result.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Tuple of (result_without_diagnostics, diagnostics_block_or_none)
|
|
175
|
+
"""
|
|
176
|
+
if "<file_diagnostics>" not in result:
|
|
177
|
+
return result, None
|
|
178
|
+
|
|
179
|
+
match = re.search(r"<file_diagnostics>.*?</file_diagnostics>", result, re.DOTALL)
|
|
180
|
+
if not match:
|
|
181
|
+
return result, None
|
|
182
|
+
|
|
183
|
+
diagnostics_block = match.group(0)
|
|
184
|
+
result_without = result.replace(diagnostics_block, "").strip()
|
|
185
|
+
|
|
186
|
+
return result_without, diagnostics_block
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""NeXTSTEP-style panel renderer for glob 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.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
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GlobData:
|
|
30
|
+
"""Parsed glob result for structured display."""
|
|
31
|
+
|
|
32
|
+
pattern: str
|
|
33
|
+
file_count: int
|
|
34
|
+
files: list[str]
|
|
35
|
+
source: str # "index" or "filesystem"
|
|
36
|
+
is_truncated: bool
|
|
37
|
+
recursive: bool
|
|
38
|
+
include_hidden: bool
|
|
39
|
+
sort_by: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_result(args: dict[str, Any] | None, result: str) -> GlobData | None:
|
|
43
|
+
"""Extract structured data from glob output.
|
|
44
|
+
|
|
45
|
+
Expected format:
|
|
46
|
+
[source:index]
|
|
47
|
+
Found N file(s) matching pattern: <pattern>
|
|
48
|
+
|
|
49
|
+
/path/to/file1.py
|
|
50
|
+
/path/to/file2.py
|
|
51
|
+
(truncated at N)
|
|
52
|
+
"""
|
|
53
|
+
if not result:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
lines = result.strip().splitlines()
|
|
57
|
+
if not lines:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Extract source marker
|
|
61
|
+
source = "filesystem"
|
|
62
|
+
start_idx = 0
|
|
63
|
+
if lines[0].startswith("[source:"):
|
|
64
|
+
marker = lines[0]
|
|
65
|
+
source = marker[8:-1] # Extract between "[source:" and "]"
|
|
66
|
+
start_idx = 1
|
|
67
|
+
|
|
68
|
+
# Parse header
|
|
69
|
+
if start_idx >= len(lines):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
header_line = lines[start_idx]
|
|
73
|
+
header_match = re.match(r"Found (\d+) files? matching pattern: (.+)", header_line)
|
|
74
|
+
if not header_match:
|
|
75
|
+
# Check for "No files found" case
|
|
76
|
+
if "No files found" in header_line:
|
|
77
|
+
return None
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
file_count = int(header_match.group(1))
|
|
81
|
+
pattern = header_match.group(2).strip()
|
|
82
|
+
|
|
83
|
+
# Parse file list
|
|
84
|
+
files: list[str] = []
|
|
85
|
+
is_truncated = False
|
|
86
|
+
|
|
87
|
+
for line in lines[start_idx + 1 :]:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
if line.startswith("(truncated"):
|
|
92
|
+
is_truncated = True
|
|
93
|
+
continue
|
|
94
|
+
# File paths
|
|
95
|
+
if line.startswith("/") or line.startswith("./") or "/" in line:
|
|
96
|
+
files.append(line)
|
|
97
|
+
|
|
98
|
+
args = args or {}
|
|
99
|
+
return GlobData(
|
|
100
|
+
pattern=pattern,
|
|
101
|
+
file_count=file_count,
|
|
102
|
+
files=files,
|
|
103
|
+
source=source,
|
|
104
|
+
is_truncated=is_truncated,
|
|
105
|
+
recursive=args.get("recursive", True),
|
|
106
|
+
include_hidden=args.get("include_hidden", False),
|
|
107
|
+
sort_by=args.get("sort_by", "modified"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _truncate_path(path: str) -> str:
|
|
112
|
+
"""Truncate a path if too wide, keeping filename visible."""
|
|
113
|
+
if len(path) <= MAX_PANEL_LINE_WIDTH:
|
|
114
|
+
return path
|
|
115
|
+
|
|
116
|
+
p = Path(path)
|
|
117
|
+
filename = p.name
|
|
118
|
+
max_dir_len = MAX_PANEL_LINE_WIDTH - len(filename) - 4 # ".../"
|
|
119
|
+
|
|
120
|
+
if max_dir_len <= 0:
|
|
121
|
+
return "..." + filename[-(MAX_PANEL_LINE_WIDTH - 3) :]
|
|
122
|
+
|
|
123
|
+
dir_part = str(p.parent)
|
|
124
|
+
if len(dir_part) > max_dir_len:
|
|
125
|
+
dir_part = "..." + dir_part[-(max_dir_len - 3) :]
|
|
126
|
+
|
|
127
|
+
return f"{dir_part}/{filename}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_glob(
|
|
131
|
+
args: dict[str, Any] | None,
|
|
132
|
+
result: str,
|
|
133
|
+
duration_ms: float | None = None,
|
|
134
|
+
) -> RenderableType | None:
|
|
135
|
+
"""Render glob with NeXTSTEP zoned layout.
|
|
136
|
+
|
|
137
|
+
Zones:
|
|
138
|
+
- Header: pattern + file count
|
|
139
|
+
- Selection context: recursive, hidden, sort parameters
|
|
140
|
+
- Primary viewport: file list
|
|
141
|
+
- Status: source (indexed/scanned), truncation, duration
|
|
142
|
+
"""
|
|
143
|
+
data = parse_result(args, result)
|
|
144
|
+
if not data:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
# Zone 1: Pattern + counts
|
|
148
|
+
header = Text()
|
|
149
|
+
header.append(f'"{data.pattern}"', style="bold")
|
|
150
|
+
file_word = "file" if data.file_count == 1 else "files"
|
|
151
|
+
header.append(f" {data.file_count} {file_word}", style="dim")
|
|
152
|
+
|
|
153
|
+
# Zone 2: Parameters
|
|
154
|
+
recursive_val = "on" if data.recursive else "off"
|
|
155
|
+
hidden_val = "on" if data.include_hidden else "off"
|
|
156
|
+
params = Text()
|
|
157
|
+
params.append("recursive:", style="dim")
|
|
158
|
+
params.append(f" {recursive_val}", style="dim bold")
|
|
159
|
+
params.append(" hidden:", style="dim")
|
|
160
|
+
params.append(f" {hidden_val}", style="dim bold")
|
|
161
|
+
params.append(" sort:", style="dim")
|
|
162
|
+
params.append(f" {data.sort_by}", style="dim bold")
|
|
163
|
+
|
|
164
|
+
separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
|
|
165
|
+
|
|
166
|
+
# Zone 3: File list viewport
|
|
167
|
+
viewport_lines: list[str] = []
|
|
168
|
+
max_display = TOOL_VIEWPORT_LINES
|
|
169
|
+
|
|
170
|
+
for i, filepath in enumerate(data.files):
|
|
171
|
+
if i >= max_display:
|
|
172
|
+
break
|
|
173
|
+
viewport_lines.append(_truncate_path(filepath))
|
|
174
|
+
|
|
175
|
+
# Pad viewport to minimum height for visual consistency
|
|
176
|
+
while len(viewport_lines) < MIN_VIEWPORT_LINES:
|
|
177
|
+
viewport_lines.append("")
|
|
178
|
+
|
|
179
|
+
viewport = Text("\n".join(viewport_lines)) if viewport_lines else Text("(no files)")
|
|
180
|
+
|
|
181
|
+
# Zone 4: Status
|
|
182
|
+
status_items: list[str] = []
|
|
183
|
+
|
|
184
|
+
# Source indicator (cache hit/miss)
|
|
185
|
+
if data.source == "index":
|
|
186
|
+
status_items.append("indexed")
|
|
187
|
+
else:
|
|
188
|
+
status_items.append("scanned")
|
|
189
|
+
|
|
190
|
+
if data.is_truncated or len(data.files) < data.file_count:
|
|
191
|
+
shown = min(len(data.files), max_display)
|
|
192
|
+
status_items.append(f"[{shown}/{data.file_count} shown]")
|
|
193
|
+
|
|
194
|
+
if duration_ms is not None:
|
|
195
|
+
status_items.append(f"{duration_ms:.0f}ms")
|
|
196
|
+
|
|
197
|
+
status = Text(" ".join(status_items), style="dim") if status_items else Text("")
|
|
198
|
+
|
|
199
|
+
# Compose
|
|
200
|
+
content = Group(
|
|
201
|
+
header,
|
|
202
|
+
Text("\n"),
|
|
203
|
+
params,
|
|
204
|
+
Text("\n"),
|
|
205
|
+
separator,
|
|
206
|
+
Text("\n"),
|
|
207
|
+
viewport,
|
|
208
|
+
Text("\n"),
|
|
209
|
+
separator,
|
|
210
|
+
Text("\n"),
|
|
211
|
+
status,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
215
|
+
|
|
216
|
+
border_color = UI_COLORS["success"]
|
|
217
|
+
|
|
218
|
+
return Panel(
|
|
219
|
+
content,
|
|
220
|
+
title=f"[{UI_COLORS['success']}]glob[/] [done]",
|
|
221
|
+
subtitle=f"[{UI_COLORS['muted']}]{timestamp}[/]",
|
|
222
|
+
border_style=Style(color=border_color),
|
|
223
|
+
padding=(0, 1),
|
|
224
|
+
expand=True,
|
|
225
|
+
width=TOOL_PANEL_WIDTH,
|
|
226
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""NeXTSTEP-style panel renderer for grep 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 GrepData:
|
|
29
|
+
"""Parsed grep result for structured display."""
|
|
30
|
+
|
|
31
|
+
pattern: str
|
|
32
|
+
total_matches: int
|
|
33
|
+
strategy: str
|
|
34
|
+
candidates: int
|
|
35
|
+
matches: list[dict[str, Any]]
|
|
36
|
+
is_truncated: bool
|
|
37
|
+
case_sensitive: bool
|
|
38
|
+
use_regex: bool
|
|
39
|
+
context_lines: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_result(args: dict[str, Any] | None, result: str) -> GrepData | None:
|
|
43
|
+
"""Extract structured data from grep output.
|
|
44
|
+
|
|
45
|
+
Expected format:
|
|
46
|
+
Found N matches for pattern: <pattern>
|
|
47
|
+
Strategy: <strategy> | Candidates: <N> files | ...
|
|
48
|
+
<matches>
|
|
49
|
+
"""
|
|
50
|
+
if not result or "Found" not in result:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
lines = result.strip().splitlines()
|
|
54
|
+
if len(lines) < 2:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Parse header: "Found N match(es) for pattern: <pattern>"
|
|
58
|
+
header_match = re.match(r"Found (\d+) match(?:es)? for pattern: (.+)", lines[0])
|
|
59
|
+
if not header_match:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
total_matches = int(header_match.group(1))
|
|
63
|
+
pattern = header_match.group(2).strip()
|
|
64
|
+
|
|
65
|
+
# Parse strategy line
|
|
66
|
+
strategy = "smart"
|
|
67
|
+
candidates = 0
|
|
68
|
+
if len(lines) > 1 and lines[1].startswith("Strategy:"):
|
|
69
|
+
strat_match = re.match(r"Strategy: (\w+) \| Candidates: (\d+)", lines[1])
|
|
70
|
+
if strat_match:
|
|
71
|
+
strategy = strat_match.group(1)
|
|
72
|
+
candidates = int(strat_match.group(2))
|
|
73
|
+
|
|
74
|
+
# Parse matches
|
|
75
|
+
matches: list[dict[str, Any]] = []
|
|
76
|
+
current_file: str | None = None
|
|
77
|
+
file_pattern = re.compile(r"\U0001f4c1 (.+?):(\d+)")
|
|
78
|
+
match_pattern = re.compile(r"\u25b6\s*(\d+)\u2502\s*(.*?)\u27e8(.+?)\u27e9(.*)")
|
|
79
|
+
|
|
80
|
+
for line in lines:
|
|
81
|
+
file_match = file_pattern.search(line)
|
|
82
|
+
if file_match:
|
|
83
|
+
current_file = file_match.group(1)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
match_line = match_pattern.search(line)
|
|
87
|
+
if match_line and current_file:
|
|
88
|
+
line_num = int(match_line.group(1))
|
|
89
|
+
before = match_line.group(2)
|
|
90
|
+
match_text = match_line.group(3)
|
|
91
|
+
after = match_line.group(4)
|
|
92
|
+
|
|
93
|
+
matches.append(
|
|
94
|
+
{
|
|
95
|
+
"file": current_file,
|
|
96
|
+
"line": line_num,
|
|
97
|
+
"before": before,
|
|
98
|
+
"match": match_text,
|
|
99
|
+
"after": after,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not matches and total_matches == 0:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
args = args or {}
|
|
107
|
+
return GrepData(
|
|
108
|
+
pattern=pattern,
|
|
109
|
+
total_matches=total_matches,
|
|
110
|
+
strategy=strategy,
|
|
111
|
+
candidates=candidates,
|
|
112
|
+
matches=matches,
|
|
113
|
+
is_truncated=len(matches) < total_matches,
|
|
114
|
+
case_sensitive=args.get("case_sensitive", False),
|
|
115
|
+
use_regex=args.get("use_regex", False),
|
|
116
|
+
context_lines=args.get("context_lines", 2),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _truncate_line(line: str) -> str:
|
|
121
|
+
"""Truncate a single line if too wide."""
|
|
122
|
+
if len(line) > MAX_PANEL_LINE_WIDTH:
|
|
123
|
+
return line[: MAX_PANEL_LINE_WIDTH - 3] + "..."
|
|
124
|
+
return line
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_grep(
|
|
128
|
+
args: dict[str, Any] | None,
|
|
129
|
+
result: str,
|
|
130
|
+
duration_ms: float | None = None,
|
|
131
|
+
) -> RenderableType | None:
|
|
132
|
+
"""Render grep with NeXTSTEP zoned layout.
|
|
133
|
+
|
|
134
|
+
Zones:
|
|
135
|
+
- Header: pattern + match count
|
|
136
|
+
- Selection context: strategy, case, regex parameters
|
|
137
|
+
- Primary viewport: grouped matches by file
|
|
138
|
+
- Status: truncation info, duration
|
|
139
|
+
"""
|
|
140
|
+
data = parse_result(args, result)
|
|
141
|
+
if not data:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# Zone 1: Pattern + counts
|
|
145
|
+
header = Text()
|
|
146
|
+
header.append(f'"{data.pattern}"', style="bold")
|
|
147
|
+
match_word = "match" if data.total_matches == 1 else "matches"
|
|
148
|
+
header.append(f" {data.total_matches} {match_word}", style="dim")
|
|
149
|
+
|
|
150
|
+
# Zone 2: Parameters
|
|
151
|
+
case_val = "yes" if data.case_sensitive else "no"
|
|
152
|
+
regex_val = "yes" if data.use_regex else "no"
|
|
153
|
+
params = Text()
|
|
154
|
+
params.append("strategy:", style="dim")
|
|
155
|
+
params.append(f" {data.strategy}", style="dim bold")
|
|
156
|
+
params.append(" case:", style="dim")
|
|
157
|
+
params.append(f" {case_val}", style="dim bold")
|
|
158
|
+
params.append(" regex:", style="dim")
|
|
159
|
+
params.append(f" {regex_val}", style="dim bold")
|
|
160
|
+
params.append(" context:", style="dim")
|
|
161
|
+
params.append(f" {data.context_lines}", style="dim bold")
|
|
162
|
+
|
|
163
|
+
separator = Text(BOX_HORIZONTAL * SEPARATOR_WIDTH, style="dim")
|
|
164
|
+
|
|
165
|
+
# Zone 3: Matches viewport grouped by file
|
|
166
|
+
viewport_lines: list[str] = []
|
|
167
|
+
current_file: str | None = None
|
|
168
|
+
shown_matches = 0
|
|
169
|
+
|
|
170
|
+
for match in data.matches:
|
|
171
|
+
if shown_matches >= TOOL_VIEWPORT_LINES:
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
if match["file"] != current_file:
|
|
175
|
+
if current_file is not None:
|
|
176
|
+
viewport_lines.append("")
|
|
177
|
+
shown_matches += 1
|
|
178
|
+
current_file = match["file"]
|
|
179
|
+
viewport_lines.append(f" {current_file}")
|
|
180
|
+
shown_matches += 1
|
|
181
|
+
|
|
182
|
+
line_content = f" {match['line']:>4}| {match['before']}{match['match']}{match['after']}"
|
|
183
|
+
viewport_lines.append(_truncate_line(line_content))
|
|
184
|
+
shown_matches += 1
|
|
185
|
+
|
|
186
|
+
# Pad viewport to minimum height for visual consistency
|
|
187
|
+
while len(viewport_lines) < MIN_VIEWPORT_LINES:
|
|
188
|
+
viewport_lines.append("")
|
|
189
|
+
|
|
190
|
+
viewport = Text("\n".join(viewport_lines)) if viewport_lines else Text("(no matches)")
|
|
191
|
+
|
|
192
|
+
# Zone 4: Status
|
|
193
|
+
status_items: list[str] = []
|
|
194
|
+
if data.is_truncated:
|
|
195
|
+
status_items.append(f"[{len(data.matches)}/{data.total_matches} shown]")
|
|
196
|
+
if data.candidates > 0:
|
|
197
|
+
status_items.append(f"{data.candidates} files searched")
|
|
198
|
+
if duration_ms is not None:
|
|
199
|
+
status_items.append(f"{duration_ms:.0f}ms")
|
|
200
|
+
|
|
201
|
+
status = Text(" ".join(status_items), style="dim") if status_items else Text("")
|
|
202
|
+
|
|
203
|
+
# Compose
|
|
204
|
+
content = Group(
|
|
205
|
+
header,
|
|
206
|
+
Text("\n"),
|
|
207
|
+
params,
|
|
208
|
+
Text("\n"),
|
|
209
|
+
separator,
|
|
210
|
+
Text("\n"),
|
|
211
|
+
viewport,
|
|
212
|
+
Text("\n"),
|
|
213
|
+
separator,
|
|
214
|
+
Text("\n"),
|
|
215
|
+
status,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
219
|
+
|
|
220
|
+
return Panel(
|
|
221
|
+
content,
|
|
222
|
+
title=f"[{UI_COLORS['success']}]grep[/] [done]",
|
|
223
|
+
subtitle=f"[{UI_COLORS['muted']}]{timestamp}[/]",
|
|
224
|
+
border_style=Style(color=UI_COLORS["success"]),
|
|
225
|
+
padding=(0, 1),
|
|
226
|
+
expand=True,
|
|
227
|
+
width=TOOL_PANEL_WIDTH,
|
|
228
|
+
)
|