patch-code 0.1.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 (153) hide show
  1. patch/__init__.py +3 -0
  2. patch/__main__.py +4 -0
  3. patch/analytics.py +268 -0
  4. patch/args.py +949 -0
  5. patch/args_formatter.py +227 -0
  6. patch/coders/__init__.py +34 -0
  7. patch/coders/architect_coder.py +48 -0
  8. patch/coders/architect_prompts.py +40 -0
  9. patch/coders/ask_coder.py +9 -0
  10. patch/coders/ask_prompts.py +41 -0
  11. patch/coders/base_coder.py +2485 -0
  12. patch/coders/base_prompts.py +60 -0
  13. patch/coders/chat_chunks.py +64 -0
  14. patch/coders/context_coder.py +53 -0
  15. patch/coders/context_prompts.py +75 -0
  16. patch/coders/editblock_coder.py +657 -0
  17. patch/coders/editblock_fenced_coder.py +10 -0
  18. patch/coders/editblock_fenced_prompts.py +143 -0
  19. patch/coders/editblock_func_coder.py +141 -0
  20. patch/coders/editblock_func_prompts.py +27 -0
  21. patch/coders/editblock_prompts.py +172 -0
  22. patch/coders/editor_diff_fenced_coder.py +9 -0
  23. patch/coders/editor_diff_fenced_prompts.py +11 -0
  24. patch/coders/editor_editblock_coder.py +8 -0
  25. patch/coders/editor_editblock_prompts.py +18 -0
  26. patch/coders/editor_whole_coder.py +8 -0
  27. patch/coders/editor_whole_prompts.py +10 -0
  28. patch/coders/help_coder.py +16 -0
  29. patch/coders/help_prompts.py +46 -0
  30. patch/coders/patch_coder.py +706 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +429 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1712 -0
  45. patch/copypaste.py +72 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/docs/__init__.py +1 -0
  49. patch/docs/analytics.md +28 -0
  50. patch/docs/config.md +43 -0
  51. patch/docs/git.md +22 -0
  52. patch/docs/install.md +54 -0
  53. patch/docs/models.md +59 -0
  54. patch/docs/troubleshooting.md +23 -0
  55. patch/docs/usage.md +47 -0
  56. patch/dump.py +29 -0
  57. patch/editor.py +147 -0
  58. patch/exceptions.py +113 -0
  59. patch/format_settings.py +26 -0
  60. patch/gui.py +545 -0
  61. patch/help.py +118 -0
  62. patch/history.py +143 -0
  63. patch/io.py +1191 -0
  64. patch/linter.py +304 -0
  65. patch/llm.py +47 -0
  66. patch/main.py +1274 -0
  67. patch/mdstream.py +243 -0
  68. patch/models.py +1338 -0
  69. patch/onboarding.py +428 -0
  70. patch/openrouter.py +128 -0
  71. patch/prompts.py +61 -0
  72. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  73. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  74. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  75. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  76. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  77. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  78. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  79. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  80. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  81. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  82. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  83. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  84. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  85. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  86. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  87. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  88. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  89. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  90. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  91. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  92. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  93. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  94. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  95. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  96. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  97. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  98. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  99. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  100. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  101. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  102. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  103. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  104. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  105. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  106. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  107. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  108. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  109. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  110. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  111. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  112. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  113. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  114. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  115. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  116. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  117. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  118. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  119. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  120. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  121. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  122. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  123. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  124. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  125. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  126. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  127. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  128. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  129. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  130. patch/reasoning_tags.py +82 -0
  131. patch/repo.py +622 -0
  132. patch/repomap.py +867 -0
  133. patch/report.py +200 -0
  134. patch/resources/__init__.py +3 -0
  135. patch/resources/model-metadata.json +715 -0
  136. patch/resources/model-settings.yml +3128 -0
  137. patch/run_cmd.py +132 -0
  138. patch/scrape.py +284 -0
  139. patch/sendchat.py +61 -0
  140. patch/special.py +203 -0
  141. patch/urls.py +17 -0
  142. patch/utils.py +348 -0
  143. patch/versioncheck.py +130 -0
  144. patch/voice.py +187 -0
  145. patch/waiting.py +221 -0
  146. patch/watch.py +318 -0
  147. patch/watch_prompts.py +12 -0
  148. patch_code-0.1.0.dist-info/METADATA +467 -0
  149. patch_code-0.1.0.dist-info/RECORD +153 -0
  150. patch_code-0.1.0.dist-info/WHEEL +5 -0
  151. patch_code-0.1.0.dist-info/entry_points.txt +2 -0
  152. patch_code-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  153. patch_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2485 @@
1
+ #!/usr/bin/env python
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import locale
7
+ import math
8
+ import mimetypes
9
+ import os
10
+ import platform
11
+ import re
12
+ import sys
13
+ import threading
14
+ import time
15
+ import traceback
16
+ from collections import defaultdict
17
+ from datetime import datetime
18
+
19
+ # Optional dependency: used to convert locale codes (eg ``en_US``)
20
+ # into human-readable language names (eg ``English``).
21
+ try:
22
+ from babel import Locale # type: ignore
23
+ except ImportError: # Babel not installed – we will fall back to a small mapping
24
+ Locale = None
25
+ from json.decoder import JSONDecodeError
26
+ from pathlib import Path
27
+ from typing import List
28
+
29
+ from rich.console import Console
30
+
31
+ from patch import __version__, models, prompts, urls, utils
32
+ from patch.analytics import Analytics
33
+ from patch.commands import Commands
34
+ from patch.exceptions import LiteLLMExceptions
35
+ from patch.history import ChatSummary
36
+ from patch.io import ConfirmGroup, InputOutput
37
+ from patch.linter import Linter
38
+ from patch.llm import litellm
39
+ from patch.models import RETRY_TIMEOUT
40
+ from patch.reasoning_tags import (
41
+ REASONING_TAG,
42
+ format_reasoning_content,
43
+ remove_reasoning_content,
44
+ replace_reasoning_tags,
45
+ )
46
+ from patch.repo import ANY_GIT_ERROR, GitRepo
47
+ from patch.repomap import RepoMap
48
+ from patch.run_cmd import run_cmd
49
+ from patch.utils import format_content, format_messages, format_tokens, is_image_file
50
+ from patch.waiting import WaitingSpinner
51
+
52
+ from ..dump import dump # noqa: F401
53
+ from .chat_chunks import ChatChunks
54
+
55
+
56
+ class UnknownEditFormat(ValueError):
57
+ def __init__(self, edit_format, valid_formats):
58
+ self.edit_format = edit_format
59
+ self.valid_formats = valid_formats
60
+ super().__init__(
61
+ f"Unknown edit format {edit_format}. Valid formats are: {', '.join(valid_formats)}"
62
+ )
63
+
64
+
65
+ class MissingAPIKeyError(ValueError):
66
+ pass
67
+
68
+
69
+ class FinishReasonLength(Exception):
70
+ pass
71
+
72
+
73
+ def wrap_fence(name):
74
+ return f"<{name}>", f"</{name}>"
75
+
76
+
77
+ all_fences = [
78
+ ("`" * 3, "`" * 3),
79
+ ("`" * 4, "`" * 4), # LLMs ignore and revert to triple-backtick, causing #2879
80
+ wrap_fence("source"),
81
+ wrap_fence("code"),
82
+ wrap_fence("pre"),
83
+ wrap_fence("codeblock"),
84
+ wrap_fence("sourcecode"),
85
+ ]
86
+
87
+
88
+ class Coder:
89
+ abs_fnames = None
90
+ abs_read_only_fnames = None
91
+ repo = None
92
+ last_patch_commit_hash = None
93
+ patch_edited_files = None
94
+ last_asked_for_commit_time = 0
95
+ repo_map = None
96
+ functions = None
97
+ num_exhausted_context_windows = 0
98
+ num_malformed_responses = 0
99
+ last_keyboard_interrupt = None
100
+ num_reflections = 0
101
+ max_reflections = 3
102
+ edit_format = None
103
+ yield_stream = False
104
+ temperature = None
105
+ auto_lint = True
106
+ auto_test = False
107
+ test_cmd = None
108
+ lint_outcome = None
109
+ test_outcome = None
110
+ multi_response_content = ""
111
+ partial_response_content = ""
112
+ commit_before_message = []
113
+ message_cost = 0.0
114
+ add_cache_headers = False
115
+ cache_warming_thread = None
116
+ num_cache_warming_pings = 0
117
+ suggest_shell_commands = True
118
+ detect_urls = True
119
+ ignore_mentions = None
120
+ chat_language = None
121
+ commit_language = None
122
+ file_watcher = None
123
+
124
+ @classmethod
125
+ def create(
126
+ self,
127
+ main_model=None,
128
+ edit_format=None,
129
+ io=None,
130
+ from_coder=None,
131
+ summarize_from_coder=True,
132
+ **kwargs,
133
+ ):
134
+ import patch.coders as coders
135
+
136
+ if not main_model:
137
+ if from_coder:
138
+ main_model = from_coder.main_model
139
+ else:
140
+ main_model = models.Model(models.DEFAULT_MODEL_NAME)
141
+
142
+ if edit_format == "code":
143
+ edit_format = None
144
+ if edit_format is None:
145
+ if from_coder:
146
+ edit_format = from_coder.edit_format
147
+ else:
148
+ edit_format = main_model.edit_format
149
+
150
+ if not io and from_coder:
151
+ io = from_coder.io
152
+
153
+ if from_coder:
154
+ use_kwargs = dict(from_coder.original_kwargs) # copy orig kwargs
155
+
156
+ # If the edit format changes, we can't leave old ASSISTANT
157
+ # messages in the chat history. The old edit format will
158
+ # confused the new LLM. It may try and imitate it, disobeying
159
+ # the system prompt.
160
+ done_messages = from_coder.done_messages
161
+ if edit_format != from_coder.edit_format and done_messages and summarize_from_coder:
162
+ try:
163
+ done_messages = from_coder.summarizer.summarize_all(done_messages)
164
+ except ValueError:
165
+ # If summarization fails, keep the original messages and warn the user
166
+ io.tool_warning(
167
+ "Chat history summarization failed, continuing with full history"
168
+ )
169
+
170
+ # Bring along context from the old Coder
171
+ update = dict(
172
+ fnames=list(from_coder.abs_fnames),
173
+ read_only_fnames=list(from_coder.abs_read_only_fnames), # Copy read-only files
174
+ done_messages=done_messages,
175
+ cur_messages=from_coder.cur_messages,
176
+ patch_commit_hashes=from_coder.patch_commit_hashes,
177
+ commands=from_coder.commands.clone(),
178
+ total_cost=from_coder.total_cost,
179
+ ignore_mentions=from_coder.ignore_mentions,
180
+ total_tokens_sent=from_coder.total_tokens_sent,
181
+ total_tokens_received=from_coder.total_tokens_received,
182
+ file_watcher=from_coder.file_watcher,
183
+ )
184
+ use_kwargs.update(update) # override to complete the switch
185
+ use_kwargs.update(kwargs) # override passed kwargs
186
+
187
+ kwargs = use_kwargs
188
+ from_coder.ok_to_warm_cache = False
189
+
190
+ for coder in coders.__all__:
191
+ if hasattr(coder, "edit_format") and coder.edit_format == edit_format:
192
+ res = coder(main_model, io, **kwargs)
193
+ res.original_kwargs = dict(kwargs)
194
+ return res
195
+
196
+ valid_formats = [
197
+ str(c.edit_format)
198
+ for c in coders.__all__
199
+ if hasattr(c, "edit_format") and c.edit_format is not None
200
+ ]
201
+ raise UnknownEditFormat(edit_format, valid_formats)
202
+
203
+ def clone(self, **kwargs):
204
+ new_coder = Coder.create(from_coder=self, **kwargs)
205
+ return new_coder
206
+
207
+ def get_announcements(self):
208
+ lines = []
209
+ lines.append(f"Patch v{__version__}")
210
+
211
+ # Model
212
+ main_model = self.main_model
213
+ weak_model = main_model.weak_model
214
+
215
+ if weak_model is not main_model:
216
+ prefix = "Main model"
217
+ else:
218
+ prefix = "Model"
219
+
220
+ output = f"{prefix}: {main_model.name} with {self.edit_format} edit format"
221
+
222
+ # Check for thinking token budget
223
+ thinking_tokens = main_model.get_thinking_tokens()
224
+ if thinking_tokens:
225
+ output += f", {thinking_tokens} think tokens"
226
+
227
+ # Check for reasoning effort
228
+ reasoning_effort = main_model.get_reasoning_effort()
229
+ if reasoning_effort:
230
+ output += f", reasoning {reasoning_effort}"
231
+
232
+ if self.add_cache_headers or main_model.caches_by_default:
233
+ output += ", prompt cache"
234
+ if main_model.info.get("supports_assistant_prefill"):
235
+ output += ", infinite output"
236
+
237
+ lines.append(output)
238
+
239
+ if self.edit_format == "architect":
240
+ output = (
241
+ f"Editor model: {main_model.editor_model.name} with"
242
+ f" {main_model.editor_edit_format} edit format"
243
+ )
244
+ lines.append(output)
245
+
246
+ if weak_model is not main_model:
247
+ output = f"Weak model: {weak_model.name}"
248
+ lines.append(output)
249
+
250
+ # Repo
251
+ if self.repo:
252
+ rel_repo_dir = self.repo.get_rel_repo_dir()
253
+ num_files = len(self.repo.get_tracked_files())
254
+
255
+ lines.append(f"Git repo: {rel_repo_dir} with {num_files:,} files")
256
+ if num_files > 1000:
257
+ lines.append(
258
+ "Warning: For large repos, consider using --subtree-only and .patchignore"
259
+ )
260
+ lines.append(f"See: {urls.large_repos}")
261
+ else:
262
+ lines.append("Git repo: none")
263
+
264
+ # Repo-map
265
+ if self.repo_map:
266
+ map_tokens = self.repo_map.max_map_tokens
267
+ if map_tokens > 0:
268
+ refresh = self.repo_map.refresh
269
+ lines.append(f"Repo-map: using {map_tokens} tokens, {refresh} refresh")
270
+ max_map_tokens = self.main_model.get_repo_map_tokens() * 2
271
+ if map_tokens > max_map_tokens:
272
+ lines.append(
273
+ f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much"
274
+ " irrelevant code can confuse LLMs."
275
+ )
276
+ else:
277
+ lines.append("Repo-map: disabled because map_tokens == 0")
278
+ else:
279
+ lines.append("Repo-map: disabled")
280
+
281
+ # Files
282
+ for fname in self.get_inchat_relative_files():
283
+ lines.append(f"Added {fname} to the chat.")
284
+
285
+ for fname in self.abs_read_only_fnames:
286
+ rel_fname = self.get_rel_fname(fname)
287
+ lines.append(f"Added {rel_fname} to the chat (read-only).")
288
+
289
+ if self.done_messages:
290
+ lines.append("Restored previous conversation history.")
291
+
292
+ if self.io.multiline_mode:
293
+ lines.append("Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text")
294
+
295
+ return lines
296
+
297
+ ok_to_warm_cache = False
298
+
299
+ def __init__(
300
+ self,
301
+ main_model,
302
+ io,
303
+ repo=None,
304
+ fnames=None,
305
+ add_gitignore_files=False,
306
+ read_only_fnames=None,
307
+ show_diffs=False,
308
+ auto_commits=True,
309
+ dirty_commits=True,
310
+ dry_run=False,
311
+ map_tokens=1024,
312
+ verbose=False,
313
+ stream=True,
314
+ use_git=True,
315
+ cur_messages=None,
316
+ done_messages=None,
317
+ restore_chat_history=False,
318
+ auto_lint=True,
319
+ auto_test=False,
320
+ lint_cmds=None,
321
+ test_cmd=None,
322
+ patch_commit_hashes=None,
323
+ map_mul_no_files=8,
324
+ commands=None,
325
+ summarizer=None,
326
+ total_cost=0.0,
327
+ analytics=None,
328
+ map_refresh="auto",
329
+ cache_prompts=False,
330
+ num_cache_warming_pings=0,
331
+ suggest_shell_commands=True,
332
+ chat_language=None,
333
+ commit_language=None,
334
+ detect_urls=True,
335
+ ignore_mentions=None,
336
+ total_tokens_sent=0,
337
+ total_tokens_received=0,
338
+ file_watcher=None,
339
+ auto_copy_context=False,
340
+ auto_accept_architect=True,
341
+ ):
342
+ # Fill in a dummy Analytics if needed, but it is never .enable()'d
343
+ self.analytics = analytics if analytics is not None else Analytics()
344
+
345
+ self.event = self.analytics.event
346
+ self.chat_language = chat_language
347
+ self.commit_language = commit_language
348
+ self.commit_before_message = []
349
+ self.patch_commit_hashes = set()
350
+ self.rejected_urls = set()
351
+ self.abs_root_path_cache = {}
352
+
353
+ self.auto_copy_context = auto_copy_context
354
+ self.auto_accept_architect = auto_accept_architect
355
+
356
+ self.ignore_mentions = ignore_mentions
357
+ if not self.ignore_mentions:
358
+ self.ignore_mentions = set()
359
+
360
+ self.file_watcher = file_watcher
361
+ if self.file_watcher:
362
+ self.file_watcher.coder = self
363
+
364
+ self.suggest_shell_commands = suggest_shell_commands
365
+ self.detect_urls = detect_urls
366
+
367
+ self.num_cache_warming_pings = num_cache_warming_pings
368
+
369
+ if not fnames:
370
+ fnames = []
371
+
372
+ if io is None:
373
+ io = InputOutput()
374
+
375
+ if patch_commit_hashes:
376
+ self.patch_commit_hashes = patch_commit_hashes
377
+ else:
378
+ self.patch_commit_hashes = set()
379
+
380
+ self.chat_completion_call_hashes = []
381
+ self.chat_completion_response_hashes = []
382
+ self.need_commit_before_edits = set()
383
+
384
+ self.total_cost = total_cost
385
+ self.total_tokens_sent = total_tokens_sent
386
+ self.total_tokens_received = total_tokens_received
387
+ self.message_tokens_sent = 0
388
+ self.message_tokens_received = 0
389
+
390
+ self.verbose = verbose
391
+ self.abs_fnames = set()
392
+ self.abs_read_only_fnames = set()
393
+ self.add_gitignore_files = add_gitignore_files
394
+
395
+ if cur_messages:
396
+ self.cur_messages = cur_messages
397
+ else:
398
+ self.cur_messages = []
399
+
400
+ if done_messages:
401
+ self.done_messages = done_messages
402
+ else:
403
+ self.done_messages = []
404
+
405
+ self.io = io
406
+
407
+ self.shell_commands = []
408
+
409
+ if not auto_commits:
410
+ dirty_commits = False
411
+
412
+ self.auto_commits = auto_commits
413
+ self.dirty_commits = dirty_commits
414
+
415
+ self.dry_run = dry_run
416
+ self.pretty = self.io.pretty
417
+
418
+ self.main_model = main_model
419
+ # Set the reasoning tag name based on model settings or default
420
+ self.reasoning_tag_name = (
421
+ self.main_model.reasoning_tag if self.main_model.reasoning_tag else REASONING_TAG
422
+ )
423
+
424
+ self.stream = stream and main_model.streaming
425
+
426
+ if cache_prompts and self.main_model.cache_control:
427
+ self.add_cache_headers = True
428
+
429
+ self.show_diffs = show_diffs
430
+
431
+ self.commands = commands or Commands(self.io, self)
432
+ self.commands.coder = self
433
+
434
+ self.repo = repo
435
+ if use_git and self.repo is None:
436
+ try:
437
+ self.repo = GitRepo(
438
+ self.io,
439
+ fnames,
440
+ None,
441
+ models=main_model.commit_message_models(),
442
+ )
443
+ except FileNotFoundError:
444
+ pass
445
+
446
+ if self.repo:
447
+ self.root = self.repo.root
448
+
449
+ for fname in fnames:
450
+ fname = Path(fname)
451
+ if self.repo and self.repo.git_ignored_file(fname) and not self.add_gitignore_files:
452
+ self.io.tool_warning(f"Skipping {fname} that matches gitignore spec.")
453
+ continue
454
+
455
+ if self.repo and self.repo.ignored_file(fname):
456
+ self.io.tool_warning(f"Skipping {fname} that matches patchignore spec.")
457
+ continue
458
+
459
+ if not fname.exists():
460
+ if utils.touch_file(fname):
461
+ self.io.tool_output(f"Creating empty file {fname}")
462
+ else:
463
+ self.io.tool_warning(f"Can not create {fname}, skipping.")
464
+ continue
465
+
466
+ if not fname.is_file():
467
+ self.io.tool_warning(f"Skipping {fname} that is not a normal file.")
468
+ continue
469
+
470
+ fname = str(fname.resolve())
471
+
472
+ self.abs_fnames.add(fname)
473
+ self.check_added_files()
474
+
475
+ if not self.repo:
476
+ self.root = utils.find_common_root(self.abs_fnames)
477
+
478
+ if read_only_fnames:
479
+ self.abs_read_only_fnames = set()
480
+ for fname in read_only_fnames:
481
+ abs_fname = self.abs_root_path(fname)
482
+ if os.path.exists(abs_fname):
483
+ self.abs_read_only_fnames.add(abs_fname)
484
+ else:
485
+ self.io.tool_warning(f"Error: Read-only file {fname} does not exist. Skipping.")
486
+
487
+ if map_tokens is None:
488
+ use_repo_map = main_model.use_repo_map
489
+ map_tokens = 1024
490
+ else:
491
+ use_repo_map = map_tokens > 0
492
+
493
+ max_inp_tokens = self.main_model.info.get("max_input_tokens") or 0
494
+
495
+ has_map_prompt = hasattr(self, "gpt_prompts") and self.gpt_prompts.repo_content_prefix
496
+
497
+ if use_repo_map and self.repo and has_map_prompt:
498
+ self.repo_map = RepoMap(
499
+ map_tokens,
500
+ self.root,
501
+ self.main_model,
502
+ io,
503
+ self.gpt_prompts.repo_content_prefix,
504
+ self.verbose,
505
+ max_inp_tokens,
506
+ map_mul_no_files=map_mul_no_files,
507
+ refresh=map_refresh,
508
+ )
509
+
510
+ self.summarizer = summarizer or ChatSummary(
511
+ [self.main_model.weak_model, self.main_model],
512
+ self.main_model.max_chat_history_tokens,
513
+ )
514
+
515
+ self.summarizer_thread = None
516
+ self.summarized_done_messages = []
517
+ self.summarizing_messages = None
518
+
519
+ if not self.done_messages and restore_chat_history:
520
+ history_md = self.io.read_text(self.io.chat_history_file)
521
+ if history_md:
522
+ self.done_messages = utils.split_chat_history_markdown(history_md)
523
+ self.summarize_start()
524
+
525
+ # Linting and testing
526
+ self.linter = Linter(root=self.root, encoding=io.encoding)
527
+ self.auto_lint = auto_lint
528
+ self.setup_lint_cmds(lint_cmds)
529
+ self.lint_cmds = lint_cmds
530
+ self.auto_test = auto_test
531
+ self.test_cmd = test_cmd
532
+
533
+ # validate the functions jsonschema
534
+ if self.functions:
535
+ from jsonschema import Draft7Validator
536
+
537
+ for function in self.functions:
538
+ Draft7Validator.check_schema(function)
539
+
540
+ if self.verbose:
541
+ self.io.tool_output("JSON Schema:")
542
+ self.io.tool_output(json.dumps(self.functions, indent=4))
543
+
544
+ def setup_lint_cmds(self, lint_cmds):
545
+ if not lint_cmds:
546
+ return
547
+ for lang, cmd in lint_cmds.items():
548
+ self.linter.set_linter(lang, cmd)
549
+
550
+ def show_announcements(self):
551
+ bold = True
552
+ for line in self.get_announcements():
553
+ self.io.tool_output(line, bold=bold)
554
+ bold = False
555
+
556
+ def add_rel_fname(self, rel_fname):
557
+ self.abs_fnames.add(self.abs_root_path(rel_fname))
558
+ self.check_added_files()
559
+
560
+ def drop_rel_fname(self, fname):
561
+ abs_fname = self.abs_root_path(fname)
562
+ if abs_fname in self.abs_fnames:
563
+ self.abs_fnames.remove(abs_fname)
564
+ return True
565
+
566
+ def abs_root_path(self, path):
567
+ key = path
568
+ if key in self.abs_root_path_cache:
569
+ return self.abs_root_path_cache[key]
570
+
571
+ res = Path(self.root) / path
572
+ res = utils.safe_abs_path(res)
573
+ self.abs_root_path_cache[key] = res
574
+ return res
575
+
576
+ fences = all_fences
577
+ fence = fences[0]
578
+
579
+ def show_pretty(self):
580
+ if not self.pretty:
581
+ return False
582
+
583
+ # only show pretty output if fences are the normal triple-backtick
584
+ if self.fence[0][0] != "`":
585
+ return False
586
+
587
+ return True
588
+
589
+ def _stop_waiting_spinner(self):
590
+ """Stop and clear the waiting spinner if it is running."""
591
+ spinner = getattr(self, "waiting_spinner", None)
592
+ if spinner:
593
+ try:
594
+ spinner.stop()
595
+ finally:
596
+ self.waiting_spinner = None
597
+
598
+ def get_abs_fnames_content(self):
599
+ for fname in list(self.abs_fnames):
600
+ content = self.io.read_text(fname)
601
+
602
+ if content is None:
603
+ relative_fname = self.get_rel_fname(fname)
604
+ self.io.tool_warning(f"Dropping {relative_fname} from the chat.")
605
+ self.abs_fnames.remove(fname)
606
+ else:
607
+ yield fname, content
608
+
609
+ def choose_fence(self):
610
+ all_content = ""
611
+ for _fname, content in self.get_abs_fnames_content():
612
+ all_content += content + "\n"
613
+ for _fname in self.abs_read_only_fnames:
614
+ content = self.io.read_text(_fname)
615
+ if content is not None:
616
+ all_content += content + "\n"
617
+
618
+ lines = all_content.splitlines()
619
+ good = False
620
+ for fence_open, fence_close in self.fences:
621
+ if any(line.startswith(fence_open) or line.startswith(fence_close) for line in lines):
622
+ continue
623
+ good = True
624
+ break
625
+
626
+ if good:
627
+ self.fence = (fence_open, fence_close)
628
+ else:
629
+ self.fence = self.fences[0]
630
+ self.io.tool_warning(
631
+ "Unable to find a fencing strategy! Falling back to:"
632
+ f" {self.fence[0]}...{self.fence[1]}"
633
+ )
634
+
635
+ return
636
+
637
+ def get_files_content(self, fnames=None):
638
+ if not fnames:
639
+ fnames = self.abs_fnames
640
+
641
+ prompt = ""
642
+ for fname, content in self.get_abs_fnames_content():
643
+ if not is_image_file(fname):
644
+ relative_fname = self.get_rel_fname(fname)
645
+ prompt += "\n"
646
+ prompt += relative_fname
647
+ prompt += f"\n{self.fence[0]}\n"
648
+
649
+ prompt += content
650
+
651
+ # lines = content.splitlines(keepends=True)
652
+ # lines = [f"{i+1:03}:{line}" for i, line in enumerate(lines)]
653
+ # prompt += "".join(lines)
654
+
655
+ prompt += f"{self.fence[1]}\n"
656
+
657
+ return prompt
658
+
659
+ def get_read_only_files_content(self):
660
+ prompt = ""
661
+ for fname in self.abs_read_only_fnames:
662
+ content = self.io.read_text(fname)
663
+ if content is not None and not is_image_file(fname):
664
+ relative_fname = self.get_rel_fname(fname)
665
+ prompt += "\n"
666
+ prompt += relative_fname
667
+ prompt += f"\n{self.fence[0]}\n"
668
+ prompt += content
669
+ prompt += f"{self.fence[1]}\n"
670
+ return prompt
671
+
672
+ def get_cur_message_text(self):
673
+ text = ""
674
+ for msg in self.cur_messages:
675
+ text += msg["content"] + "\n"
676
+ return text
677
+
678
+ def get_ident_mentions(self, text):
679
+ # Split the string on any character that is not alphanumeric
680
+ # \W+ matches one or more non-word characters (equivalent to [^a-zA-Z0-9_]+)
681
+ words = set(re.split(r"\W+", text))
682
+ return words
683
+
684
+ def get_ident_filename_matches(self, idents):
685
+ all_fnames = defaultdict(set)
686
+ for fname in self.get_all_relative_files():
687
+ # Skip empty paths or just '.'
688
+ if not fname or fname == ".":
689
+ continue
690
+
691
+ try:
692
+ # Handle dotfiles properly
693
+ path = Path(fname)
694
+ base = path.stem.lower() # Use stem instead of with_suffix("").name
695
+ if len(base) >= 5:
696
+ all_fnames[base].add(fname)
697
+ except ValueError:
698
+ # Skip paths that can't be processed
699
+ continue
700
+
701
+ matches = set()
702
+ for ident in idents:
703
+ if len(ident) < 5:
704
+ continue
705
+ matches.update(all_fnames[ident.lower()])
706
+
707
+ return matches
708
+
709
+ def get_repo_map(self, force_refresh=False):
710
+ if not self.repo_map:
711
+ return
712
+
713
+ cur_msg_text = self.get_cur_message_text()
714
+ mentioned_fnames = self.get_file_mentions(cur_msg_text)
715
+ mentioned_idents = self.get_ident_mentions(cur_msg_text)
716
+
717
+ mentioned_fnames.update(self.get_ident_filename_matches(mentioned_idents))
718
+
719
+ all_abs_files = set(self.get_all_abs_files())
720
+ repo_abs_read_only_fnames = set(self.abs_read_only_fnames) & all_abs_files
721
+ chat_files = set(self.abs_fnames) | repo_abs_read_only_fnames
722
+ other_files = all_abs_files - chat_files
723
+
724
+ repo_content = self.repo_map.get_repo_map(
725
+ chat_files,
726
+ other_files,
727
+ mentioned_fnames=mentioned_fnames,
728
+ mentioned_idents=mentioned_idents,
729
+ force_refresh=force_refresh,
730
+ )
731
+
732
+ # fall back to global repo map if files in chat are disjoint from rest of repo
733
+ if not repo_content:
734
+ repo_content = self.repo_map.get_repo_map(
735
+ set(),
736
+ all_abs_files,
737
+ mentioned_fnames=mentioned_fnames,
738
+ mentioned_idents=mentioned_idents,
739
+ )
740
+
741
+ # fall back to completely unhinted repo
742
+ if not repo_content:
743
+ repo_content = self.repo_map.get_repo_map(
744
+ set(),
745
+ all_abs_files,
746
+ )
747
+
748
+ return repo_content
749
+
750
+ def get_repo_messages(self):
751
+ repo_messages = []
752
+ repo_content = self.get_repo_map()
753
+ if repo_content:
754
+ repo_messages += [
755
+ dict(role="user", content=repo_content),
756
+ dict(
757
+ role="assistant",
758
+ content="Ok, I won't try and edit those files without asking first.",
759
+ ),
760
+ ]
761
+ return repo_messages
762
+
763
+ def get_readonly_files_messages(self):
764
+ readonly_messages = []
765
+
766
+ # Handle non-image files
767
+ read_only_content = self.get_read_only_files_content()
768
+ if read_only_content:
769
+ readonly_messages += [
770
+ dict(
771
+ role="user", content=self.gpt_prompts.read_only_files_prefix + read_only_content
772
+ ),
773
+ dict(
774
+ role="assistant",
775
+ content="Ok, I will use these files as references.",
776
+ ),
777
+ ]
778
+
779
+ # Handle image files
780
+ images_message = self.get_images_message(self.abs_read_only_fnames)
781
+ if images_message is not None:
782
+ readonly_messages += [
783
+ images_message,
784
+ dict(role="assistant", content="Ok, I will use these images as references."),
785
+ ]
786
+
787
+ return readonly_messages
788
+
789
+ def get_chat_files_messages(self):
790
+ chat_files_messages = []
791
+ if self.abs_fnames:
792
+ files_content = self.gpt_prompts.files_content_prefix
793
+ files_content += self.get_files_content()
794
+ files_reply = self.gpt_prompts.files_content_assistant_reply
795
+ elif self.get_repo_map() and self.gpt_prompts.files_no_full_files_with_repo_map:
796
+ files_content = self.gpt_prompts.files_no_full_files_with_repo_map
797
+ files_reply = self.gpt_prompts.files_no_full_files_with_repo_map_reply
798
+ else:
799
+ files_content = self.gpt_prompts.files_no_full_files
800
+ files_reply = "Ok."
801
+
802
+ if files_content:
803
+ chat_files_messages += [
804
+ dict(role="user", content=files_content),
805
+ dict(role="assistant", content=files_reply),
806
+ ]
807
+
808
+ images_message = self.get_images_message(self.abs_fnames)
809
+ if images_message is not None:
810
+ chat_files_messages += [
811
+ images_message,
812
+ dict(role="assistant", content="Ok."),
813
+ ]
814
+
815
+ return chat_files_messages
816
+
817
+ def get_images_message(self, fnames):
818
+ supports_images = self.main_model.info.get("supports_vision")
819
+ supports_pdfs = self.main_model.info.get("supports_pdf_input") or self.main_model.info.get(
820
+ "max_pdf_size_mb"
821
+ )
822
+
823
+ # https://github.com/BerriAI/litellm/pull/6928
824
+ supports_pdfs = supports_pdfs or "claude-3-5-sonnet-20241022" in self.main_model.name
825
+
826
+ if not (supports_images or supports_pdfs):
827
+ return None
828
+
829
+ image_messages = []
830
+ for fname in fnames:
831
+ if not is_image_file(fname):
832
+ continue
833
+
834
+ mime_type, _ = mimetypes.guess_type(fname)
835
+ if not mime_type:
836
+ continue
837
+
838
+ with open(fname, "rb") as image_file:
839
+ encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
840
+ image_url = f"data:{mime_type};base64,{encoded_string}"
841
+ rel_fname = self.get_rel_fname(fname)
842
+
843
+ if mime_type.startswith("image/") and supports_images:
844
+ image_messages += [
845
+ {"type": "text", "text": f"Image file: {rel_fname}"},
846
+ {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}},
847
+ ]
848
+ elif mime_type == "application/pdf" and supports_pdfs:
849
+ image_messages += [
850
+ {"type": "text", "text": f"PDF file: {rel_fname}"},
851
+ {"type": "image_url", "image_url": image_url},
852
+ ]
853
+
854
+ if not image_messages:
855
+ return None
856
+
857
+ return {"role": "user", "content": image_messages}
858
+
859
+ def run_stream(self, user_message):
860
+ self.io.user_input(user_message)
861
+ self.init_before_message()
862
+ yield from self.send_message(user_message)
863
+
864
+ def init_before_message(self):
865
+ self.patch_edited_files = set()
866
+ self.reflected_message = None
867
+ self.num_reflections = 0
868
+ self.lint_outcome = None
869
+ self.test_outcome = None
870
+ self.shell_commands = []
871
+ self.message_cost = 0
872
+
873
+ if self.repo:
874
+ self.commit_before_message.append(self.repo.get_head_commit_sha())
875
+
876
+ def run(self, with_message=None, preproc=True):
877
+ try:
878
+ if with_message:
879
+ self.io.user_input(with_message)
880
+ self.run_one(with_message, preproc)
881
+ return self.partial_response_content
882
+ while True:
883
+ try:
884
+ if not self.io.placeholder:
885
+ self.copy_context()
886
+ user_message = self.get_input()
887
+ self.run_one(user_message, preproc)
888
+ self.show_undo_hint()
889
+ except KeyboardInterrupt:
890
+ self.keyboard_interrupt()
891
+ except EOFError:
892
+ return
893
+
894
+ def copy_context(self):
895
+ if self.auto_copy_context:
896
+ self.commands.cmd_copy_context()
897
+
898
+ def get_input(self):
899
+ inchat_files = self.get_inchat_relative_files()
900
+ read_only_files = [self.get_rel_fname(fname) for fname in self.abs_read_only_fnames]
901
+ all_files = sorted(set(inchat_files + read_only_files))
902
+ edit_format = "" if self.edit_format == self.main_model.edit_format else self.edit_format
903
+ return self.io.get_input(
904
+ self.root,
905
+ all_files,
906
+ self.get_addable_relative_files(),
907
+ self.commands,
908
+ self.abs_read_only_fnames,
909
+ edit_format=edit_format,
910
+ )
911
+
912
+ def preproc_user_input(self, inp):
913
+ if not inp:
914
+ return
915
+
916
+ if self.commands.is_command(inp):
917
+ return self.commands.run(inp)
918
+
919
+ self.check_for_file_mentions(inp)
920
+ inp = self.check_for_urls(inp)
921
+
922
+ return inp
923
+
924
+ def run_one(self, user_message, preproc):
925
+ self.init_before_message()
926
+
927
+ if preproc:
928
+ message = self.preproc_user_input(user_message)
929
+ else:
930
+ message = user_message
931
+
932
+ while message:
933
+ self.reflected_message = None
934
+ list(self.send_message(message))
935
+
936
+ if not self.reflected_message:
937
+ break
938
+
939
+ if self.num_reflections >= self.max_reflections:
940
+ self.io.tool_warning(f"Only {self.max_reflections} reflections allowed, stopping.")
941
+ return
942
+
943
+ self.num_reflections += 1
944
+ message = self.reflected_message
945
+
946
+ def check_and_open_urls(self, exc, friendly_msg=None):
947
+ """Check exception for URLs, offer to open in a browser, with user-friendly error msgs."""
948
+ text = str(exc)
949
+
950
+ if friendly_msg:
951
+ self.io.tool_warning(text)
952
+ self.io.tool_error(f"{friendly_msg}")
953
+ else:
954
+ self.io.tool_error(text)
955
+
956
+ # Exclude double quotes from the matched URL characters
957
+ url_pattern = re.compile(r'(https?://[^\s/$.?#].[^\s"]*)')
958
+ urls = list(set(url_pattern.findall(text))) # Use set to remove duplicates
959
+ for url in urls:
960
+ url = url.rstrip(".',\"}") # Added } to the characters to strip
961
+ self.io.offer_url(url)
962
+ return urls
963
+
964
+ def check_for_urls(self, inp: str) -> List[str]:
965
+ """Check input for URLs and offer to add them to the chat."""
966
+ if not self.detect_urls:
967
+ return inp
968
+
969
+ # Exclude double quotes from the matched URL characters
970
+ url_pattern = re.compile(r'(https?://[^\s/$.?#].[^\s"]*[^\s,.])')
971
+ urls = list(set(url_pattern.findall(inp))) # Use set to remove duplicates
972
+ group = ConfirmGroup(urls)
973
+ for url in urls:
974
+ if url not in self.rejected_urls:
975
+ url = url.rstrip(".',\"")
976
+ if self.io.confirm_ask(
977
+ "Add URL to the chat?", subject=url, group=group, allow_never=True
978
+ ):
979
+ inp += "\n\n"
980
+ inp += self.commands.cmd_web(url, return_content=True)
981
+ else:
982
+ self.rejected_urls.add(url)
983
+
984
+ return inp
985
+
986
+ def keyboard_interrupt(self):
987
+ # Ensure cursor is visible on exit
988
+ Console().show_cursor(True)
989
+
990
+ now = time.time()
991
+
992
+ thresh = 2 # seconds
993
+ if self.last_keyboard_interrupt and now - self.last_keyboard_interrupt < thresh:
994
+ self.io.tool_warning("\n\n^C KeyboardInterrupt")
995
+ self.event("exit", reason="Control-C")
996
+ sys.exit()
997
+
998
+ self.io.tool_warning("\n\n^C again to exit")
999
+
1000
+ self.last_keyboard_interrupt = now
1001
+
1002
+ def summarize_start(self):
1003
+ if not self.summarizer.too_big(self.done_messages):
1004
+ return
1005
+
1006
+ self.summarize_end()
1007
+
1008
+ if self.verbose:
1009
+ self.io.tool_output("Starting to summarize chat history.")
1010
+
1011
+ self.summarizer_thread = threading.Thread(target=self.summarize_worker)
1012
+ self.summarizer_thread.start()
1013
+
1014
+ def summarize_worker(self):
1015
+ self.summarizing_messages = list(self.done_messages)
1016
+ try:
1017
+ self.summarized_done_messages = self.summarizer.summarize(self.summarizing_messages)
1018
+ except ValueError as err:
1019
+ self.io.tool_warning(err.args[0])
1020
+
1021
+ if self.verbose:
1022
+ self.io.tool_output("Finished summarizing chat history.")
1023
+
1024
+ def summarize_end(self):
1025
+ if self.summarizer_thread is None:
1026
+ return
1027
+
1028
+ self.summarizer_thread.join()
1029
+ self.summarizer_thread = None
1030
+
1031
+ if self.summarizing_messages == self.done_messages:
1032
+ self.done_messages = self.summarized_done_messages
1033
+ self.summarizing_messages = None
1034
+ self.summarized_done_messages = []
1035
+
1036
+ def move_back_cur_messages(self, message):
1037
+ self.done_messages += self.cur_messages
1038
+ self.summarize_start()
1039
+
1040
+ # TODO check for impact on image messages
1041
+ if message:
1042
+ self.done_messages += [
1043
+ dict(role="user", content=message),
1044
+ dict(role="assistant", content="Ok."),
1045
+ ]
1046
+ self.cur_messages = []
1047
+
1048
+ def normalize_language(self, lang_code):
1049
+ """
1050
+ Convert a locale code such as ``en_US`` or ``fr`` into a readable
1051
+ language name (e.g. ``English`` or ``French``). If Babel is
1052
+ available it is used for reliable conversion; otherwise a small
1053
+ built-in fallback map handles common languages.
1054
+ """
1055
+ if not lang_code:
1056
+ return None
1057
+
1058
+ if lang_code.upper() in ("C", "POSIX"):
1059
+ return None
1060
+
1061
+ # Probably already a language name
1062
+ if (
1063
+ len(lang_code) > 3
1064
+ and "_" not in lang_code
1065
+ and "-" not in lang_code
1066
+ and lang_code[0].isupper()
1067
+ ):
1068
+ return lang_code
1069
+
1070
+ # Preferred: Babel
1071
+ if Locale is not None:
1072
+ try:
1073
+ loc = Locale.parse(lang_code.replace("-", "_"))
1074
+ return loc.get_display_name("en").capitalize()
1075
+ except Exception:
1076
+ pass # Fall back to manual mapping
1077
+
1078
+ # Simple fallback for common languages
1079
+ fallback = {
1080
+ "en": "English",
1081
+ "fr": "French",
1082
+ "es": "Spanish",
1083
+ "de": "German",
1084
+ "it": "Italian",
1085
+ "pt": "Portuguese",
1086
+ "zh": "Chinese",
1087
+ "ja": "Japanese",
1088
+ "ko": "Korean",
1089
+ "ru": "Russian",
1090
+ }
1091
+ primary_lang_code = lang_code.replace("-", "_").split("_")[0].lower()
1092
+ return fallback.get(primary_lang_code, lang_code)
1093
+
1094
+ def get_user_language(self):
1095
+ """
1096
+ Detect the user's language preference and return a human-readable
1097
+ language name such as ``English``. Detection order:
1098
+
1099
+ 1. ``self.chat_language`` if explicitly set
1100
+ 2. ``locale.getlocale()``
1101
+ 3. ``LANG`` / ``LANGUAGE`` / ``LC_ALL`` / ``LC_MESSAGES`` environment variables
1102
+ """
1103
+
1104
+ # Explicit override
1105
+ if self.chat_language:
1106
+ return self.normalize_language(self.chat_language)
1107
+
1108
+ # System locale
1109
+ try:
1110
+ lang = locale.getlocale()[0]
1111
+ if lang:
1112
+ lang = self.normalize_language(lang)
1113
+ if lang:
1114
+ return lang
1115
+ except Exception:
1116
+ pass
1117
+
1118
+ # Environment variables
1119
+ for env_var in ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES"):
1120
+ lang = os.environ.get(env_var)
1121
+ if lang:
1122
+ lang = lang.split(".")[0] # Strip encoding if present
1123
+ return self.normalize_language(lang)
1124
+
1125
+ return None
1126
+
1127
+ def get_platform_info(self):
1128
+ platform_text = ""
1129
+ try:
1130
+ platform_text = f"- Platform: {platform.platform()}\n"
1131
+ except KeyError:
1132
+ # Skip platform info if it can't be retrieved
1133
+ platform_text = "- Platform information unavailable\n"
1134
+
1135
+ shell_var = "COMSPEC" if os.name == "nt" else "SHELL"
1136
+ shell_val = os.getenv(shell_var)
1137
+ platform_text += f"- Shell: {shell_var}={shell_val}\n"
1138
+
1139
+ user_lang = self.get_user_language()
1140
+ if user_lang:
1141
+ platform_text += f"- Language: {user_lang}\n"
1142
+
1143
+ dt = datetime.now().astimezone().strftime("%Y-%m-%d")
1144
+ platform_text += f"- Current date: {dt}\n"
1145
+
1146
+ if self.repo:
1147
+ platform_text += "- The user is operating inside a git repository\n"
1148
+
1149
+ if self.lint_cmds:
1150
+ if self.auto_lint:
1151
+ platform_text += (
1152
+ "- The user's pre-commit runs these lint commands, don't suggest running"
1153
+ " them:\n"
1154
+ )
1155
+ else:
1156
+ platform_text += "- The user prefers these lint commands:\n"
1157
+ for lang, cmd in self.lint_cmds.items():
1158
+ if lang is None:
1159
+ platform_text += f" - {cmd}\n"
1160
+ else:
1161
+ platform_text += f" - {lang}: {cmd}\n"
1162
+
1163
+ if self.test_cmd:
1164
+ if self.auto_test:
1165
+ platform_text += (
1166
+ "- The user's pre-commit runs this test command, don't suggest running them: "
1167
+ )
1168
+ else:
1169
+ platform_text += "- The user prefers this test command: "
1170
+ platform_text += self.test_cmd + "\n"
1171
+
1172
+ return platform_text
1173
+
1174
+ def fmt_system_prompt(self, prompt):
1175
+ final_reminders = []
1176
+ if self.main_model.lazy:
1177
+ final_reminders.append(self.gpt_prompts.lazy_prompt)
1178
+ if self.main_model.overeager:
1179
+ final_reminders.append(self.gpt_prompts.overeager_prompt)
1180
+
1181
+ user_lang = self.get_user_language()
1182
+ if user_lang:
1183
+ final_reminders.append(f"Reply in {user_lang}.\n")
1184
+
1185
+ platform_text = self.get_platform_info()
1186
+
1187
+ if self.suggest_shell_commands:
1188
+ shell_cmd_prompt = self.gpt_prompts.shell_cmd_prompt.format(platform=platform_text)
1189
+ shell_cmd_reminder = self.gpt_prompts.shell_cmd_reminder.format(platform=platform_text)
1190
+ rename_with_shell = self.gpt_prompts.rename_with_shell
1191
+ else:
1192
+ shell_cmd_prompt = self.gpt_prompts.no_shell_cmd_prompt.format(platform=platform_text)
1193
+ shell_cmd_reminder = self.gpt_prompts.no_shell_cmd_reminder.format(
1194
+ platform=platform_text
1195
+ )
1196
+ rename_with_shell = ""
1197
+
1198
+ if user_lang: # user_lang is the result of self.get_user_language()
1199
+ language = user_lang
1200
+ else:
1201
+ language = "the same language they are using" # Default if no specific lang detected
1202
+
1203
+ if self.fence[0] == "`" * 4:
1204
+ quad_backtick_reminder = (
1205
+ "\nIMPORTANT: Use *quadruple* backticks ```` as fences, not triple backticks!\n"
1206
+ )
1207
+ else:
1208
+ quad_backtick_reminder = ""
1209
+
1210
+ final_reminders = "\n\n".join(final_reminders)
1211
+
1212
+ prompt = prompt.format(
1213
+ fence=self.fence,
1214
+ quad_backtick_reminder=quad_backtick_reminder,
1215
+ final_reminders=final_reminders,
1216
+ platform=platform_text,
1217
+ shell_cmd_prompt=shell_cmd_prompt,
1218
+ rename_with_shell=rename_with_shell,
1219
+ shell_cmd_reminder=shell_cmd_reminder,
1220
+ go_ahead_tip=self.gpt_prompts.go_ahead_tip,
1221
+ language=language,
1222
+ )
1223
+
1224
+ return prompt
1225
+
1226
+ def format_chat_chunks(self):
1227
+ self.choose_fence()
1228
+ main_sys = self.fmt_system_prompt(self.gpt_prompts.main_system)
1229
+ if self.main_model.system_prompt_prefix:
1230
+ main_sys = self.main_model.system_prompt_prefix + "\n" + main_sys
1231
+
1232
+ example_messages = []
1233
+ if self.main_model.examples_as_sys_msg:
1234
+ if self.gpt_prompts.example_messages:
1235
+ main_sys += "\n# Example conversations:\n\n"
1236
+ for msg in self.gpt_prompts.example_messages:
1237
+ role = msg["role"]
1238
+ content = self.fmt_system_prompt(msg["content"])
1239
+ main_sys += f"## {role.upper()}: {content}\n\n"
1240
+ main_sys = main_sys.strip()
1241
+ else:
1242
+ for msg in self.gpt_prompts.example_messages:
1243
+ example_messages.append(
1244
+ dict(
1245
+ role=msg["role"],
1246
+ content=self.fmt_system_prompt(msg["content"]),
1247
+ )
1248
+ )
1249
+ if self.gpt_prompts.example_messages:
1250
+ example_messages += [
1251
+ dict(
1252
+ role="user",
1253
+ content=(
1254
+ "I switched to a new code base. Please don't consider the above files"
1255
+ " or try to edit them any longer."
1256
+ ),
1257
+ ),
1258
+ dict(role="assistant", content="Ok."),
1259
+ ]
1260
+
1261
+ if self.gpt_prompts.system_reminder:
1262
+ main_sys += "\n" + self.fmt_system_prompt(self.gpt_prompts.system_reminder)
1263
+
1264
+ chunks = ChatChunks()
1265
+
1266
+ if self.main_model.use_system_prompt:
1267
+ chunks.system = [
1268
+ dict(role="system", content=main_sys),
1269
+ ]
1270
+ else:
1271
+ chunks.system = [
1272
+ dict(role="user", content=main_sys),
1273
+ dict(role="assistant", content="Ok."),
1274
+ ]
1275
+
1276
+ chunks.examples = example_messages
1277
+
1278
+ self.summarize_end()
1279
+ chunks.done = self.done_messages
1280
+
1281
+ chunks.repo = self.get_repo_messages()
1282
+ chunks.readonly_files = self.get_readonly_files_messages()
1283
+ chunks.chat_files = self.get_chat_files_messages()
1284
+
1285
+ if self.gpt_prompts.system_reminder:
1286
+ reminder_message = [
1287
+ dict(
1288
+ role="system", content=self.fmt_system_prompt(self.gpt_prompts.system_reminder)
1289
+ ),
1290
+ ]
1291
+ else:
1292
+ reminder_message = []
1293
+
1294
+ chunks.cur = list(self.cur_messages)
1295
+ chunks.reminder = []
1296
+
1297
+ # TODO review impact of token count on image messages
1298
+ messages_tokens = self.main_model.token_count(chunks.all_messages())
1299
+ reminder_tokens = self.main_model.token_count(reminder_message)
1300
+ cur_tokens = self.main_model.token_count(chunks.cur)
1301
+
1302
+ if None not in (messages_tokens, reminder_tokens, cur_tokens):
1303
+ total_tokens = messages_tokens + reminder_tokens + cur_tokens
1304
+ else:
1305
+ # add the reminder anyway
1306
+ total_tokens = 0
1307
+
1308
+ if chunks.cur:
1309
+ final = chunks.cur[-1]
1310
+ else:
1311
+ final = None
1312
+
1313
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
1314
+ # Add the reminder prompt if we still have room to include it.
1315
+ if (
1316
+ not max_input_tokens
1317
+ or total_tokens < max_input_tokens
1318
+ and self.gpt_prompts.system_reminder
1319
+ ):
1320
+ if self.main_model.reminder == "sys":
1321
+ chunks.reminder = reminder_message
1322
+ elif self.main_model.reminder == "user" and final and final["role"] == "user":
1323
+ # stuff it into the user message
1324
+ new_content = (
1325
+ final["content"]
1326
+ + "\n\n"
1327
+ + self.fmt_system_prompt(self.gpt_prompts.system_reminder)
1328
+ )
1329
+ chunks.cur[-1] = dict(role=final["role"], content=new_content)
1330
+
1331
+ return chunks
1332
+
1333
+ def format_messages(self):
1334
+ chunks = self.format_chat_chunks()
1335
+ if self.add_cache_headers:
1336
+ chunks.add_cache_control_headers()
1337
+
1338
+ return chunks
1339
+
1340
+ def warm_cache(self, chunks):
1341
+ if not self.add_cache_headers:
1342
+ return
1343
+ if not self.num_cache_warming_pings:
1344
+ return
1345
+ if not self.ok_to_warm_cache:
1346
+ return
1347
+
1348
+ delay = 5 * 60 - 5
1349
+ delay = float(os.environ.get("PATCH_CACHE_KEEPALIVE_DELAY", delay))
1350
+ self.next_cache_warm = time.time() + delay
1351
+ self.warming_pings_left = self.num_cache_warming_pings
1352
+ self.cache_warming_chunks = chunks
1353
+
1354
+ if self.cache_warming_thread:
1355
+ return
1356
+
1357
+ def warm_cache_worker():
1358
+ while self.ok_to_warm_cache:
1359
+ time.sleep(1)
1360
+ if self.warming_pings_left <= 0:
1361
+ continue
1362
+ now = time.time()
1363
+ if now < self.next_cache_warm:
1364
+ continue
1365
+
1366
+ self.warming_pings_left -= 1
1367
+ self.next_cache_warm = time.time() + delay
1368
+
1369
+ kwargs = dict(self.main_model.extra_params) or dict()
1370
+ kwargs["max_tokens"] = 1
1371
+
1372
+ try:
1373
+ completion = litellm.completion(
1374
+ model=self.main_model.name,
1375
+ messages=self.cache_warming_chunks.cacheable_messages(),
1376
+ stream=False,
1377
+ **kwargs,
1378
+ )
1379
+ except Exception as err:
1380
+ self.io.tool_warning(f"Cache warming error: {str(err)}")
1381
+ continue
1382
+
1383
+ cache_hit_tokens = getattr(
1384
+ completion.usage, "prompt_cache_hit_tokens", 0
1385
+ ) or getattr(completion.usage, "cache_read_input_tokens", 0)
1386
+
1387
+ if self.verbose:
1388
+ self.io.tool_output(f"Warmed {format_tokens(cache_hit_tokens)} cached tokens.")
1389
+
1390
+ self.cache_warming_thread = threading.Timer(0, warm_cache_worker)
1391
+ self.cache_warming_thread.daemon = True
1392
+ self.cache_warming_thread.start()
1393
+
1394
+ return chunks
1395
+
1396
+ def check_tokens(self, messages):
1397
+ """Check if the messages will fit within the model's token limits."""
1398
+ input_tokens = self.main_model.token_count(messages)
1399
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
1400
+
1401
+ if max_input_tokens and input_tokens >= max_input_tokens:
1402
+ self.io.tool_error(
1403
+ f"Your estimated chat context of {input_tokens:,} tokens exceeds the"
1404
+ f" {max_input_tokens:,} token limit for {self.main_model.name}!"
1405
+ )
1406
+ self.io.tool_output("To reduce the chat context:")
1407
+ self.io.tool_output("- Use /drop to remove unneeded files from the chat")
1408
+ self.io.tool_output("- Use /clear to clear the chat history")
1409
+ self.io.tool_output("- Break your code into smaller files")
1410
+ self.io.tool_output(
1411
+ "It's probably safe to try and send the request, most providers won't charge if"
1412
+ " the context limit is exceeded."
1413
+ )
1414
+
1415
+ if not self.io.confirm_ask("Try to proceed anyway?"):
1416
+ return False
1417
+ return True
1418
+
1419
+ def send_message(self, inp):
1420
+ self.event("message_send_starting")
1421
+
1422
+ # Notify IO that LLM processing is starting
1423
+ self.io.llm_started()
1424
+
1425
+ self.cur_messages += [
1426
+ dict(role="user", content=inp),
1427
+ ]
1428
+
1429
+ chunks = self.format_messages()
1430
+ messages = chunks.all_messages()
1431
+ if not self.check_tokens(messages):
1432
+ return
1433
+ self.warm_cache(chunks)
1434
+
1435
+ if self.verbose:
1436
+ utils.show_messages(messages, functions=self.functions)
1437
+
1438
+ self.multi_response_content = ""
1439
+ if self.show_pretty():
1440
+ self.waiting_spinner = WaitingSpinner("Waiting for " + self.main_model.name)
1441
+ self.waiting_spinner.start()
1442
+ if self.stream:
1443
+ self.mdstream = self.io.get_assistant_mdstream()
1444
+ else:
1445
+ self.mdstream = None
1446
+ else:
1447
+ self.mdstream = None
1448
+
1449
+ retry_delay = 0.125
1450
+
1451
+ litellm_ex = LiteLLMExceptions()
1452
+
1453
+ self.usage_report = None
1454
+ exhausted = False
1455
+ interrupted = False
1456
+ try:
1457
+ while True:
1458
+ try:
1459
+ yield from self.send(messages, functions=self.functions)
1460
+ break
1461
+ except litellm_ex.exceptions_tuple() as err:
1462
+ ex_info = litellm_ex.get_ex_info(err)
1463
+
1464
+ if ex_info.name == "ContextWindowExceededError":
1465
+ exhausted = True
1466
+ break
1467
+
1468
+ should_retry = ex_info.retry
1469
+ if should_retry:
1470
+ retry_delay *= 2
1471
+ if retry_delay > RETRY_TIMEOUT:
1472
+ should_retry = False
1473
+
1474
+ if not should_retry:
1475
+ self.mdstream = None
1476
+ self.check_and_open_urls(err, ex_info.description)
1477
+ break
1478
+
1479
+ err_msg = str(err)
1480
+ if ex_info.description:
1481
+ self.io.tool_warning(err_msg)
1482
+ self.io.tool_error(ex_info.description)
1483
+ else:
1484
+ self.io.tool_error(err_msg)
1485
+
1486
+ self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...")
1487
+ time.sleep(retry_delay)
1488
+ continue
1489
+ except KeyboardInterrupt:
1490
+ interrupted = True
1491
+ break
1492
+ except FinishReasonLength:
1493
+ # We hit the output limit!
1494
+ if not self.main_model.info.get("supports_assistant_prefill"):
1495
+ exhausted = True
1496
+ break
1497
+
1498
+ self.multi_response_content = self.get_multi_response_content_in_progress()
1499
+
1500
+ if messages[-1]["role"] == "assistant":
1501
+ messages[-1]["content"] = self.multi_response_content
1502
+ else:
1503
+ messages.append(
1504
+ dict(role="assistant", content=self.multi_response_content, prefix=True)
1505
+ )
1506
+ except Exception as err:
1507
+ self.mdstream = None
1508
+ lines = traceback.format_exception(type(err), err, err.__traceback__)
1509
+ self.io.tool_warning("".join(lines))
1510
+ self.io.tool_error(str(err))
1511
+ self.event("message_send_exception", exception=str(err))
1512
+ return
1513
+ finally:
1514
+ if self.mdstream:
1515
+ self.live_incremental_response(True)
1516
+ self.mdstream = None
1517
+
1518
+ # Ensure any waiting spinner is stopped
1519
+ self._stop_waiting_spinner()
1520
+
1521
+ self.partial_response_content = self.get_multi_response_content_in_progress(True)
1522
+ self.remove_reasoning_content()
1523
+ self.multi_response_content = ""
1524
+
1525
+ ###
1526
+ # print()
1527
+ # print("=" * 20)
1528
+ # dump(self.partial_response_content)
1529
+
1530
+ self.io.tool_output()
1531
+
1532
+ self.show_usage_report()
1533
+
1534
+ self.add_assistant_reply_to_cur_messages()
1535
+
1536
+ if exhausted:
1537
+ if self.cur_messages and self.cur_messages[-1]["role"] == "user":
1538
+ self.cur_messages += [
1539
+ dict(
1540
+ role="assistant",
1541
+ content="FinishReasonLength exception: you sent too many tokens",
1542
+ ),
1543
+ ]
1544
+
1545
+ self.show_exhausted_error()
1546
+ self.num_exhausted_context_windows += 1
1547
+ return
1548
+
1549
+ if self.partial_response_function_call:
1550
+ args = self.parse_partial_args()
1551
+ if args:
1552
+ content = args.get("explanation") or ""
1553
+ else:
1554
+ content = ""
1555
+ elif self.partial_response_content:
1556
+ content = self.partial_response_content
1557
+ else:
1558
+ content = ""
1559
+
1560
+ if not interrupted:
1561
+ add_rel_files_message = self.check_for_file_mentions(content)
1562
+ if add_rel_files_message:
1563
+ if self.reflected_message:
1564
+ self.reflected_message += "\n\n" + add_rel_files_message
1565
+ else:
1566
+ self.reflected_message = add_rel_files_message
1567
+ return
1568
+
1569
+ try:
1570
+ if self.reply_completed():
1571
+ return
1572
+ except KeyboardInterrupt:
1573
+ interrupted = True
1574
+
1575
+ if interrupted:
1576
+ if self.cur_messages and self.cur_messages[-1]["role"] == "user":
1577
+ self.cur_messages[-1]["content"] += "\n^C KeyboardInterrupt"
1578
+ else:
1579
+ self.cur_messages += [dict(role="user", content="^C KeyboardInterrupt")]
1580
+ self.cur_messages += [
1581
+ dict(role="assistant", content="I see that you interrupted my previous reply.")
1582
+ ]
1583
+ return
1584
+
1585
+ edited = self.apply_updates()
1586
+
1587
+ if edited:
1588
+ self.patch_edited_files.update(edited)
1589
+ saved_message = self.auto_commit(edited)
1590
+
1591
+ if not saved_message and hasattr(self.gpt_prompts, "files_content_gpt_edits_no_repo"):
1592
+ saved_message = self.gpt_prompts.files_content_gpt_edits_no_repo
1593
+
1594
+ self.move_back_cur_messages(saved_message)
1595
+
1596
+ if self.reflected_message:
1597
+ return
1598
+
1599
+ if edited and self.auto_lint:
1600
+ lint_errors = self.lint_edited(edited)
1601
+ self.auto_commit(edited, context="Ran the linter")
1602
+ self.lint_outcome = not lint_errors
1603
+ if lint_errors:
1604
+ ok = self.io.confirm_ask("Attempt to fix lint errors?")
1605
+ if ok:
1606
+ self.reflected_message = lint_errors
1607
+ return
1608
+
1609
+ shared_output = self.run_shell_commands()
1610
+ if shared_output:
1611
+ self.cur_messages += [
1612
+ dict(role="user", content=shared_output),
1613
+ dict(role="assistant", content="Ok"),
1614
+ ]
1615
+
1616
+ if edited and self.auto_test:
1617
+ test_errors = self.commands.cmd_test(self.test_cmd)
1618
+ self.test_outcome = not test_errors
1619
+ if test_errors:
1620
+ ok = self.io.confirm_ask("Attempt to fix test errors?")
1621
+ if ok:
1622
+ self.reflected_message = test_errors
1623
+ return
1624
+
1625
+ def reply_completed(self):
1626
+ pass
1627
+
1628
+ def show_exhausted_error(self):
1629
+ output_tokens = 0
1630
+ if self.partial_response_content:
1631
+ output_tokens = self.main_model.token_count(self.partial_response_content)
1632
+ max_output_tokens = self.main_model.info.get("max_output_tokens") or 0
1633
+
1634
+ input_tokens = self.main_model.token_count(self.format_messages().all_messages())
1635
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
1636
+
1637
+ total_tokens = input_tokens + output_tokens
1638
+
1639
+ fudge = 0.7
1640
+
1641
+ out_err = ""
1642
+ if output_tokens >= max_output_tokens * fudge:
1643
+ out_err = " -- possibly exceeded output limit!"
1644
+
1645
+ inp_err = ""
1646
+ if input_tokens >= max_input_tokens * fudge:
1647
+ inp_err = " -- possibly exhausted context window!"
1648
+
1649
+ tot_err = ""
1650
+ if total_tokens >= max_input_tokens * fudge:
1651
+ tot_err = " -- possibly exhausted context window!"
1652
+
1653
+ res = ["", ""]
1654
+ res.append(f"Model {self.main_model.name} has hit a token limit!")
1655
+ res.append("Token counts below are approximate.")
1656
+ res.append("")
1657
+ res.append(f"Input tokens: ~{input_tokens:,} of {max_input_tokens:,}{inp_err}")
1658
+ res.append(f"Output tokens: ~{output_tokens:,} of {max_output_tokens:,}{out_err}")
1659
+ res.append(f"Total tokens: ~{total_tokens:,} of {max_input_tokens:,}{tot_err}")
1660
+
1661
+ if output_tokens >= max_output_tokens:
1662
+ res.append("")
1663
+ res.append("To reduce output tokens:")
1664
+ res.append("- Ask for smaller changes in each request.")
1665
+ res.append("- Break your code into smaller source files.")
1666
+ if "diff" not in self.main_model.edit_format:
1667
+ res.append("- Use a stronger model that can return diffs.")
1668
+
1669
+ if input_tokens >= max_input_tokens or total_tokens >= max_input_tokens:
1670
+ res.append("")
1671
+ res.append("To reduce input tokens:")
1672
+ res.append("- Use /tokens to see token usage.")
1673
+ res.append("- Use /drop to remove unneeded files from the chat session.")
1674
+ res.append("- Use /clear to clear the chat history.")
1675
+ res.append("- Break your code into smaller source files.")
1676
+
1677
+ res = "".join([line + "\n" for line in res])
1678
+ self.io.tool_error(res)
1679
+ self.io.offer_url(urls.token_limits)
1680
+
1681
+ def lint_edited(self, fnames):
1682
+ res = ""
1683
+ for fname in fnames:
1684
+ if not fname:
1685
+ continue
1686
+ errors = self.linter.lint(self.abs_root_path(fname))
1687
+
1688
+ if errors:
1689
+ res += "\n"
1690
+ res += errors
1691
+ res += "\n"
1692
+
1693
+ if res:
1694
+ self.io.tool_warning(res)
1695
+
1696
+ return res
1697
+
1698
+ def __del__(self):
1699
+ """Cleanup when the Coder object is destroyed."""
1700
+ self.ok_to_warm_cache = False
1701
+
1702
+ def add_assistant_reply_to_cur_messages(self):
1703
+ if self.partial_response_content:
1704
+ self.cur_messages += [dict(role="assistant", content=self.partial_response_content)]
1705
+ if self.partial_response_function_call:
1706
+ self.cur_messages += [
1707
+ dict(
1708
+ role="assistant",
1709
+ content=None,
1710
+ function_call=self.partial_response_function_call,
1711
+ )
1712
+ ]
1713
+
1714
+ def get_file_mentions(self, content, ignore_current=False):
1715
+ words = set(word for word in content.split())
1716
+
1717
+ # drop sentence punctuation from the end
1718
+ words = set(word.rstrip(",.!;:?") for word in words)
1719
+
1720
+ # strip away all kinds of quotes
1721
+ quotes = "\"'`*_"
1722
+ words = set(word.strip(quotes) for word in words)
1723
+
1724
+ if ignore_current:
1725
+ addable_rel_fnames = self.get_all_relative_files()
1726
+ existing_basenames = {}
1727
+ else:
1728
+ addable_rel_fnames = self.get_addable_relative_files()
1729
+
1730
+ # Get basenames of files already in chat or read-only
1731
+ existing_basenames = {os.path.basename(f) for f in self.get_inchat_relative_files()} | {
1732
+ os.path.basename(self.get_rel_fname(f)) for f in self.abs_read_only_fnames
1733
+ }
1734
+
1735
+ mentioned_rel_fnames = set()
1736
+ fname_to_rel_fnames = {}
1737
+ for rel_fname in addable_rel_fnames:
1738
+ normalized_rel_fname = rel_fname.replace("\\", "/")
1739
+ normalized_words = set(word.replace("\\", "/") for word in words)
1740
+ if normalized_rel_fname in normalized_words:
1741
+ mentioned_rel_fnames.add(rel_fname)
1742
+
1743
+ fname = os.path.basename(rel_fname)
1744
+
1745
+ # Don't add basenames that could be plain words like "run" or "make"
1746
+ if "/" in fname or "\\" in fname or "." in fname or "_" in fname or "-" in fname:
1747
+ if fname not in fname_to_rel_fnames:
1748
+ fname_to_rel_fnames[fname] = []
1749
+ fname_to_rel_fnames[fname].append(rel_fname)
1750
+
1751
+ for fname, rel_fnames in fname_to_rel_fnames.items():
1752
+ # If the basename is already in chat, don't add based on a basename mention
1753
+ if fname in existing_basenames:
1754
+ continue
1755
+ # If the basename mention is unique among addable files and present in the text
1756
+ if len(rel_fnames) == 1 and fname in words:
1757
+ mentioned_rel_fnames.add(rel_fnames[0])
1758
+
1759
+ return mentioned_rel_fnames
1760
+
1761
+ def check_for_file_mentions(self, content):
1762
+ mentioned_rel_fnames = self.get_file_mentions(content)
1763
+
1764
+ new_mentions = mentioned_rel_fnames - self.ignore_mentions
1765
+
1766
+ if not new_mentions:
1767
+ return
1768
+
1769
+ added_fnames = []
1770
+ group = ConfirmGroup(new_mentions)
1771
+ for rel_fname in sorted(new_mentions):
1772
+ if self.io.confirm_ask(
1773
+ "Add file to the chat?", subject=rel_fname, group=group, allow_never=True
1774
+ ):
1775
+ self.add_rel_fname(rel_fname)
1776
+ added_fnames.append(rel_fname)
1777
+ else:
1778
+ self.ignore_mentions.add(rel_fname)
1779
+
1780
+ if added_fnames:
1781
+ return prompts.added_files.format(fnames=", ".join(added_fnames))
1782
+
1783
+ def send(self, messages, model=None, functions=None):
1784
+ self.got_reasoning_content = False
1785
+ self.ended_reasoning_content = False
1786
+
1787
+ if not model:
1788
+ model = self.main_model
1789
+
1790
+ self.partial_response_content = ""
1791
+ self.partial_response_function_call = dict()
1792
+
1793
+ self.io.log_llm_history("TO LLM", format_messages(messages))
1794
+
1795
+ completion = None
1796
+ try:
1797
+ hash_object, completion = model.send_completion(
1798
+ messages,
1799
+ functions,
1800
+ self.stream,
1801
+ self.temperature,
1802
+ )
1803
+ self.chat_completion_call_hashes.append(hash_object.hexdigest())
1804
+
1805
+ if self.stream:
1806
+ yield from self.show_send_output_stream(completion)
1807
+ else:
1808
+ self.show_send_output(completion)
1809
+
1810
+ # Calculate costs for successful responses
1811
+ self.calculate_and_show_tokens_and_cost(messages, completion)
1812
+
1813
+ except LiteLLMExceptions().exceptions_tuple() as err:
1814
+ ex_info = LiteLLMExceptions().get_ex_info(err)
1815
+ if ex_info.name == "ContextWindowExceededError":
1816
+ # Still calculate costs for context window errors
1817
+ self.calculate_and_show_tokens_and_cost(messages, completion)
1818
+ raise
1819
+ except KeyboardInterrupt as kbi:
1820
+ self.keyboard_interrupt()
1821
+ raise kbi
1822
+ finally:
1823
+ self.io.log_llm_history(
1824
+ "LLM RESPONSE",
1825
+ format_content("ASSISTANT", self.partial_response_content),
1826
+ )
1827
+
1828
+ if self.partial_response_content:
1829
+ self.io.ai_output(self.partial_response_content)
1830
+ elif self.partial_response_function_call:
1831
+ # TODO: push this into subclasses
1832
+ args = self.parse_partial_args()
1833
+ if args:
1834
+ self.io.ai_output(json.dumps(args, indent=4))
1835
+
1836
+ def show_send_output(self, completion):
1837
+ # Stop spinner once we have a response
1838
+ self._stop_waiting_spinner()
1839
+
1840
+ if self.verbose:
1841
+ print(completion)
1842
+
1843
+ if not completion.choices:
1844
+ self.io.tool_error(str(completion))
1845
+ return
1846
+
1847
+ show_func_err = None
1848
+ show_content_err = None
1849
+ try:
1850
+ if completion.choices[0].message.tool_calls:
1851
+ self.partial_response_function_call = (
1852
+ completion.choices[0].message.tool_calls[0].function
1853
+ )
1854
+ except AttributeError as func_err:
1855
+ show_func_err = func_err
1856
+
1857
+ try:
1858
+ reasoning_content = completion.choices[0].message.reasoning_content
1859
+ except AttributeError:
1860
+ try:
1861
+ reasoning_content = completion.choices[0].message.reasoning
1862
+ except AttributeError:
1863
+ reasoning_content = None
1864
+
1865
+ try:
1866
+ self.partial_response_content = completion.choices[0].message.content or ""
1867
+ except AttributeError as content_err:
1868
+ show_content_err = content_err
1869
+
1870
+ resp_hash = dict(
1871
+ function_call=str(self.partial_response_function_call),
1872
+ content=self.partial_response_content,
1873
+ )
1874
+ resp_hash = hashlib.sha1(json.dumps(resp_hash, sort_keys=True).encode())
1875
+ self.chat_completion_response_hashes.append(resp_hash.hexdigest())
1876
+
1877
+ if show_func_err and show_content_err:
1878
+ self.io.tool_error(show_func_err)
1879
+ self.io.tool_error(show_content_err)
1880
+ raise Exception("No data found in LLM response!")
1881
+
1882
+ show_resp = self.render_incremental_response(True)
1883
+
1884
+ if reasoning_content:
1885
+ formatted_reasoning = format_reasoning_content(
1886
+ reasoning_content, self.reasoning_tag_name
1887
+ )
1888
+ show_resp = formatted_reasoning + show_resp
1889
+
1890
+ show_resp = replace_reasoning_tags(show_resp, self.reasoning_tag_name)
1891
+
1892
+ self.io.assistant_output(show_resp, pretty=self.show_pretty())
1893
+
1894
+ if (
1895
+ hasattr(completion.choices[0], "finish_reason")
1896
+ and completion.choices[0].finish_reason == "length"
1897
+ ):
1898
+ raise FinishReasonLength()
1899
+
1900
+ def show_send_output_stream(self, completion):
1901
+ received_content = False
1902
+
1903
+ for chunk in completion:
1904
+ if len(chunk.choices) == 0:
1905
+ continue
1906
+
1907
+ if (
1908
+ hasattr(chunk.choices[0], "finish_reason")
1909
+ and chunk.choices[0].finish_reason == "length"
1910
+ ):
1911
+ raise FinishReasonLength()
1912
+
1913
+ try:
1914
+ func = chunk.choices[0].delta.function_call
1915
+ # dump(func)
1916
+ for k, v in func.items():
1917
+ if k in self.partial_response_function_call:
1918
+ self.partial_response_function_call[k] += v
1919
+ else:
1920
+ self.partial_response_function_call[k] = v
1921
+ received_content = True
1922
+ except AttributeError:
1923
+ pass
1924
+
1925
+ text = ""
1926
+
1927
+ try:
1928
+ reasoning_content = chunk.choices[0].delta.reasoning_content
1929
+ except AttributeError:
1930
+ try:
1931
+ reasoning_content = chunk.choices[0].delta.reasoning
1932
+ except AttributeError:
1933
+ reasoning_content = None
1934
+
1935
+ if reasoning_content:
1936
+ if not self.got_reasoning_content:
1937
+ text += f"<{REASONING_TAG}>\n\n"
1938
+ text += reasoning_content
1939
+ self.got_reasoning_content = True
1940
+ received_content = True
1941
+
1942
+ try:
1943
+ content = chunk.choices[0].delta.content
1944
+ if content:
1945
+ if self.got_reasoning_content and not self.ended_reasoning_content:
1946
+ text += f"\n\n</{self.reasoning_tag_name}>\n\n"
1947
+ self.ended_reasoning_content = True
1948
+
1949
+ text += content
1950
+ received_content = True
1951
+ except AttributeError:
1952
+ pass
1953
+
1954
+ if received_content:
1955
+ self._stop_waiting_spinner()
1956
+ self.partial_response_content += text
1957
+
1958
+ if self.show_pretty():
1959
+ self.live_incremental_response(False)
1960
+ elif text:
1961
+ # Apply reasoning tag formatting
1962
+ text = replace_reasoning_tags(text, self.reasoning_tag_name)
1963
+ try:
1964
+ sys.stdout.write(text)
1965
+ except UnicodeEncodeError:
1966
+ # Safely encode and decode the text
1967
+ safe_text = text.encode(sys.stdout.encoding, errors="backslashreplace").decode(
1968
+ sys.stdout.encoding
1969
+ )
1970
+ sys.stdout.write(safe_text)
1971
+ sys.stdout.flush()
1972
+ yield text
1973
+
1974
+ if not received_content:
1975
+ self.io.tool_warning("Empty response received from LLM. Check your provider account?")
1976
+
1977
+ def live_incremental_response(self, final):
1978
+ show_resp = self.render_incremental_response(final)
1979
+ # Apply any reasoning tag formatting
1980
+ show_resp = replace_reasoning_tags(show_resp, self.reasoning_tag_name)
1981
+ self.mdstream.update(show_resp, final=final)
1982
+
1983
+ def render_incremental_response(self, final):
1984
+ return self.get_multi_response_content_in_progress()
1985
+
1986
+ def remove_reasoning_content(self):
1987
+ """Remove reasoning content from the model's response."""
1988
+
1989
+ self.partial_response_content = remove_reasoning_content(
1990
+ self.partial_response_content,
1991
+ self.reasoning_tag_name,
1992
+ )
1993
+
1994
+ def calculate_and_show_tokens_and_cost(self, messages, completion=None):
1995
+ prompt_tokens = 0
1996
+ completion_tokens = 0
1997
+ cache_hit_tokens = 0
1998
+ cache_write_tokens = 0
1999
+
2000
+ if completion and hasattr(completion, "usage") and completion.usage is not None:
2001
+ prompt_tokens = completion.usage.prompt_tokens
2002
+ completion_tokens = completion.usage.completion_tokens
2003
+ cache_hit_tokens = getattr(completion.usage, "prompt_cache_hit_tokens", 0) or getattr(
2004
+ completion.usage, "cache_read_input_tokens", 0
2005
+ )
2006
+ cache_write_tokens = getattr(completion.usage, "cache_creation_input_tokens", 0)
2007
+
2008
+ if hasattr(completion.usage, "cache_read_input_tokens") or hasattr(
2009
+ completion.usage, "cache_creation_input_tokens"
2010
+ ):
2011
+ self.message_tokens_sent += prompt_tokens
2012
+ self.message_tokens_sent += cache_write_tokens
2013
+ else:
2014
+ self.message_tokens_sent += prompt_tokens
2015
+
2016
+ else:
2017
+ prompt_tokens = self.main_model.token_count(messages)
2018
+ completion_tokens = self.main_model.token_count(self.partial_response_content)
2019
+ self.message_tokens_sent += prompt_tokens
2020
+
2021
+ self.message_tokens_received += completion_tokens
2022
+
2023
+ tokens_report = f"Tokens: {format_tokens(self.message_tokens_sent)} sent"
2024
+
2025
+ if cache_write_tokens:
2026
+ tokens_report += f", {format_tokens(cache_write_tokens)} cache write"
2027
+ if cache_hit_tokens:
2028
+ tokens_report += f", {format_tokens(cache_hit_tokens)} cache hit"
2029
+ tokens_report += f", {format_tokens(self.message_tokens_received)} received."
2030
+
2031
+ if not self.main_model.info.get("input_cost_per_token"):
2032
+ self.usage_report = tokens_report
2033
+ return
2034
+
2035
+ try:
2036
+ # Try and use litellm's built in cost calculator. Seems to work for non-streaming only?
2037
+ cost = litellm.completion_cost(completion_response=completion)
2038
+ except Exception:
2039
+ cost = 0
2040
+
2041
+ if not cost:
2042
+ cost = self.compute_costs_from_tokens(
2043
+ prompt_tokens, completion_tokens, cache_write_tokens, cache_hit_tokens
2044
+ )
2045
+
2046
+ self.total_cost += cost
2047
+ self.message_cost += cost
2048
+
2049
+ def format_cost(value):
2050
+ if value == 0:
2051
+ return "0.00"
2052
+ magnitude = abs(value)
2053
+ if magnitude >= 0.01:
2054
+ return f"{value:.2f}"
2055
+ else:
2056
+ return f"{value:.{max(2, 2 - int(math.log10(magnitude)))}f}"
2057
+
2058
+ cost_report = (
2059
+ f"Cost: ${format_cost(self.message_cost)} message,"
2060
+ f" ${format_cost(self.total_cost)} session."
2061
+ )
2062
+
2063
+ if cache_hit_tokens and cache_write_tokens:
2064
+ sep = "\n"
2065
+ else:
2066
+ sep = " "
2067
+
2068
+ self.usage_report = tokens_report + sep + cost_report
2069
+
2070
+ def compute_costs_from_tokens(
2071
+ self, prompt_tokens, completion_tokens, cache_write_tokens, cache_hit_tokens
2072
+ ):
2073
+ cost = 0
2074
+
2075
+ input_cost_per_token = self.main_model.info.get("input_cost_per_token") or 0
2076
+ output_cost_per_token = self.main_model.info.get("output_cost_per_token") or 0
2077
+ input_cost_per_token_cache_hit = (
2078
+ self.main_model.info.get("input_cost_per_token_cache_hit") or 0
2079
+ )
2080
+
2081
+ # deepseek
2082
+ # prompt_cache_hit_tokens + prompt_cache_miss_tokens
2083
+ # == prompt_tokens == total tokens that were sent
2084
+ #
2085
+ # Anthropic
2086
+ # cache_creation_input_tokens + cache_read_input_tokens + prompt
2087
+ # == total tokens that were
2088
+
2089
+ if input_cost_per_token_cache_hit:
2090
+ # must be deepseek
2091
+ cost += input_cost_per_token_cache_hit * cache_hit_tokens
2092
+ cost += (prompt_tokens - input_cost_per_token_cache_hit) * input_cost_per_token
2093
+ else:
2094
+ # hard code the anthropic adjustments, no-ops for other models since cache_x_tokens==0
2095
+ cost += cache_write_tokens * input_cost_per_token * 1.25
2096
+ cost += cache_hit_tokens * input_cost_per_token * 0.10
2097
+ cost += prompt_tokens * input_cost_per_token
2098
+
2099
+ cost += completion_tokens * output_cost_per_token
2100
+ return cost
2101
+
2102
+ def show_usage_report(self):
2103
+ if not self.usage_report:
2104
+ return
2105
+
2106
+ self.total_tokens_sent += self.message_tokens_sent
2107
+ self.total_tokens_received += self.message_tokens_received
2108
+
2109
+ self.io.tool_output(self.usage_report)
2110
+
2111
+ prompt_tokens = self.message_tokens_sent
2112
+ completion_tokens = self.message_tokens_received
2113
+ self.event(
2114
+ "message_send",
2115
+ main_model=self.main_model,
2116
+ edit_format=self.edit_format,
2117
+ prompt_tokens=prompt_tokens,
2118
+ completion_tokens=completion_tokens,
2119
+ total_tokens=prompt_tokens + completion_tokens,
2120
+ cost=self.message_cost,
2121
+ total_cost=self.total_cost,
2122
+ )
2123
+
2124
+ self.message_cost = 0.0
2125
+ self.message_tokens_sent = 0
2126
+ self.message_tokens_received = 0
2127
+
2128
+ def get_multi_response_content_in_progress(self, final=False):
2129
+ cur = self.multi_response_content or ""
2130
+ new = self.partial_response_content or ""
2131
+
2132
+ if new.rstrip() != new and not final:
2133
+ new = new.rstrip()
2134
+
2135
+ return cur + new
2136
+
2137
+ def get_rel_fname(self, fname):
2138
+ try:
2139
+ return os.path.relpath(fname, self.root)
2140
+ except ValueError:
2141
+ return fname
2142
+
2143
+ def get_inchat_relative_files(self):
2144
+ files = [self.get_rel_fname(fname) for fname in self.abs_fnames]
2145
+ return sorted(set(files))
2146
+
2147
+ def is_file_safe(self, fname):
2148
+ try:
2149
+ return Path(self.abs_root_path(fname)).is_file()
2150
+ except OSError:
2151
+ return
2152
+
2153
+ def get_all_relative_files(self):
2154
+ if self.repo:
2155
+ files = self.repo.get_tracked_files()
2156
+ else:
2157
+ files = self.get_inchat_relative_files()
2158
+
2159
+ # This is quite slow in large repos
2160
+ # files = [fname for fname in files if self.is_file_safe(fname)]
2161
+
2162
+ return sorted(set(files))
2163
+
2164
+ def get_all_abs_files(self):
2165
+ files = self.get_all_relative_files()
2166
+ files = [self.abs_root_path(path) for path in files]
2167
+ return files
2168
+
2169
+ def get_addable_relative_files(self):
2170
+ all_files = set(self.get_all_relative_files())
2171
+ inchat_files = set(self.get_inchat_relative_files())
2172
+ read_only_files = set(self.get_rel_fname(fname) for fname in self.abs_read_only_fnames)
2173
+ return all_files - inchat_files - read_only_files
2174
+
2175
+ def check_for_dirty_commit(self, path):
2176
+ if not self.repo:
2177
+ return
2178
+ if not self.dirty_commits:
2179
+ return
2180
+ if not self.repo.is_dirty(path):
2181
+ return
2182
+
2183
+ # We need a committed copy of the file in order to /undo, so skip this
2184
+ # fullp = Path(self.abs_root_path(path))
2185
+ # if not fullp.stat().st_size:
2186
+ # return
2187
+
2188
+ self.io.tool_output(f"Committing {path} before applying edits.")
2189
+ self.need_commit_before_edits.add(path)
2190
+
2191
+ def allowed_to_edit(self, path):
2192
+ full_path = self.abs_root_path(path)
2193
+ if self.repo:
2194
+ need_to_add = not self.repo.path_in_repo(path)
2195
+ else:
2196
+ need_to_add = False
2197
+
2198
+ if full_path in self.abs_fnames:
2199
+ self.check_for_dirty_commit(path)
2200
+ return True
2201
+
2202
+ if self.repo and self.repo.git_ignored_file(path):
2203
+ self.io.tool_warning(f"Skipping edits to {path} that matches gitignore spec.")
2204
+ return
2205
+
2206
+ if not Path(full_path).exists():
2207
+ if not self.io.confirm_ask("Create new file?", subject=path):
2208
+ self.io.tool_output(f"Skipping edits to {path}")
2209
+ return
2210
+
2211
+ if not self.dry_run:
2212
+ if not utils.touch_file(full_path):
2213
+ self.io.tool_error(f"Unable to create {path}, skipping edits.")
2214
+ return
2215
+
2216
+ # Seems unlikely that we needed to create the file, but it was
2217
+ # actually already part of the repo.
2218
+ # But let's only add if we need to, just to be safe.
2219
+ if need_to_add and self.auto_commits:
2220
+ self.repo.repo.git.add(full_path)
2221
+
2222
+ self.abs_fnames.add(full_path)
2223
+ self.check_added_files()
2224
+ return True
2225
+
2226
+ if not self.io.confirm_ask(
2227
+ "Allow edits to file that has not been added to the chat?",
2228
+ subject=path,
2229
+ ):
2230
+ self.io.tool_output(f"Skipping edits to {path}")
2231
+ return
2232
+
2233
+ if need_to_add and self.auto_commits:
2234
+ self.repo.repo.git.add(full_path)
2235
+
2236
+ self.abs_fnames.add(full_path)
2237
+ self.check_added_files()
2238
+ self.check_for_dirty_commit(path)
2239
+
2240
+ return True
2241
+
2242
+ warning_given = False
2243
+
2244
+ def check_added_files(self):
2245
+ if self.warning_given:
2246
+ return
2247
+
2248
+ warn_number_of_files = 4
2249
+ warn_number_of_tokens = 20 * 1024
2250
+
2251
+ num_files = len(self.abs_fnames)
2252
+ if num_files < warn_number_of_files:
2253
+ return
2254
+
2255
+ tokens = 0
2256
+ for fname in self.abs_fnames:
2257
+ if is_image_file(fname):
2258
+ continue
2259
+ content = self.io.read_text(fname)
2260
+ tokens += self.main_model.token_count(content)
2261
+
2262
+ if tokens < warn_number_of_tokens:
2263
+ return
2264
+
2265
+ self.io.tool_warning("Warning: it's best to only add files that need changes to the chat.")
2266
+ self.io.tool_warning(urls.edit_errors)
2267
+ self.warning_given = True
2268
+
2269
+ def prepare_to_edit(self, edits):
2270
+ res = []
2271
+ seen = dict()
2272
+
2273
+ self.need_commit_before_edits = set()
2274
+
2275
+ for edit in edits:
2276
+ path = edit[0]
2277
+ if path is None:
2278
+ res.append(edit)
2279
+ continue
2280
+ if path == "python":
2281
+ dump(edits)
2282
+ if path in seen:
2283
+ allowed = seen[path]
2284
+ else:
2285
+ allowed = self.allowed_to_edit(path)
2286
+ seen[path] = allowed
2287
+
2288
+ if allowed:
2289
+ res.append(edit)
2290
+
2291
+ self.dirty_commit()
2292
+ self.need_commit_before_edits = set()
2293
+
2294
+ return res
2295
+
2296
+ def apply_updates(self):
2297
+ edited = set()
2298
+ try:
2299
+ edits = self.get_edits()
2300
+ edits = self.apply_edits_dry_run(edits)
2301
+ edits = self.prepare_to_edit(edits)
2302
+ edited = set(edit[0] for edit in edits)
2303
+
2304
+ self.apply_edits(edits)
2305
+ except ValueError as err:
2306
+ self.num_malformed_responses += 1
2307
+
2308
+ err = err.args[0]
2309
+
2310
+ self.io.tool_error("The LLM did not conform to the edit format.")
2311
+ self.io.tool_output(urls.edit_errors)
2312
+ self.io.tool_output()
2313
+ self.io.tool_output(str(err))
2314
+
2315
+ self.reflected_message = str(err)
2316
+ return edited
2317
+
2318
+ except ANY_GIT_ERROR as err:
2319
+ self.io.tool_error(str(err))
2320
+ return edited
2321
+ except Exception as err:
2322
+ self.io.tool_error("Exception while updating files:")
2323
+ self.io.tool_error(str(err), strip=False)
2324
+
2325
+ traceback.print_exc()
2326
+
2327
+ self.reflected_message = str(err)
2328
+ return edited
2329
+
2330
+ for path in edited:
2331
+ if self.dry_run:
2332
+ self.io.tool_output(f"Did not apply edit to {path} (--dry-run)")
2333
+ else:
2334
+ self.io.tool_output(f"Applied edit to {path}")
2335
+
2336
+ return edited
2337
+
2338
+ def parse_partial_args(self):
2339
+ # dump(self.partial_response_function_call)
2340
+
2341
+ data = self.partial_response_function_call.get("arguments")
2342
+ if not data:
2343
+ return
2344
+
2345
+ try:
2346
+ return json.loads(data)
2347
+ except JSONDecodeError:
2348
+ pass
2349
+
2350
+ try:
2351
+ return json.loads(data + "]}")
2352
+ except JSONDecodeError:
2353
+ pass
2354
+
2355
+ try:
2356
+ return json.loads(data + "}]}")
2357
+ except JSONDecodeError:
2358
+ pass
2359
+
2360
+ try:
2361
+ return json.loads(data + '"}]}')
2362
+ except JSONDecodeError:
2363
+ pass
2364
+
2365
+ # commits...
2366
+
2367
+ def get_context_from_history(self, history):
2368
+ context = ""
2369
+ if history:
2370
+ for msg in history:
2371
+ context += "\n" + msg["role"].upper() + ": " + msg["content"] + "\n"
2372
+
2373
+ return context
2374
+
2375
+ def auto_commit(self, edited, context=None):
2376
+ if not self.repo or not self.auto_commits or self.dry_run:
2377
+ return
2378
+
2379
+ if not context:
2380
+ context = self.get_context_from_history(self.cur_messages)
2381
+
2382
+ try:
2383
+ res = self.repo.commit(fnames=edited, context=context, patch_edits=True, coder=self)
2384
+ if res:
2385
+ self.show_auto_commit_outcome(res)
2386
+ commit_hash, commit_message = res
2387
+ return self.gpt_prompts.files_content_gpt_edits.format(
2388
+ hash=commit_hash,
2389
+ message=commit_message,
2390
+ )
2391
+
2392
+ return self.gpt_prompts.files_content_gpt_no_edits
2393
+ except ANY_GIT_ERROR as err:
2394
+ self.io.tool_error(f"Unable to commit: {str(err)}")
2395
+ return
2396
+
2397
+ def show_auto_commit_outcome(self, res):
2398
+ commit_hash, commit_message = res
2399
+ self.last_patch_commit_hash = commit_hash
2400
+ self.patch_commit_hashes.add(commit_hash)
2401
+ self.last_patch_commit_message = commit_message
2402
+ if self.show_diffs:
2403
+ self.commands.cmd_diff()
2404
+
2405
+ def show_undo_hint(self):
2406
+ if not self.commit_before_message:
2407
+ return
2408
+ if self.commit_before_message[-1] != self.repo.get_head_commit_sha():
2409
+ self.io.tool_output("You can use /undo to undo and discard each patch commit.")
2410
+
2411
+ def dirty_commit(self):
2412
+ if not self.need_commit_before_edits:
2413
+ return
2414
+ if not self.dirty_commits:
2415
+ return
2416
+ if not self.repo:
2417
+ return
2418
+
2419
+ self.repo.commit(fnames=self.need_commit_before_edits, coder=self)
2420
+
2421
+ # files changed, move cur messages back behind the files messages
2422
+ # self.move_back_cur_messages(self.gpt_prompts.files_content_local_edits)
2423
+ return True
2424
+
2425
+ def get_edits(self, mode="update"):
2426
+ return []
2427
+
2428
+ def apply_edits(self, edits):
2429
+ return
2430
+
2431
+ def apply_edits_dry_run(self, edits):
2432
+ return edits
2433
+
2434
+ def run_shell_commands(self):
2435
+ if not self.suggest_shell_commands:
2436
+ return ""
2437
+
2438
+ done = set()
2439
+ group = ConfirmGroup(set(self.shell_commands))
2440
+ accumulated_output = ""
2441
+ for command in self.shell_commands:
2442
+ if command in done:
2443
+ continue
2444
+ done.add(command)
2445
+ output = self.handle_shell_commands(command, group)
2446
+ if output:
2447
+ accumulated_output += output + "\n\n"
2448
+ return accumulated_output
2449
+
2450
+ def handle_shell_commands(self, commands_str, group):
2451
+ commands = commands_str.strip().splitlines()
2452
+ command_count = sum(
2453
+ 1 for cmd in commands if cmd.strip() and not cmd.strip().startswith("#")
2454
+ )
2455
+ prompt = "Run shell command?" if command_count == 1 else "Run shell commands?"
2456
+ if not self.io.confirm_ask(
2457
+ prompt,
2458
+ subject="\n".join(commands),
2459
+ explicit_yes_required=True,
2460
+ group=group,
2461
+ allow_never=True,
2462
+ ):
2463
+ return
2464
+
2465
+ accumulated_output = ""
2466
+ for command in commands:
2467
+ command = command.strip()
2468
+ if not command or command.startswith("#"):
2469
+ continue
2470
+
2471
+ self.io.tool_output()
2472
+ self.io.tool_output(f"Running {command}")
2473
+ # Add the command to input history
2474
+ self.io.add_to_input_history(f"/run {command.strip()}")
2475
+ exit_status, output = run_cmd(command, error_print=self.io.tool_error, cwd=self.root)
2476
+ if output:
2477
+ accumulated_output += f"Output from {command}\n{output}\n"
2478
+
2479
+ if accumulated_output.strip() and self.io.confirm_ask(
2480
+ "Add command output to the chat?", allow_never=True
2481
+ ):
2482
+ num_lines = len(accumulated_output.strip().splitlines())
2483
+ line_plural = "line" if num_lines == 1 else "lines"
2484
+ self.io.tool_output(f"Added {num_lines} {line_plural} of output to the chat.")
2485
+ return accumulated_output