ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__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 (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,609 @@
1
+ """LSP tool for code intelligence queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from ripperdoc.core.tool import (
11
+ Tool,
12
+ ToolOutput,
13
+ ToolResult,
14
+ ToolUseContext,
15
+ ToolUseExample,
16
+ ValidationResult,
17
+ )
18
+ from ripperdoc.utils.log import get_logger
19
+ from ripperdoc.utils.path_ignore import check_path_for_tool
20
+ from ripperdoc.utils.lsp import (
21
+ LspLaunchError,
22
+ LspProtocolError,
23
+ LspRequestError,
24
+ ensure_lsp_manager,
25
+ uri_to_path,
26
+ )
27
+
28
+
29
+ logger = get_logger()
30
+
31
+ LSP_USAGE = (
32
+ "Interact with Language Server Protocol (LSP) servers to get code intelligence features.\n\n"
33
+ "Supported operations:\n"
34
+ "- goToDefinition: Find where a symbol is defined\n"
35
+ "- findReferences: Find all references to a symbol\n"
36
+ "- hover: Get hover information (documentation, type info) for a symbol\n"
37
+ "- documentSymbol: Get all symbols (functions, classes, variables) in a document\n"
38
+ "- workspaceSymbol: Search for symbols across the entire workspace\n"
39
+ "- goToImplementation: Find implementations of an interface or abstract method\n\n"
40
+ "All operations require:\n"
41
+ "- filePath: The file to operate on\n"
42
+ "- line: The line number (1-based, as shown in editors)\n"
43
+ "- character: The character offset (1-based, as shown in editors)\n\n"
44
+ "Note: LSP servers must be configured for the file type. "
45
+ "If no server is available, an error will be returned."
46
+ )
47
+
48
+ MAX_RESULTS = 50
49
+
50
+ SYMBOL_KIND_NAMES = {
51
+ 1: "File",
52
+ 2: "Module",
53
+ 3: "Namespace",
54
+ 4: "Package",
55
+ 5: "Class",
56
+ 6: "Method",
57
+ 7: "Property",
58
+ 8: "Field",
59
+ 9: "Constructor",
60
+ 10: "Enum",
61
+ 11: "Interface",
62
+ 12: "Function",
63
+ 13: "Variable",
64
+ 14: "Constant",
65
+ 15: "String",
66
+ 16: "Number",
67
+ 17: "Boolean",
68
+ 18: "Array",
69
+ 19: "Object",
70
+ 20: "Key",
71
+ 21: "Null",
72
+ 22: "EnumMember",
73
+ 23: "Struct",
74
+ 24: "Event",
75
+ 25: "Operator",
76
+ 26: "TypeParameter",
77
+ }
78
+
79
+
80
+ def _resolve_file_path(raw_path: str) -> Path:
81
+ candidate = Path(raw_path).expanduser()
82
+ if not candidate.is_absolute():
83
+ candidate = (Path.cwd() / candidate).resolve()
84
+ else:
85
+ candidate = candidate.resolve()
86
+ return candidate
87
+
88
+
89
+ def _display_path(file_path: Path, verbose: bool) -> str:
90
+ if verbose:
91
+ return str(file_path)
92
+ try:
93
+ rel = file_path.resolve().relative_to(Path.cwd().resolve())
94
+ except (ValueError, OSError):
95
+ return str(file_path)
96
+ rel_str = str(rel)
97
+ return rel_str if rel_str != "." else str(file_path)
98
+
99
+
100
+ def _read_text(file_path: Path) -> str:
101
+ return file_path.read_text(encoding="utf-8", errors="replace")
102
+
103
+
104
+ def _normalize_position(lines: List[str], line: int, character: int) -> Tuple[int, int, str]:
105
+ if not lines:
106
+ return 0, 0, ""
107
+ line_index = max(0, min(line - 1, len(lines) - 1))
108
+ line_text = lines[line_index]
109
+ char_index = max(0, min(character - 1, len(line_text)))
110
+ return line_index, char_index, line_text
111
+
112
+
113
+ def _extract_symbol_at_position(line_text: str, char_index: int) -> Optional[str]:
114
+ if not line_text:
115
+ return None
116
+ if char_index >= len(line_text):
117
+ char_index = len(line_text) - 1
118
+ if char_index < 0:
119
+ return None
120
+
121
+ if not line_text[char_index].isalnum() and line_text[char_index] != "_":
122
+ if char_index > 0 and (
123
+ line_text[char_index - 1].isalnum() or line_text[char_index - 1] == "_"
124
+ ):
125
+ char_index -= 1
126
+ else:
127
+ return None
128
+
129
+ start = char_index
130
+ while start > 0 and (line_text[start - 1].isalnum() or line_text[start - 1] == "_"):
131
+ start -= 1
132
+ end = char_index
133
+ while end + 1 < len(line_text) and (line_text[end + 1].isalnum() or line_text[end + 1] == "_"):
134
+ end += 1
135
+ symbol = line_text[start : end + 1].strip()
136
+ return symbol or None
137
+
138
+
139
+ def _symbol_kind_name(kind: Any) -> str:
140
+ try:
141
+ kind_value = int(kind)
142
+ except (TypeError, ValueError):
143
+ return "Unknown"
144
+ return SYMBOL_KIND_NAMES.get(kind_value, "Unknown")
145
+
146
+
147
+ def _location_to_path_line_char(location: Optional[Dict[str, Any]]) -> Tuple[str, int, int]:
148
+ if not location:
149
+ return "<unknown>", 0, 0
150
+ uri = location.get("uri") or location.get("targetUri")
151
+ range_info = (
152
+ location.get("range") or location.get("targetRange") or location.get("targetSelectionRange")
153
+ )
154
+ path = "<unknown>"
155
+ if isinstance(uri, str):
156
+ file_path = uri_to_path(uri)
157
+ if file_path:
158
+ path = str(file_path)
159
+ line = 0
160
+ character = 0
161
+ if isinstance(range_info, dict):
162
+ start = range_info.get("start")
163
+ if isinstance(start, dict):
164
+ line = int(start.get("line", 0)) + 1
165
+ character = int(start.get("character", 0)) + 1
166
+ return path, line, character
167
+
168
+
169
+ def _format_locations(label: str, locations: List[Dict[str, Any]]) -> Tuple[str, int, int]:
170
+ if not locations:
171
+ return f"No {label} found.", 0, 0
172
+
173
+ unique_files = set()
174
+ lines: List[str] = []
175
+ for loc in locations[:MAX_RESULTS]:
176
+ path, line, char = _location_to_path_line_char(loc)
177
+ unique_files.add(path)
178
+ lines.append(f"{path}:{line}:{char}")
179
+
180
+ omitted = len(locations) - len(lines)
181
+ if omitted > 0:
182
+ lines.append(f"... {omitted} more result(s) not shown")
183
+
184
+ summary = f"{len(locations)} {label} found in {len(unique_files)} file(s)."
185
+ return f"{summary}\n\n" + "\n".join(lines), len(locations), len(unique_files)
186
+
187
+
188
+ def _format_hover(result: Any) -> Tuple[str, int, int]:
189
+ if not result:
190
+ return "No hover information found.", 0, 0
191
+
192
+ if isinstance(result, str):
193
+ text = result.strip()
194
+ return (text, 1, 1) if text else ("No hover information found.", 0, 0)
195
+
196
+ contents = result.get("contents") if isinstance(result, dict) else None
197
+ if not contents:
198
+ return "No hover information found.", 0, 0
199
+
200
+ if isinstance(contents, dict):
201
+ value = contents.get("value")
202
+ text = value if isinstance(value, str) else str(contents)
203
+ elif isinstance(contents, list):
204
+ parts = []
205
+ for item in contents:
206
+ if isinstance(item, str):
207
+ parts.append(item)
208
+ elif isinstance(item, dict):
209
+ value = item.get("value")
210
+ parts.append(value if isinstance(value, str) else str(item))
211
+ else:
212
+ parts.append(str(item))
213
+ text = "\n".join([part for part in parts if part])
214
+ else:
215
+ text = str(contents)
216
+
217
+ text = text.strip()
218
+ if not text:
219
+ return "No hover information found.", 0, 0
220
+ return text, 1, 1
221
+
222
+
223
+ def _flatten_document_symbols(
224
+ symbols: List[Dict[str, Any]],
225
+ depth: int = 0,
226
+ lines: Optional[List[str]] = None,
227
+ ) -> Tuple[List[str], int]:
228
+ if lines is None:
229
+ lines = []
230
+ count = 0
231
+
232
+ for symbol in symbols:
233
+ count += 1
234
+ name = symbol.get("name", "<unknown>")
235
+ detail = symbol.get("detail")
236
+ kind = _symbol_kind_name(symbol.get("kind"))
237
+ selection = symbol.get("selectionRange") or symbol.get("range") or {}
238
+ start = selection.get("start") if isinstance(selection, dict) else {}
239
+ line = int(start.get("line", 0)) + 1 if isinstance(start, dict) else 0
240
+ char = int(start.get("character", 0)) + 1 if isinstance(start, dict) else 0
241
+ prefix = " " * depth
242
+ detail_text = f" - {detail}" if detail else ""
243
+ lines.append(f"{prefix}{name}{detail_text} ({kind}) @ {line}:{char}")
244
+
245
+ children = symbol.get("children")
246
+ if isinstance(children, list) and children:
247
+ child_lines, child_count = _flatten_document_symbols(children, depth + 1, lines)
248
+ count += child_count
249
+ lines = child_lines
250
+
251
+ return lines, count
252
+
253
+
254
+ def _format_document_symbols(result: Any) -> Tuple[str, int, int]:
255
+ if not result:
256
+ return "No document symbols found.", 0, 0
257
+
258
+ symbols: List[Dict[str, Any]] = []
259
+ if isinstance(result, list):
260
+ symbols = [s for s in result if isinstance(s, dict)]
261
+ if not symbols:
262
+ return "No document symbols found.", 0, 0
263
+
264
+ lines, count = _flatten_document_symbols(symbols)
265
+ if len(lines) > MAX_RESULTS:
266
+ omitted = len(lines) - MAX_RESULTS
267
+ lines = lines[:MAX_RESULTS] + [f"... {omitted} more result(s) not shown"]
268
+
269
+ summary = f"{count} symbol(s) found in document."
270
+ return f"{summary}\n\n" + "\n".join(lines), count, 1
271
+
272
+
273
+ def _format_workspace_symbols(result: Any) -> Tuple[str, int, int]:
274
+ if not result:
275
+ return "No workspace symbols found.", 0, 0
276
+
277
+ symbols: List[Dict[str, Any]] = []
278
+ if isinstance(result, list):
279
+ symbols = [s for s in result if isinstance(s, dict)]
280
+ if not symbols:
281
+ return "No workspace symbols found.", 0, 0
282
+
283
+ unique_files = set()
284
+ lines: List[str] = []
285
+ for symbol in symbols[:MAX_RESULTS]:
286
+ name = symbol.get("name", "<unknown>")
287
+ kind = _symbol_kind_name(symbol.get("kind"))
288
+ container = symbol.get("containerName")
289
+ location = None
290
+ if isinstance(symbol.get("location"), dict):
291
+ location = symbol.get("location")
292
+ else:
293
+ locations = symbol.get("locations")
294
+ if isinstance(locations, list) and locations:
295
+ first = locations[0]
296
+ if isinstance(first, dict):
297
+ location = first
298
+ path, line, char = _location_to_path_line_char(location)
299
+ unique_files.add(path)
300
+ container_text = f" ({container})" if container else ""
301
+ lines.append(f"{name}{container_text} ({kind}) {path}:{line}:{char}")
302
+
303
+ omitted = len(symbols) - len(lines)
304
+ if omitted > 0:
305
+ lines.append(f"... {omitted} more result(s) not shown")
306
+
307
+ summary = f"{len(symbols)} symbol(s) found in {len(unique_files)} file(s)."
308
+ return f"{summary}\n\n" + "\n".join(lines), len(symbols), len(unique_files)
309
+
310
+
311
+ class LspToolInput(BaseModel):
312
+ """Input schema for LspTool."""
313
+
314
+ model_config = ConfigDict(populate_by_name=True)
315
+
316
+ operation: Literal[
317
+ "goToDefinition",
318
+ "findReferences",
319
+ "hover",
320
+ "documentSymbol",
321
+ "workspaceSymbol",
322
+ "goToImplementation",
323
+ ] = Field(description="The LSP operation to perform.")
324
+ file_path: str = Field(
325
+ validation_alias="filePath",
326
+ serialization_alias="filePath",
327
+ description="The absolute or relative path to the file",
328
+ )
329
+ line: int = Field(ge=1, description="The line number (1-based, as shown in editors)")
330
+ character: int = Field(ge=1, description="The character offset (1-based, as shown in editors)")
331
+
332
+
333
+ class LspToolOutput(BaseModel):
334
+ """Output from LspTool."""
335
+
336
+ model_config = ConfigDict(populate_by_name=True)
337
+
338
+ operation: str
339
+ result: str
340
+ file_path: str = Field(validation_alias="filePath", serialization_alias="filePath")
341
+ is_error: bool = Field(
342
+ default=False,
343
+ validation_alias="is_error",
344
+ serialization_alias="is_error",
345
+ description="Whether the LSP operation failed.",
346
+ )
347
+ result_count: Optional[int] = Field(
348
+ default=None,
349
+ validation_alias="resultCount",
350
+ serialization_alias="resultCount",
351
+ )
352
+ file_count: Optional[int] = Field(
353
+ default=None,
354
+ validation_alias="fileCount",
355
+ serialization_alias="fileCount",
356
+ )
357
+
358
+
359
+ class LspTool(Tool[LspToolInput, LspToolOutput]):
360
+ """Tool for LSP-backed code intelligence."""
361
+
362
+ @property
363
+ def name(self) -> str:
364
+ return "LSP"
365
+
366
+ async def description(self) -> str:
367
+ return LSP_USAGE
368
+
369
+ @property
370
+ def input_schema(self) -> type[LspToolInput]:
371
+ return LspToolInput
372
+
373
+ def input_examples(self) -> List[ToolUseExample]:
374
+ return [
375
+ ToolUseExample(
376
+ description="Jump to a symbol definition",
377
+ example={
378
+ "operation": "goToDefinition",
379
+ "filePath": "src/main.py",
380
+ "line": 12,
381
+ "character": 8,
382
+ },
383
+ ),
384
+ ToolUseExample(
385
+ description="Find references to a function",
386
+ example={
387
+ "operation": "findReferences",
388
+ "filePath": "src/main.py",
389
+ "line": 12,
390
+ "character": 8,
391
+ },
392
+ ),
393
+ ToolUseExample(
394
+ description="List document symbols",
395
+ example={
396
+ "operation": "documentSymbol",
397
+ "filePath": "src/main.py",
398
+ "line": 1,
399
+ "character": 1,
400
+ },
401
+ ),
402
+ ]
403
+
404
+ async def prompt(self, _yolo_mode: bool = False) -> str:
405
+ return LSP_USAGE
406
+
407
+ def is_read_only(self) -> bool:
408
+ return True
409
+
410
+ def is_concurrency_safe(self) -> bool:
411
+ return True
412
+
413
+ def needs_permissions(self, _input_data: Optional[LspToolInput] = None) -> bool:
414
+ return False
415
+
416
+ async def validate_input(
417
+ self, input_data: LspToolInput, _context: Optional[ToolUseContext] = None
418
+ ) -> ValidationResult:
419
+ try:
420
+ resolved_path = _resolve_file_path(input_data.file_path)
421
+ except (OSError, RuntimeError, ValueError) as exc:
422
+ return ValidationResult(result=False, message=str(exc))
423
+
424
+ if not resolved_path.exists():
425
+ return ValidationResult(result=False, message=f"File not found: {input_data.file_path}")
426
+ if not resolved_path.is_file():
427
+ return ValidationResult(
428
+ result=False, message=f"Path is not a file: {input_data.file_path}"
429
+ )
430
+
431
+ should_proceed, warning_msg = check_path_for_tool(
432
+ resolved_path, tool_name="LSP", warn_only=True
433
+ )
434
+ if warning_msg:
435
+ logger.info("[lsp_tool] %s", warning_msg)
436
+ if not should_proceed:
437
+ return ValidationResult(result=False, message=warning_msg or "Access denied.")
438
+
439
+ return ValidationResult(result=True)
440
+
441
+ def render_result_for_assistant(self, output: LspToolOutput) -> str:
442
+ return output.result
443
+
444
+ def render_tool_use_message(self, input_data: LspToolInput, verbose: bool = False) -> str:
445
+ try:
446
+ file_path = _resolve_file_path(input_data.file_path)
447
+ except (OSError, RuntimeError, ValueError):
448
+ file_path = Path(input_data.file_path)
449
+
450
+ symbol = None
451
+ if input_data.operation in {
452
+ "goToDefinition",
453
+ "findReferences",
454
+ "hover",
455
+ "goToImplementation",
456
+ "workspaceSymbol",
457
+ }:
458
+ try:
459
+ text = _read_text(file_path)
460
+ lines = text.splitlines()
461
+ _line_index, char_index, line_text = _normalize_position(
462
+ lines, input_data.line, input_data.character
463
+ )
464
+ symbol = _extract_symbol_at_position(line_text, char_index)
465
+ except (OSError, RuntimeError, UnicodeDecodeError):
466
+ symbol = None
467
+
468
+ parts = [f'operation: "{input_data.operation}"']
469
+ if symbol:
470
+ parts.append(f'symbol: "{symbol}"')
471
+ parts.append(f'file: "{_display_path(file_path, verbose)}"')
472
+ if not symbol:
473
+ parts.append(f"position: {input_data.line}:{input_data.character}")
474
+ return ", ".join(parts)
475
+
476
+ async def call(
477
+ self, input_data: LspToolInput, _context: ToolUseContext
478
+ ) -> AsyncGenerator[ToolOutput, None]:
479
+ try:
480
+ file_path = _resolve_file_path(input_data.file_path)
481
+ text = _read_text(file_path)
482
+ lines = text.splitlines()
483
+ line_index, char_index, line_text = _normalize_position(
484
+ lines, input_data.line, input_data.character
485
+ )
486
+ symbol = _extract_symbol_at_position(line_text, char_index)
487
+ except (OSError, RuntimeError, UnicodeDecodeError, ValueError) as exc:
488
+ output = LspToolOutput(
489
+ operation=input_data.operation,
490
+ result=f"Error reading file for LSP: {exc}",
491
+ file_path=input_data.file_path,
492
+ is_error=True,
493
+ )
494
+ yield ToolResult(data=output, result_for_assistant=output.result)
495
+ return
496
+
497
+ operation = input_data.operation
498
+ method: Optional[str] = None
499
+ params: Optional[Dict[str, Any]] = None
500
+
501
+ position = {"line": line_index, "character": char_index}
502
+ text_document = {"uri": file_path.resolve().as_uri()}
503
+
504
+ if operation == "goToDefinition":
505
+ method = "textDocument/definition"
506
+ params = {"textDocument": text_document, "position": position}
507
+ elif operation == "findReferences":
508
+ method = "textDocument/references"
509
+ params = {
510
+ "textDocument": text_document,
511
+ "position": position,
512
+ "context": {"includeDeclaration": True},
513
+ }
514
+ elif operation == "hover":
515
+ method = "textDocument/hover"
516
+ params = {"textDocument": text_document, "position": position}
517
+ elif operation == "documentSymbol":
518
+ method = "textDocument/documentSymbol"
519
+ params = {"textDocument": text_document}
520
+ elif operation == "workspaceSymbol":
521
+ if not symbol:
522
+ output = LspToolOutput(
523
+ operation=operation,
524
+ result="No symbol found at the given position to search in workspace.",
525
+ file_path=input_data.file_path,
526
+ )
527
+ yield ToolResult(data=output, result_for_assistant=output.result)
528
+ return
529
+ method = "workspace/symbol"
530
+ params = {"query": symbol}
531
+ elif operation == "goToImplementation":
532
+ method = "textDocument/implementation"
533
+ params = {"textDocument": text_document, "position": position}
534
+ else:
535
+ output = LspToolOutput(
536
+ operation=operation,
537
+ result=f"Unknown LSP operation: {operation}",
538
+ file_path=input_data.file_path,
539
+ is_error=True,
540
+ )
541
+ yield ToolResult(data=output, result_for_assistant=output.result)
542
+ return
543
+
544
+ manager = await ensure_lsp_manager(Path.cwd())
545
+ server_info = await manager.server_for_path(file_path)
546
+ if not server_info:
547
+ output = LspToolOutput(
548
+ operation=operation,
549
+ result=(
550
+ f"No LSP server available for file type: {file_path.suffix or 'unknown'}. "
551
+ "Configure servers in ~/.ripperdoc/lsp.json, ~/.lsp.json, "
552
+ ".ripperdoc/lsp.json, or .lsp.json."
553
+ ),
554
+ file_path=input_data.file_path,
555
+ is_error=True,
556
+ )
557
+ yield ToolResult(data=output, result_for_assistant=output.result)
558
+ return
559
+
560
+ server, _config, language_id = server_info
561
+
562
+ try:
563
+ await server.ensure_initialized()
564
+ if method.startswith("textDocument/"):
565
+ await server.ensure_document_open(file_path, text, language_id)
566
+ result = await server.request(method, params)
567
+ except (LspLaunchError, LspProtocolError, LspRequestError) as exc:
568
+ output = LspToolOutput(
569
+ operation=operation,
570
+ result=f"Error performing {operation}: {exc}",
571
+ file_path=input_data.file_path,
572
+ is_error=True,
573
+ )
574
+ yield ToolResult(data=output, result_for_assistant=output.result)
575
+ return
576
+
577
+ formatted: str
578
+ result_count: Optional[int] = None
579
+ file_count: Optional[int] = None
580
+
581
+ if operation == "goToDefinition":
582
+ if isinstance(result, dict):
583
+ result = [result]
584
+ formatted, result_count, file_count = _format_locations("definition(s)", result or [])
585
+ elif operation == "findReferences":
586
+ formatted, result_count, file_count = _format_locations("reference(s)", result or [])
587
+ elif operation == "hover":
588
+ formatted, result_count, file_count = _format_hover(result or {})
589
+ elif operation == "documentSymbol":
590
+ formatted, result_count, file_count = _format_document_symbols(result)
591
+ elif operation == "workspaceSymbol":
592
+ formatted, result_count, file_count = _format_workspace_symbols(result)
593
+ elif operation == "goToImplementation":
594
+ if isinstance(result, dict):
595
+ result = [result]
596
+ formatted, result_count, file_count = _format_locations(
597
+ "implementation(s)", result or []
598
+ )
599
+ else:
600
+ formatted = str(result)
601
+
602
+ output = LspToolOutput(
603
+ operation=operation,
604
+ result=formatted,
605
+ file_path=input_data.file_path,
606
+ result_count=result_count,
607
+ file_count=file_count,
608
+ )
609
+ yield ToolResult(data=output, result_for_assistant=output.result)
@@ -20,6 +20,7 @@ from ripperdoc.core.tool import (
20
20
  )
21
21
  from ripperdoc.utils.log import get_logger
22
22
  from ripperdoc.utils.file_watch import record_snapshot
23
+ from ripperdoc.tools.file_read_tool import detect_file_encoding
23
24
 
24
25
  logger = get_logger()
25
26
 
@@ -341,10 +342,18 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
341
342
 
342
343
  existing = file_path.exists()
343
344
  original_content = ""
345
+ file_encoding = "utf-8"
346
+
347
+ # Detect file encoding if file exists
348
+ if existing:
349
+ detected_encoding, _ = detect_file_encoding(str(file_path))
350
+ if detected_encoding:
351
+ file_encoding = detected_encoding
352
+
344
353
  try:
345
354
  if existing:
346
- original_content = file_path.read_text(encoding="utf-8")
347
- except (OSError, IOError, PermissionError) as exc:
355
+ original_content = file_path.read_text(encoding=file_encoding)
356
+ except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
348
357
  # pragma: no cover - unlikely permission issue
349
358
  logger.warning(
350
359
  "[multi_edit_tool] Error reading file before edits: %s: %s",
@@ -396,13 +405,27 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
396
405
 
397
406
  # Ensure parent exists (validated earlier) and write the file.
398
407
  file_path.parent.mkdir(parents=True, exist_ok=True)
408
+
409
+ # Verify content can be encoded, fall back to UTF-8 if needed
410
+ write_encoding = file_encoding
411
+ try:
412
+ updated_content.encode(file_encoding)
413
+ except (UnicodeEncodeError, LookupError):
414
+ logger.info(
415
+ "New content cannot be encoded with %s, using UTF-8 for %s",
416
+ file_encoding,
417
+ str(file_path),
418
+ )
419
+ write_encoding = "utf-8"
420
+
399
421
  try:
400
- file_path.write_text(updated_content, encoding="utf-8")
422
+ file_path.write_text(updated_content, encoding=write_encoding)
401
423
  try:
402
424
  record_snapshot(
403
425
  str(file_path),
404
426
  updated_content,
405
427
  getattr(context, "file_state_cache", {}),
428
+ encoding=write_encoding,
406
429
  )
407
430
  except (OSError, IOError, RuntimeError) as exc:
408
431
  logger.warning(