tsugite-cli 0.3.3__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.
Files changed (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
@@ -0,0 +1,393 @@
1
+ """Replacement strategies for smart file editing.
2
+
3
+ This module provides various strategies for finding and replacing text in files,
4
+ with progressive fallback from exact matching to more flexible approaches.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Generator, List
9
+
10
+ MIN_BLOCK_ANCHOR_LINES = 3
11
+
12
+
13
+ class ReplacementStrategy(ABC):
14
+ """Base class for text replacement strategies."""
15
+
16
+ @abstractmethod
17
+ def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
18
+ """Find all matches of search string in content.
19
+
20
+ Args:
21
+ content: The full file content to search in
22
+ search: The text pattern to find
23
+
24
+ Yields:
25
+ Exact strings from content that match the search pattern
26
+ """
27
+ pass
28
+
29
+
30
+ class ExactStrategy(ReplacementStrategy):
31
+ """Exact string matching - the most precise strategy."""
32
+
33
+ def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
34
+ """Find exact string matches."""
35
+ if not search:
36
+ return
37
+
38
+ start_index = 0
39
+ while True:
40
+ index = content.find(search, start_index)
41
+ if index == -1:
42
+ break
43
+ yield search
44
+ start_index = index + len(search)
45
+
46
+
47
+ class LineTrimmedStrategy(ReplacementStrategy):
48
+ """Match lines with trimmed whitespace.
49
+
50
+ Ignores leading/trailing whitespace on each line while preserving
51
+ the original whitespace in the matched content.
52
+ """
53
+
54
+ def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
55
+ """Find matches with trimmed line comparison."""
56
+ if not search:
57
+ return
58
+
59
+ content_lines = content.split("\n")
60
+ search_lines = search.split("\n")
61
+
62
+ # Remove trailing empty line if present
63
+ if search_lines and search_lines[-1] == "":
64
+ search_lines.pop()
65
+
66
+ if not search_lines:
67
+ return
68
+
69
+ # Search for matching blocks
70
+ for i in range(len(content_lines) - len(search_lines) + 1):
71
+ # Check if all lines match when trimmed
72
+ matches = True
73
+ for j in range(len(search_lines)):
74
+ if content_lines[i + j].strip() != search_lines[j].strip():
75
+ matches = False
76
+ break
77
+
78
+ if matches:
79
+ # Yield the original content with its whitespace preserved
80
+ match_start = sum(len(line) + 1 for line in content_lines[:i])
81
+ match_end = match_start + sum(
82
+ len(content_lines[i + j]) + (1 if j < len(search_lines) - 1 else 0)
83
+ for j in range(len(search_lines))
84
+ )
85
+ yield content[match_start:match_end]
86
+
87
+
88
+ class BlockAnchorStrategy(ReplacementStrategy):
89
+ """Match blocks using first and last lines as anchors.
90
+
91
+ Uses Levenshtein distance for fuzzy matching of middle content.
92
+ Requires at least MIN_BLOCK_ANCHOR_LINES (first anchor, middle content, last anchor).
93
+ """
94
+
95
+ SINGLE_CANDIDATE_THRESHOLD = 0.0
96
+ MULTIPLE_CANDIDATES_THRESHOLD = 0.3
97
+
98
+ def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
99
+ """Find matches using anchor lines with fuzzy middle content."""
100
+ search_lines = search.split("\n")
101
+
102
+ if search_lines and search_lines[-1] == "":
103
+ search_lines.pop()
104
+
105
+ if len(search_lines) < MIN_BLOCK_ANCHOR_LINES:
106
+ return
107
+
108
+ content_lines = content.split("\n")
109
+ first_line_search = search_lines[0].strip()
110
+ last_line_search = search_lines[-1].strip()
111
+ search_block_size = len(search_lines)
112
+
113
+ # Find all candidate blocks with matching anchors
114
+ candidates: List[tuple[int, int]] = []
115
+ for i in range(len(content_lines)):
116
+ if content_lines[i].strip() != first_line_search:
117
+ continue
118
+
119
+ # Look for matching last line
120
+ for j in range(i + 2, len(content_lines)):
121
+ if content_lines[j].strip() == last_line_search:
122
+ candidates.append((i, j))
123
+ break
124
+
125
+ if not candidates:
126
+ return
127
+
128
+ # Handle single candidate with relaxed threshold
129
+ if len(candidates) == 1:
130
+ start_line, end_line = candidates[0]
131
+ actual_block_size = end_line - start_line + 1
132
+ similarity = self._calculate_similarity(
133
+ content_lines, search_lines, start_line, search_block_size, actual_block_size
134
+ )
135
+
136
+ if similarity >= self.SINGLE_CANDIDATE_THRESHOLD:
137
+ yield self._extract_block(content, content_lines, start_line, end_line)
138
+ return
139
+
140
+ # Handle multiple candidates - find best match
141
+ best_match = None
142
+ max_similarity = -1
143
+
144
+ for start_line, end_line in candidates:
145
+ actual_block_size = end_line - start_line + 1
146
+ similarity = self._calculate_similarity(
147
+ content_lines, search_lines, start_line, search_block_size, actual_block_size
148
+ )
149
+
150
+ if similarity > max_similarity:
151
+ max_similarity = similarity
152
+ best_match = (start_line, end_line)
153
+
154
+ if max_similarity >= self.MULTIPLE_CANDIDATES_THRESHOLD and best_match:
155
+ start_line, end_line = best_match
156
+ yield self._extract_block(content, content_lines, start_line, end_line)
157
+
158
+ def _calculate_similarity(
159
+ self,
160
+ content_lines: List[str],
161
+ search_lines: List[str],
162
+ start_line: int,
163
+ search_block_size: int,
164
+ actual_block_size: int,
165
+ ) -> float:
166
+ """Calculate similarity between middle lines using Levenshtein distance."""
167
+ lines_to_check = min(search_block_size - 2, actual_block_size - 2)
168
+
169
+ if lines_to_check <= 0:
170
+ return 1.0
171
+
172
+ similarity = 0.0
173
+ for j in range(1, min(search_block_size - 1, actual_block_size - 1)):
174
+ content_line = content_lines[start_line + j].strip()
175
+ search_line = search_lines[j].strip()
176
+ max_len = max(len(content_line), len(search_line))
177
+
178
+ if max_len == 0:
179
+ continue
180
+
181
+ distance = self._levenshtein_distance(content_line, search_line)
182
+ similarity += (1 - distance / max_len) / lines_to_check
183
+
184
+ # Early exit if we've already hit threshold
185
+ if similarity >= self.SINGLE_CANDIDATE_THRESHOLD:
186
+ break
187
+
188
+ return similarity
189
+
190
+ def _extract_block(self, content: str, content_lines: List[str], start_line: int, end_line: int) -> str:
191
+ """Extract block of text from content."""
192
+ match_start = sum(len(line) + 1 for line in content_lines[:start_line])
193
+ match_end = match_start + sum(
194
+ len(content_lines[i]) + (1 if i < end_line else 0) for i in range(start_line, end_line + 1)
195
+ )
196
+ return content[match_start:match_end]
197
+
198
+ @staticmethod
199
+ def _levenshtein_distance(s1: str, s2: str) -> int:
200
+ """Calculate Levenshtein distance between two strings."""
201
+ if not s1 or not s2:
202
+ return max(len(s1), len(s2))
203
+
204
+ # Create distance matrix
205
+ m, n = len(s1), len(s2)
206
+ matrix = [[0] * (n + 1) for _ in range(m + 1)]
207
+
208
+ # Initialize first row and column
209
+ for i in range(m + 1):
210
+ matrix[i][0] = i
211
+ for j in range(n + 1):
212
+ matrix[0][j] = j
213
+
214
+ # Fill matrix
215
+ for i in range(1, m + 1):
216
+ for j in range(1, n + 1):
217
+ cost = 0 if s1[i - 1] == s2[j - 1] else 1
218
+ matrix[i][j] = min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
219
+
220
+ return matrix[m][n]
221
+
222
+
223
+ class WhitespaceNormalizedStrategy(ReplacementStrategy):
224
+ """Match with normalized whitespace.
225
+
226
+ Normalizes all whitespace to single spaces and trims before comparison.
227
+ """
228
+
229
+ def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
230
+ """Find matches with normalized whitespace."""
231
+
232
+ if not search:
233
+ return
234
+
235
+ normalized_search = self._normalize_whitespace(search)
236
+ lines = content.split("\n")
237
+
238
+ # Check single line matches
239
+ for line in lines:
240
+ if self._normalize_whitespace(line) == normalized_search:
241
+ yield line
242
+
243
+ # Check multi-line matches
244
+ search_lines = search.split("\n")
245
+ if len(search_lines) <= 1:
246
+ return
247
+
248
+ for i in range(len(lines) - len(search_lines) + 1):
249
+ block = "\n".join(lines[i : i + len(search_lines)])
250
+ if self._normalize_whitespace(block) == normalized_search:
251
+ yield block
252
+
253
+ @staticmethod
254
+ def _normalize_whitespace(text: str) -> str:
255
+ """Normalize whitespace to single spaces and trim."""
256
+ import re
257
+
258
+ return re.sub(r"\s+", " ", text).strip()
259
+
260
+
261
+ class IndentationFlexibleStrategy(ReplacementStrategy):
262
+ """Match ignoring indentation differences.
263
+
264
+ Strips minimum common indentation from both search and content blocks
265
+ before comparison.
266
+ """
267
+
268
+ def find_matches(self, content: str, search: str) -> Generator[str, None, None]:
269
+ """Find matches ignoring indentation."""
270
+ if not search:
271
+ return
272
+
273
+ normalized_search = self._remove_indentation(search)
274
+ content_lines = content.split("\n")
275
+ search_lines = search.split("\n")
276
+
277
+ for i in range(len(content_lines) - len(search_lines) + 1):
278
+ block = "\n".join(content_lines[i : i + len(search_lines)])
279
+ if self._remove_indentation(block) == normalized_search:
280
+ yield block
281
+
282
+ @staticmethod
283
+ def _remove_indentation(text: str) -> str:
284
+ """Remove minimum common indentation from text."""
285
+ lines = text.split("\n")
286
+ non_empty_lines = [line for line in lines if line.strip()]
287
+
288
+ if not non_empty_lines:
289
+ return text
290
+
291
+ # Find minimum indentation
292
+ min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
293
+
294
+ # Remove minimum indentation from all lines
295
+ dedented_lines = [line[min_indent:] if line.strip() else line for line in lines]
296
+
297
+ return "\n".join(dedented_lines)
298
+
299
+
300
+ def apply_replacement(content: str, search: str, replace: str, expected_count: int = 1) -> tuple[str, int, str | None]:
301
+ """Apply replacement using progressive fallback strategies.
302
+
303
+ Args:
304
+ content: Full file content
305
+ search: Text to find
306
+ replace: Text to replace with
307
+ expected_count: Expected number of matches (default: 1)
308
+
309
+ Returns:
310
+ Tuple of (new_content, match_count, error_message)
311
+ error_message is None on success
312
+ """
313
+ if not search:
314
+ return content, 0, "Search string cannot be empty"
315
+
316
+ if search == replace:
317
+ return content, 0, "Search and replace strings must be different"
318
+
319
+ # Try strategies in order of precision
320
+ strategies = [
321
+ ("exact", ExactStrategy()),
322
+ ("line-trimmed", LineTrimmedStrategy()),
323
+ ("block-anchor", BlockAnchorStrategy()),
324
+ ("whitespace-normalized", WhitespaceNormalizedStrategy()),
325
+ ("indentation-flexible", IndentationFlexibleStrategy()),
326
+ ]
327
+
328
+ for strategy_name, strategy in strategies:
329
+ matches = list(strategy.find_matches(content, search))
330
+
331
+ if not matches:
332
+ continue
333
+
334
+ match_count = len(matches)
335
+
336
+ # Check if match count matches expectation
337
+ if match_count != expected_count:
338
+ if expected_count == 1:
339
+ error = (
340
+ f"Found {match_count} matches but expected 1. "
341
+ "Either add more context to make the match unique or use expected_replacements parameter."
342
+ )
343
+ else:
344
+ error = f"Found {match_count} matches but expected {expected_count}."
345
+ return content, match_count, error
346
+
347
+ # Apply replacement
348
+ new_content = content
349
+ for match in matches:
350
+ new_content = new_content.replace(match, replace, 1)
351
+
352
+ return new_content, match_count, None
353
+
354
+ return (
355
+ content,
356
+ 0,
357
+ "No matches found. Ensure old_string matches file content exactly (including whitespace/indentation). Use read_file_lines to verify.",
358
+ )
359
+
360
+
361
+ def detect_line_ending(content: str) -> str:
362
+ """Detect line ending style in content.
363
+
364
+ Args:
365
+ content: File content to analyze
366
+
367
+ Returns:
368
+ '\r\n' for Windows-style or '\n' for Unix-style
369
+ """
370
+ return "\r\n" if "\r\n" in content else "\n"
371
+
372
+
373
+ def preserve_line_ending(original_content: str, modified_content: str) -> str:
374
+ """Preserve original line endings in modified content.
375
+
376
+ Args:
377
+ original_content: Original file content
378
+ modified_content: Modified content (with \n line endings)
379
+
380
+ Returns:
381
+ Modified content with original line ending style
382
+ """
383
+ original_ending = detect_line_ending(original_content)
384
+
385
+ # If original had CRLF but modified has only LF, convert
386
+ if original_ending == "\r\n" and "\r\n" not in modified_content:
387
+ return modified_content.replace("\n", "\r\n")
388
+
389
+ # If original had LF but modified has CRLF, convert
390
+ if original_ending == "\n" and "\r\n" in modified_content:
391
+ return modified_content.replace("\r\n", "\n")
392
+
393
+ return modified_content