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,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
+ )