aider-ce 0.87.2.dev9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aider-ce might be problematic. Click here for more details.

Files changed (264) hide show
  1. aider/__init__.py +20 -0
  2. aider/__main__.py +4 -0
  3. aider/_version.py +34 -0
  4. aider/analytics.py +258 -0
  5. aider/args.py +1014 -0
  6. aider/args_formatter.py +228 -0
  7. aider/change_tracker.py +133 -0
  8. aider/coders/__init__.py +36 -0
  9. aider/coders/architect_coder.py +48 -0
  10. aider/coders/architect_prompts.py +40 -0
  11. aider/coders/ask_coder.py +9 -0
  12. aider/coders/ask_prompts.py +35 -0
  13. aider/coders/base_coder.py +3013 -0
  14. aider/coders/base_prompts.py +87 -0
  15. aider/coders/chat_chunks.py +64 -0
  16. aider/coders/context_coder.py +53 -0
  17. aider/coders/context_prompts.py +75 -0
  18. aider/coders/editblock_coder.py +657 -0
  19. aider/coders/editblock_fenced_coder.py +10 -0
  20. aider/coders/editblock_fenced_prompts.py +143 -0
  21. aider/coders/editblock_func_coder.py +141 -0
  22. aider/coders/editblock_func_prompts.py +27 -0
  23. aider/coders/editblock_prompts.py +177 -0
  24. aider/coders/editor_diff_fenced_coder.py +9 -0
  25. aider/coders/editor_diff_fenced_prompts.py +11 -0
  26. aider/coders/editor_editblock_coder.py +9 -0
  27. aider/coders/editor_editblock_prompts.py +21 -0
  28. aider/coders/editor_whole_coder.py +9 -0
  29. aider/coders/editor_whole_prompts.py +12 -0
  30. aider/coders/help_coder.py +16 -0
  31. aider/coders/help_prompts.py +46 -0
  32. aider/coders/navigator_coder.py +2711 -0
  33. aider/coders/navigator_legacy_prompts.py +338 -0
  34. aider/coders/navigator_prompts.py +530 -0
  35. aider/coders/patch_coder.py +706 -0
  36. aider/coders/patch_prompts.py +161 -0
  37. aider/coders/search_replace.py +757 -0
  38. aider/coders/shell.py +37 -0
  39. aider/coders/single_wholefile_func_coder.py +102 -0
  40. aider/coders/single_wholefile_func_prompts.py +27 -0
  41. aider/coders/udiff_coder.py +429 -0
  42. aider/coders/udiff_prompts.py +117 -0
  43. aider/coders/udiff_simple.py +14 -0
  44. aider/coders/udiff_simple_prompts.py +25 -0
  45. aider/coders/wholefile_coder.py +144 -0
  46. aider/coders/wholefile_func_coder.py +134 -0
  47. aider/coders/wholefile_func_prompts.py +27 -0
  48. aider/coders/wholefile_prompts.py +70 -0
  49. aider/commands.py +1946 -0
  50. aider/copypaste.py +72 -0
  51. aider/deprecated.py +126 -0
  52. aider/diffs.py +128 -0
  53. aider/dump.py +29 -0
  54. aider/editor.py +147 -0
  55. aider/exceptions.py +107 -0
  56. aider/format_settings.py +26 -0
  57. aider/gui.py +545 -0
  58. aider/help.py +163 -0
  59. aider/help_pats.py +19 -0
  60. aider/history.py +178 -0
  61. aider/io.py +1257 -0
  62. aider/linter.py +304 -0
  63. aider/llm.py +47 -0
  64. aider/main.py +1297 -0
  65. aider/mcp/__init__.py +94 -0
  66. aider/mcp/server.py +119 -0
  67. aider/mdstream.py +243 -0
  68. aider/models.py +1344 -0
  69. aider/onboarding.py +428 -0
  70. aider/openrouter.py +129 -0
  71. aider/prompts.py +56 -0
  72. aider/queries/tree-sitter-language-pack/README.md +7 -0
  73. aider/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  74. aider/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  75. aider/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  76. aider/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  77. aider/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  78. aider/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  79. aider/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  80. aider/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  81. aider/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  82. aider/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  83. aider/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  84. aider/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  85. aider/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  86. aider/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  87. aider/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  88. aider/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  89. aider/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  90. aider/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  91. aider/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  92. aider/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  93. aider/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  94. aider/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  95. aider/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  96. aider/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  97. aider/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  98. aider/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  99. aider/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  100. aider/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  101. aider/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  102. aider/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  103. aider/queries/tree-sitter-languages/README.md +23 -0
  104. aider/queries/tree-sitter-languages/c-tags.scm +9 -0
  105. aider/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  106. aider/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  107. aider/queries/tree-sitter-languages/dart-tags.scm +91 -0
  108. aider/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  109. aider/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  110. aider/queries/tree-sitter-languages/elm-tags.scm +19 -0
  111. aider/queries/tree-sitter-languages/go-tags.scm +30 -0
  112. aider/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  113. aider/queries/tree-sitter-languages/java-tags.scm +20 -0
  114. aider/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  115. aider/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  116. aider/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  117. aider/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  118. aider/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  119. aider/queries/tree-sitter-languages/php-tags.scm +26 -0
  120. aider/queries/tree-sitter-languages/python-tags.scm +12 -0
  121. aider/queries/tree-sitter-languages/ql-tags.scm +26 -0
  122. aider/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  123. aider/queries/tree-sitter-languages/rust-tags.scm +60 -0
  124. aider/queries/tree-sitter-languages/scala-tags.scm +65 -0
  125. aider/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  126. aider/reasoning_tags.py +82 -0
  127. aider/repo.py +621 -0
  128. aider/repomap.py +988 -0
  129. aider/report.py +200 -0
  130. aider/resources/__init__.py +3 -0
  131. aider/resources/model-metadata.json +699 -0
  132. aider/resources/model-settings.yml +2046 -0
  133. aider/run_cmd.py +132 -0
  134. aider/scrape.py +284 -0
  135. aider/sendchat.py +61 -0
  136. aider/special.py +203 -0
  137. aider/tools/__init__.py +26 -0
  138. aider/tools/command.py +58 -0
  139. aider/tools/command_interactive.py +53 -0
  140. aider/tools/delete_block.py +120 -0
  141. aider/tools/delete_line.py +112 -0
  142. aider/tools/delete_lines.py +137 -0
  143. aider/tools/extract_lines.py +276 -0
  144. aider/tools/grep.py +171 -0
  145. aider/tools/indent_lines.py +155 -0
  146. aider/tools/insert_block.py +211 -0
  147. aider/tools/list_changes.py +51 -0
  148. aider/tools/ls.py +49 -0
  149. aider/tools/make_editable.py +46 -0
  150. aider/tools/make_readonly.py +29 -0
  151. aider/tools/remove.py +48 -0
  152. aider/tools/replace_all.py +77 -0
  153. aider/tools/replace_line.py +125 -0
  154. aider/tools/replace_lines.py +160 -0
  155. aider/tools/replace_text.py +125 -0
  156. aider/tools/show_numbered_context.py +101 -0
  157. aider/tools/tool_utils.py +313 -0
  158. aider/tools/undo_change.py +60 -0
  159. aider/tools/view.py +13 -0
  160. aider/tools/view_files_at_glob.py +65 -0
  161. aider/tools/view_files_matching.py +103 -0
  162. aider/tools/view_files_with_symbol.py +121 -0
  163. aider/urls.py +17 -0
  164. aider/utils.py +454 -0
  165. aider/versioncheck.py +113 -0
  166. aider/voice.py +187 -0
  167. aider/waiting.py +221 -0
  168. aider/watch.py +318 -0
  169. aider/watch_prompts.py +12 -0
  170. aider/website/Gemfile +8 -0
  171. aider/website/_includes/blame.md +162 -0
  172. aider/website/_includes/get-started.md +22 -0
  173. aider/website/_includes/help-tip.md +5 -0
  174. aider/website/_includes/help.md +24 -0
  175. aider/website/_includes/install.md +5 -0
  176. aider/website/_includes/keys.md +4 -0
  177. aider/website/_includes/model-warnings.md +67 -0
  178. aider/website/_includes/multi-line.md +22 -0
  179. aider/website/_includes/python-m-aider.md +5 -0
  180. aider/website/_includes/recording.css +228 -0
  181. aider/website/_includes/recording.md +34 -0
  182. aider/website/_includes/replit-pipx.md +9 -0
  183. aider/website/_includes/works-best.md +1 -0
  184. aider/website/_sass/custom/custom.scss +103 -0
  185. aider/website/docs/config/adv-model-settings.md +2260 -0
  186. aider/website/docs/config/aider_conf.md +548 -0
  187. aider/website/docs/config/api-keys.md +90 -0
  188. aider/website/docs/config/dotenv.md +493 -0
  189. aider/website/docs/config/editor.md +127 -0
  190. aider/website/docs/config/mcp.md +95 -0
  191. aider/website/docs/config/model-aliases.md +104 -0
  192. aider/website/docs/config/options.md +890 -0
  193. aider/website/docs/config/reasoning.md +210 -0
  194. aider/website/docs/config.md +44 -0
  195. aider/website/docs/faq.md +384 -0
  196. aider/website/docs/git.md +76 -0
  197. aider/website/docs/index.md +47 -0
  198. aider/website/docs/install/codespaces.md +39 -0
  199. aider/website/docs/install/docker.md +57 -0
  200. aider/website/docs/install/optional.md +100 -0
  201. aider/website/docs/install/replit.md +8 -0
  202. aider/website/docs/install.md +115 -0
  203. aider/website/docs/languages.md +264 -0
  204. aider/website/docs/legal/contributor-agreement.md +111 -0
  205. aider/website/docs/legal/privacy.md +104 -0
  206. aider/website/docs/llms/anthropic.md +77 -0
  207. aider/website/docs/llms/azure.md +48 -0
  208. aider/website/docs/llms/bedrock.md +132 -0
  209. aider/website/docs/llms/cohere.md +34 -0
  210. aider/website/docs/llms/deepseek.md +32 -0
  211. aider/website/docs/llms/gemini.md +49 -0
  212. aider/website/docs/llms/github.md +111 -0
  213. aider/website/docs/llms/groq.md +36 -0
  214. aider/website/docs/llms/lm-studio.md +39 -0
  215. aider/website/docs/llms/ollama.md +75 -0
  216. aider/website/docs/llms/openai-compat.md +39 -0
  217. aider/website/docs/llms/openai.md +58 -0
  218. aider/website/docs/llms/openrouter.md +78 -0
  219. aider/website/docs/llms/other.md +111 -0
  220. aider/website/docs/llms/vertex.md +50 -0
  221. aider/website/docs/llms/warnings.md +10 -0
  222. aider/website/docs/llms/xai.md +53 -0
  223. aider/website/docs/llms.md +54 -0
  224. aider/website/docs/more/analytics.md +127 -0
  225. aider/website/docs/more/edit-formats.md +116 -0
  226. aider/website/docs/more/infinite-output.md +159 -0
  227. aider/website/docs/more-info.md +8 -0
  228. aider/website/docs/recordings/auto-accept-architect.md +31 -0
  229. aider/website/docs/recordings/dont-drop-original-read-files.md +35 -0
  230. aider/website/docs/recordings/index.md +21 -0
  231. aider/website/docs/recordings/model-accepts-settings.md +69 -0
  232. aider/website/docs/recordings/tree-sitter-language-pack.md +80 -0
  233. aider/website/docs/repomap.md +112 -0
  234. aider/website/docs/scripting.md +100 -0
  235. aider/website/docs/troubleshooting/aider-not-found.md +24 -0
  236. aider/website/docs/troubleshooting/edit-errors.md +76 -0
  237. aider/website/docs/troubleshooting/imports.md +62 -0
  238. aider/website/docs/troubleshooting/models-and-keys.md +54 -0
  239. aider/website/docs/troubleshooting/support.md +79 -0
  240. aider/website/docs/troubleshooting/token-limits.md +96 -0
  241. aider/website/docs/troubleshooting/warnings.md +12 -0
  242. aider/website/docs/troubleshooting.md +11 -0
  243. aider/website/docs/usage/browser.md +57 -0
  244. aider/website/docs/usage/caching.md +49 -0
  245. aider/website/docs/usage/commands.md +133 -0
  246. aider/website/docs/usage/conventions.md +119 -0
  247. aider/website/docs/usage/copypaste.md +121 -0
  248. aider/website/docs/usage/images-urls.md +48 -0
  249. aider/website/docs/usage/lint-test.md +118 -0
  250. aider/website/docs/usage/modes.md +211 -0
  251. aider/website/docs/usage/not-code.md +179 -0
  252. aider/website/docs/usage/notifications.md +87 -0
  253. aider/website/docs/usage/tips.md +79 -0
  254. aider/website/docs/usage/tutorials.md +30 -0
  255. aider/website/docs/usage/voice.md +121 -0
  256. aider/website/docs/usage/watch.md +294 -0
  257. aider/website/docs/usage.md +102 -0
  258. aider/website/share/index.md +101 -0
  259. aider_ce-0.87.2.dev9.dist-info/METADATA +543 -0
  260. aider_ce-0.87.2.dev9.dist-info/RECORD +264 -0
  261. aider_ce-0.87.2.dev9.dist-info/WHEEL +5 -0
  262. aider_ce-0.87.2.dev9.dist-info/entry_points.txt +3 -0
  263. aider_ce-0.87.2.dev9.dist-info/licenses/LICENSE.txt +202 -0
  264. aider_ce-0.87.2.dev9.dist-info/top_level.txt +1 -0
aider/io.py ADDED
@@ -0,0 +1,1257 @@
1
+ import base64
2
+ import functools
3
+ import os
4
+ import shutil
5
+ import signal
6
+ import subprocess
7
+ import time
8
+ import webbrowser
9
+ from collections import defaultdict
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from io import StringIO
13
+ from pathlib import Path
14
+
15
+ from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter
16
+ from prompt_toolkit.cursor_shapes import ModalCursorShapeConfig
17
+ from prompt_toolkit.enums import EditingMode
18
+ from prompt_toolkit.filters import Condition, is_searching
19
+ from prompt_toolkit.history import FileHistory
20
+ from prompt_toolkit.key_binding import KeyBindings
21
+ from prompt_toolkit.key_binding.vi_state import InputMode
22
+ from prompt_toolkit.keys import Keys
23
+ from prompt_toolkit.lexers import PygmentsLexer
24
+ from prompt_toolkit.output.vt100 import is_dumb_terminal
25
+ from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
26
+ from prompt_toolkit.styles import Style
27
+ from pygments.lexers import MarkdownLexer, guess_lexer_for_filename
28
+ from pygments.token import Token
29
+ from rich.color import ColorParseError
30
+ from rich.columns import Columns
31
+ from rich.console import Console
32
+ from rich.markdown import Markdown
33
+ from rich.style import Style as RichStyle
34
+ from rich.text import Text
35
+
36
+ from aider.mdstream import MarkdownStream
37
+
38
+ from .dump import dump # noqa: F401
39
+ from .editor import pipe_editor
40
+ from .utils import is_image_file, run_fzf
41
+
42
+ # Constants
43
+ NOTIFICATION_MESSAGE = "Aider is waiting for your input"
44
+
45
+
46
+ def ensure_hash_prefix(color):
47
+ """Ensure hex color values have a # prefix."""
48
+ if not color:
49
+ return color
50
+ if isinstance(color, str) and color.strip() and not color.startswith("#"):
51
+ # Check if it's a valid hex color (3 or 6 hex digits)
52
+ if all(c in "0123456789ABCDEFabcdef" for c in color) and len(color) in (3, 6):
53
+ return f"#{color}"
54
+ return color
55
+
56
+
57
+ def restore_multiline(func):
58
+ """Decorator to restore multiline mode after function execution"""
59
+
60
+ @functools.wraps(func)
61
+ def wrapper(self, *args, **kwargs):
62
+ orig_multiline = self.multiline_mode
63
+ self.multiline_mode = False
64
+ try:
65
+ return func(self, *args, **kwargs)
66
+ except Exception:
67
+ raise
68
+ finally:
69
+ self.multiline_mode = orig_multiline
70
+
71
+ return wrapper
72
+
73
+
74
+ class CommandCompletionException(Exception):
75
+ """Raised when a command should use the normal autocompleter instead of
76
+ command-specific completion."""
77
+
78
+ pass
79
+
80
+
81
+ @dataclass
82
+ class ConfirmGroup:
83
+ preference: str = None
84
+ show_group: bool = True
85
+
86
+ def __init__(self, items=None):
87
+ if items is not None:
88
+ self.show_group = len(items) > 1
89
+
90
+
91
+ class AutoCompleter(Completer):
92
+ def __init__(
93
+ self, root, rel_fnames, addable_rel_fnames, commands, encoding, abs_read_only_fnames=None
94
+ ):
95
+ self.addable_rel_fnames = addable_rel_fnames
96
+ self.rel_fnames = rel_fnames
97
+ self.encoding = encoding
98
+ self.abs_read_only_fnames = abs_read_only_fnames or []
99
+
100
+ fname_to_rel_fnames = defaultdict(list)
101
+ for rel_fname in addable_rel_fnames:
102
+ fname = os.path.basename(rel_fname)
103
+ if fname != rel_fname:
104
+ fname_to_rel_fnames[fname].append(rel_fname)
105
+ self.fname_to_rel_fnames = fname_to_rel_fnames
106
+
107
+ self.words = set()
108
+
109
+ self.commands = commands
110
+ self.command_completions = dict()
111
+ if commands:
112
+ self.command_names = self.commands.get_commands()
113
+
114
+ for rel_fname in addable_rel_fnames:
115
+ self.words.add(rel_fname)
116
+
117
+ for rel_fname in rel_fnames:
118
+ self.words.add(rel_fname)
119
+
120
+ all_fnames = [Path(root) / rel_fname for rel_fname in rel_fnames]
121
+ if abs_read_only_fnames:
122
+ all_fnames.extend(abs_read_only_fnames)
123
+
124
+ self.all_fnames = all_fnames
125
+ self.tokenized = False
126
+
127
+ def tokenize(self):
128
+ if self.tokenized:
129
+ return
130
+ self.tokenized = True
131
+
132
+ # Performance optimization for large file sets
133
+ if len(self.all_fnames) > 100:
134
+ # Skip tokenization for very large numbers of files to avoid input lag
135
+ self.tokenized = True
136
+ return
137
+
138
+ # Limit number of files to process to avoid excessive tokenization time
139
+ process_fnames = self.all_fnames
140
+ if len(process_fnames) > 50:
141
+ # Only process a subset of files to maintain responsiveness
142
+ process_fnames = process_fnames[:50]
143
+
144
+ for fname in process_fnames:
145
+ try:
146
+ with open(fname, "r", encoding=self.encoding) as f:
147
+ content = f.read()
148
+ except (FileNotFoundError, UnicodeDecodeError, IsADirectoryError):
149
+ continue
150
+ try:
151
+ lexer = guess_lexer_for_filename(fname, content)
152
+ except Exception: # On Windows, bad ref to time.clock which is deprecated
153
+ continue
154
+
155
+ tokens = list(lexer.get_tokens(content))
156
+ self.words.update(
157
+ (token[1], f"`{token[1]}`") for token in tokens if token[0] in Token.Name
158
+ )
159
+
160
+ def get_command_completions(self, document, complete_event, text, words):
161
+ if len(words) == 1 and not text[-1].isspace():
162
+ partial = words[0].lower()
163
+ candidates = [cmd for cmd in self.command_names if cmd.startswith(partial)]
164
+ for candidate in sorted(candidates):
165
+ yield Completion(candidate, start_position=-len(words[-1]))
166
+ return
167
+
168
+ if len(words) <= 1 or text[-1].isspace():
169
+ return
170
+
171
+ cmd = words[0]
172
+ partial = words[-1].lower()
173
+
174
+ matches, _, _ = self.commands.matching_commands(cmd)
175
+ if len(matches) == 1:
176
+ cmd = matches[0]
177
+ elif cmd not in matches:
178
+ return
179
+
180
+ raw_completer = self.commands.get_raw_completions(cmd)
181
+ if raw_completer:
182
+ yield from raw_completer(document, complete_event)
183
+ return
184
+
185
+ if cmd not in self.command_completions:
186
+ candidates = self.commands.get_completions(cmd)
187
+ self.command_completions[cmd] = candidates
188
+ else:
189
+ candidates = self.command_completions[cmd]
190
+
191
+ if candidates is None:
192
+ return
193
+
194
+ candidates = [word for word in candidates if partial in word.lower()]
195
+ for candidate in sorted(candidates):
196
+ yield Completion(candidate, start_position=-len(words[-1]))
197
+
198
+ def get_completions(self, document, complete_event):
199
+ self.tokenize()
200
+
201
+ text = document.text_before_cursor
202
+ words = text.split()
203
+ if not words:
204
+ return
205
+
206
+ if text and text[-1].isspace():
207
+ # don't keep completing after a space
208
+ return
209
+
210
+ if text[0] == "/":
211
+ try:
212
+ yield from self.get_command_completions(document, complete_event, text, words)
213
+ return
214
+ except CommandCompletionException:
215
+ # Fall through to normal completion
216
+ pass
217
+
218
+ candidates = self.words
219
+ candidates.update(set(self.fname_to_rel_fnames))
220
+ candidates = [word if type(word) is tuple else (word, word) for word in candidates]
221
+
222
+ last_word = words[-1]
223
+
224
+ # Only provide completions if the user has typed at least 3 characters
225
+ if len(last_word) < 3:
226
+ return
227
+
228
+ completions = []
229
+ for word_match, word_insert in candidates:
230
+ if word_match.lower().startswith(last_word.lower()):
231
+ completions.append((word_insert, -len(last_word), word_match))
232
+
233
+ rel_fnames = self.fname_to_rel_fnames.get(word_match, [])
234
+ if rel_fnames:
235
+ for rel_fname in rel_fnames:
236
+ completions.append((rel_fname, -len(last_word), rel_fname))
237
+
238
+ for ins, pos, match in sorted(completions):
239
+ yield Completion(ins, start_position=pos, display=match)
240
+
241
+
242
+ class InputOutput:
243
+ num_error_outputs = 0
244
+ num_user_asks = 0
245
+ clipboard_watcher = None
246
+ bell_on_next_input = False
247
+ notifications_command = None
248
+ encoding = "utf-8"
249
+
250
+ def __init__(
251
+ self,
252
+ pretty=True,
253
+ yes=None,
254
+ input_history_file=None,
255
+ chat_history_file=None,
256
+ input=None,
257
+ output=None,
258
+ user_input_color="blue",
259
+ tool_output_color=None,
260
+ tool_error_color="red",
261
+ tool_warning_color="#FFA500",
262
+ assistant_output_color="blue",
263
+ completion_menu_color=None,
264
+ completion_menu_bg_color=None,
265
+ completion_menu_current_color=None,
266
+ completion_menu_current_bg_color=None,
267
+ code_theme="default",
268
+ encoding="utf-8",
269
+ line_endings="platform",
270
+ dry_run=False,
271
+ llm_history_file=None,
272
+ editingmode=EditingMode.EMACS,
273
+ fancy_input=True,
274
+ file_watcher=None,
275
+ multiline_mode=False,
276
+ root=".",
277
+ notifications=False,
278
+ notifications_command=None,
279
+ ):
280
+ self.console = Console()
281
+ self.pretty = pretty
282
+ if chat_history_file is not None:
283
+ self.chat_history_file = Path(chat_history_file)
284
+ else:
285
+ self.chat_history_file = None
286
+
287
+ self.placeholder = None
288
+ self.interrupted = False
289
+ self.never_prompts = set()
290
+ self.editingmode = editingmode
291
+ self.multiline_mode = multiline_mode
292
+ self.bell_on_next_input = False
293
+ self.notifications = notifications
294
+ if notifications and notifications_command is None:
295
+ self.notifications_command = self.get_default_notification_command()
296
+ else:
297
+ self.notifications_command = notifications_command
298
+
299
+ no_color = os.environ.get("NO_COLOR")
300
+ if no_color is not None and no_color != "":
301
+ pretty = False
302
+
303
+ self.user_input_color = ensure_hash_prefix(user_input_color) if pretty else None
304
+ self.tool_output_color = ensure_hash_prefix(tool_output_color) if pretty else None
305
+ self.tool_error_color = ensure_hash_prefix(tool_error_color) if pretty else None
306
+ self.tool_warning_color = ensure_hash_prefix(tool_warning_color) if pretty else None
307
+ self.assistant_output_color = ensure_hash_prefix(assistant_output_color)
308
+ self.completion_menu_color = ensure_hash_prefix(completion_menu_color) if pretty else None
309
+ self.completion_menu_bg_color = (
310
+ ensure_hash_prefix(completion_menu_bg_color) if pretty else None
311
+ )
312
+ self.completion_menu_current_color = (
313
+ ensure_hash_prefix(completion_menu_current_color) if pretty else None
314
+ )
315
+ self.completion_menu_current_bg_color = (
316
+ ensure_hash_prefix(completion_menu_current_bg_color) if pretty else None
317
+ )
318
+
319
+ self.fzf_available = shutil.which("fzf")
320
+ if not self.fzf_available:
321
+ self.tool_warning(
322
+ "fzf not found, fuzzy finder features will be disabled. Install it for enhanced"
323
+ " file/history search."
324
+ )
325
+
326
+ self.code_theme = code_theme
327
+
328
+ self.input = input
329
+ self.output = output
330
+
331
+ self.pretty = pretty
332
+ if self.output:
333
+ self.pretty = False
334
+
335
+ self.yes = yes
336
+
337
+ self.input_history_file = input_history_file
338
+ if self.input_history_file:
339
+ try:
340
+ Path(self.input_history_file).parent.mkdir(parents=True, exist_ok=True)
341
+ except (PermissionError, OSError) as e:
342
+ self.tool_warning(f"Could not create directory for input history: {e}")
343
+ self.input_history_file = None
344
+ self.llm_history_file = llm_history_file
345
+ if chat_history_file is not None:
346
+ self.chat_history_file = Path(chat_history_file)
347
+ else:
348
+ self.chat_history_file = None
349
+
350
+ self.encoding = encoding
351
+ valid_line_endings = {"platform", "lf", "crlf"}
352
+ if line_endings not in valid_line_endings:
353
+ raise ValueError(
354
+ f"Invalid line_endings value: {line_endings}. "
355
+ f"Must be one of: {', '.join(valid_line_endings)}"
356
+ )
357
+ self.newline = (
358
+ None if line_endings == "platform" else "\n" if line_endings == "lf" else "\r\n"
359
+ )
360
+ self.dry_run = dry_run
361
+
362
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
363
+ self.append_chat_history(f"\n# aider chat started at {current_time}\n\n")
364
+
365
+ self.prompt_session = None
366
+ self.is_dumb_terminal = is_dumb_terminal()
367
+
368
+ if self.is_dumb_terminal:
369
+ self.pretty = False
370
+ fancy_input = False
371
+
372
+ if fancy_input:
373
+ # Initialize PromptSession only if we have a capable terminal
374
+ session_kwargs = {
375
+ "input": self.input,
376
+ "output": self.output,
377
+ "lexer": PygmentsLexer(MarkdownLexer),
378
+ "editing_mode": self.editingmode,
379
+ }
380
+ if self.editingmode == EditingMode.VI:
381
+ session_kwargs["cursor"] = ModalCursorShapeConfig()
382
+ if self.input_history_file is not None:
383
+ session_kwargs["history"] = FileHistory(self.input_history_file)
384
+ try:
385
+ self.prompt_session = PromptSession(**session_kwargs)
386
+ self.console = Console() # pretty console
387
+ except Exception as err:
388
+ self.console = Console(force_terminal=False, no_color=True)
389
+ self.tool_error(f"Can't initialize prompt toolkit: {err}") # non-pretty
390
+ else:
391
+ self.console = Console(force_terminal=False, no_color=True) # non-pretty
392
+ if self.is_dumb_terminal:
393
+ self.tool_output("Detected dumb terminal, disabling fancy input and pretty output.")
394
+
395
+ self.file_watcher = file_watcher
396
+ self.root = root
397
+
398
+ # Validate color settings after console is initialized
399
+ self._validate_color_settings()
400
+
401
+ def _validate_color_settings(self):
402
+ """Validate configured color strings and reset invalid ones."""
403
+ color_attributes = [
404
+ "user_input_color",
405
+ "tool_output_color",
406
+ "tool_error_color",
407
+ "tool_warning_color",
408
+ "assistant_output_color",
409
+ "completion_menu_color",
410
+ "completion_menu_bg_color",
411
+ "completion_menu_current_color",
412
+ "completion_menu_current_bg_color",
413
+ ]
414
+ for attr_name in color_attributes:
415
+ color_value = getattr(self, attr_name, None)
416
+ if color_value:
417
+ try:
418
+ # Try creating a style to validate the color
419
+ RichStyle(color=color_value)
420
+ except ColorParseError as e:
421
+ self.console.print(
422
+ "[bold red]Warning:[/bold red] Invalid configuration for"
423
+ f" {attr_name}: '{color_value}'. {e}. Disabling this color."
424
+ )
425
+ setattr(self, attr_name, None) # Reset invalid color to None
426
+
427
+ def _get_style(self):
428
+ style_dict = {}
429
+ if not self.pretty:
430
+ return Style.from_dict(style_dict)
431
+
432
+ if self.user_input_color:
433
+ style_dict.setdefault("", self.user_input_color)
434
+ style_dict.update(
435
+ {
436
+ "pygments.literal.string": f"bold italic {self.user_input_color}",
437
+ }
438
+ )
439
+
440
+ # Conditionally add 'completion-menu' style
441
+ completion_menu_style = []
442
+ if self.completion_menu_bg_color:
443
+ completion_menu_style.append(f"bg:{self.completion_menu_bg_color}")
444
+ if self.completion_menu_color:
445
+ completion_menu_style.append(self.completion_menu_color)
446
+ if completion_menu_style:
447
+ style_dict["completion-menu"] = " ".join(completion_menu_style)
448
+
449
+ # Conditionally add 'completion-menu.completion.current' style
450
+ completion_menu_current_style = []
451
+ if self.completion_menu_current_bg_color:
452
+ completion_menu_current_style.append(self.completion_menu_current_bg_color)
453
+ if self.completion_menu_current_color:
454
+ completion_menu_current_style.append(f"bg:{self.completion_menu_current_color}")
455
+ if completion_menu_current_style:
456
+ style_dict["completion-menu.completion.current"] = " ".join(
457
+ completion_menu_current_style
458
+ )
459
+
460
+ return Style.from_dict(style_dict)
461
+
462
+ def read_image(self, filename):
463
+ try:
464
+ with open(str(filename), "rb") as image_file:
465
+ encoded_string = base64.b64encode(image_file.read())
466
+ return encoded_string.decode("utf-8")
467
+ except OSError as err:
468
+ self.tool_error(f"{filename}: unable to read: {err}")
469
+ return
470
+ except FileNotFoundError:
471
+ self.tool_error(f"{filename}: file not found error")
472
+ return
473
+ except IsADirectoryError:
474
+ self.tool_error(f"{filename}: is a directory")
475
+ return
476
+ except Exception as e:
477
+ self.tool_error(f"{filename}: {e}")
478
+ return
479
+
480
+ def read_text(self, filename, silent=False):
481
+ if is_image_file(filename):
482
+ return self.read_image(filename)
483
+
484
+ try:
485
+ with open(str(filename), "r", encoding=self.encoding) as f:
486
+ return f.read()
487
+ except FileNotFoundError:
488
+ if not silent:
489
+ self.tool_error(f"{filename}: file not found error")
490
+ return
491
+ except IsADirectoryError:
492
+ if not silent:
493
+ self.tool_error(f"{filename}: is a directory")
494
+ return
495
+ except OSError as err:
496
+ if not silent:
497
+ self.tool_error(f"{filename}: unable to read: {err}")
498
+ return
499
+ except UnicodeError as e:
500
+ if not silent:
501
+ self.tool_error(f"{filename}: {e}")
502
+ self.tool_error("Use --encoding to set the unicode encoding.")
503
+ return
504
+
505
+ def write_text(self, filename, content, max_retries=5, initial_delay=0.1):
506
+ """
507
+ Writes content to a file, retrying with progressive backoff if the file is locked.
508
+
509
+ :param filename: Path to the file to write.
510
+ :param content: Content to write to the file.
511
+ :param max_retries: Maximum number of retries if a file lock is encountered.
512
+ :param initial_delay: Initial delay (in seconds) before the first retry.
513
+ """
514
+ if self.dry_run:
515
+ return
516
+
517
+ delay = initial_delay
518
+ for attempt in range(max_retries):
519
+ try:
520
+ with open(str(filename), "w", encoding=self.encoding, newline=self.newline) as f:
521
+ f.write(content)
522
+ return # Successfully wrote the file
523
+ except PermissionError as err:
524
+ if attempt < max_retries - 1:
525
+ time.sleep(delay)
526
+ delay *= 2 # Exponential backoff
527
+ else:
528
+ self.tool_error(
529
+ f"Unable to write file {filename} after {max_retries} attempts: {err}"
530
+ )
531
+ raise
532
+ except OSError as err:
533
+ self.tool_error(f"Unable to write file {filename}: {err}")
534
+ raise
535
+
536
+ def rule(self):
537
+ if self.pretty:
538
+ style = dict(style=self.user_input_color) if self.user_input_color else dict()
539
+ self.console.rule(**style)
540
+ else:
541
+ print()
542
+
543
+ def interrupt_input(self):
544
+ if self.prompt_session and self.prompt_session.app:
545
+ # Store any partial input before interrupting
546
+ self.placeholder = self.prompt_session.app.current_buffer.text
547
+ self.interrupted = True
548
+ self.prompt_session.app.exit()
549
+
550
+ def get_input(
551
+ self,
552
+ root,
553
+ rel_fnames,
554
+ addable_rel_fnames,
555
+ commands,
556
+ abs_read_only_fnames=None,
557
+ edit_format=None,
558
+ ):
559
+ self.rule()
560
+
561
+ # Ring the bell if needed
562
+ self.ring_bell()
563
+
564
+ rel_fnames = list(rel_fnames)
565
+ show = ""
566
+ if rel_fnames:
567
+ rel_read_only_fnames = [
568
+ get_rel_fname(fname, root) for fname in (abs_read_only_fnames or [])
569
+ ]
570
+ show = self.format_files_for_input(rel_fnames, rel_read_only_fnames)
571
+
572
+ prompt_prefix = ""
573
+ if edit_format:
574
+ prompt_prefix += edit_format
575
+ if self.multiline_mode:
576
+ prompt_prefix += (" " if edit_format else "") + "multi"
577
+ prompt_prefix += "> "
578
+
579
+ show += prompt_prefix
580
+ self.prompt_prefix = prompt_prefix
581
+
582
+ inp = ""
583
+ multiline_input = False
584
+
585
+ style = self._get_style()
586
+
587
+ completer_instance = ThreadedCompleter(
588
+ AutoCompleter(
589
+ root,
590
+ rel_fnames,
591
+ addable_rel_fnames,
592
+ commands,
593
+ self.encoding,
594
+ abs_read_only_fnames=abs_read_only_fnames,
595
+ )
596
+ )
597
+
598
+ def suspend_to_bg(event):
599
+ """Suspend currently running application."""
600
+ event.app.suspend_to_background()
601
+
602
+ kb = KeyBindings()
603
+
604
+ @kb.add(Keys.ControlZ, filter=Condition(lambda: hasattr(signal, "SIGTSTP")))
605
+ def _(event):
606
+ "Suspend to background with ctrl-z"
607
+ suspend_to_bg(event)
608
+
609
+ @kb.add("c-space")
610
+ def _(event):
611
+ "Ignore Ctrl when pressing space bar"
612
+ event.current_buffer.insert_text(" ")
613
+
614
+ @kb.add("c-up")
615
+ def _(event):
616
+ "Navigate backward through history"
617
+ event.current_buffer.history_backward()
618
+
619
+ @kb.add("c-down")
620
+ def _(event):
621
+ "Navigate forward through history"
622
+ event.current_buffer.history_forward()
623
+
624
+ @kb.add("c-x", "c-e")
625
+ def _(event):
626
+ "Edit current input in external editor (like Bash)"
627
+ buffer = event.current_buffer
628
+ current_text = buffer.text
629
+
630
+ # Open the editor with the current text
631
+ edited_text = pipe_editor(input_data=current_text, suffix="md")
632
+
633
+ # Replace the buffer with the edited text, strip any trailing newlines
634
+ buffer.text = edited_text.rstrip("\n")
635
+
636
+ # Move cursor to the end of the text
637
+ buffer.cursor_position = len(buffer.text)
638
+
639
+ @kb.add("c-t", filter=Condition(lambda: self.fzf_available))
640
+ def _(event):
641
+ "Fuzzy find files to add to the chat"
642
+ buffer = event.current_buffer
643
+ if not buffer.text.strip().startswith("/add "):
644
+ return
645
+
646
+ files = run_fzf(addable_rel_fnames, multi=True)
647
+ if files:
648
+ buffer.text = "/add " + " ".join(files)
649
+ buffer.cursor_position = len(buffer.text)
650
+
651
+ @kb.add("c-r", filter=Condition(lambda: self.fzf_available))
652
+ def _(event):
653
+ "Fuzzy search in history and paste it in the prompt"
654
+ buffer = event.current_buffer
655
+ history_lines = self.get_input_history()
656
+ selected_lines = run_fzf(history_lines)
657
+ if selected_lines:
658
+ buffer.text = "".join(selected_lines)
659
+ buffer.cursor_position = len(buffer.text)
660
+
661
+ @kb.add("enter", eager=True, filter=~is_searching)
662
+ def _(event):
663
+ "Handle Enter key press"
664
+ if self.multiline_mode and not (
665
+ self.editingmode == EditingMode.VI
666
+ and event.app.vi_state.input_mode == InputMode.NAVIGATION
667
+ ):
668
+ # In multiline mode and if not in vi-mode or vi navigation/normal mode,
669
+ # Enter adds a newline
670
+ event.current_buffer.insert_text("\n")
671
+ else:
672
+ # In normal mode, Enter submits
673
+ event.current_buffer.validate_and_handle()
674
+
675
+ @kb.add("escape", "enter", eager=True, filter=~is_searching) # This is Alt+Enter
676
+ def _(event):
677
+ "Handle Alt+Enter key press"
678
+ if self.multiline_mode:
679
+ # In multiline mode, Alt+Enter submits
680
+ event.current_buffer.validate_and_handle()
681
+ else:
682
+ # In normal mode, Alt+Enter adds a newline
683
+ event.current_buffer.insert_text("\n")
684
+
685
+ while True:
686
+ if multiline_input:
687
+ show = self.prompt_prefix
688
+
689
+ try:
690
+ if self.prompt_session:
691
+ # Use placeholder if set, then clear it
692
+ default = self.placeholder or ""
693
+ self.placeholder = None
694
+
695
+ self.interrupted = False
696
+ if not multiline_input:
697
+ if self.file_watcher:
698
+ self.file_watcher.start()
699
+ if self.clipboard_watcher:
700
+ self.clipboard_watcher.start()
701
+
702
+ def get_continuation(width, line_number, is_soft_wrap):
703
+ return self.prompt_prefix
704
+
705
+ line = self.prompt_session.prompt(
706
+ show,
707
+ default=default,
708
+ completer=completer_instance,
709
+ reserve_space_for_menu=4,
710
+ complete_style=CompleteStyle.MULTI_COLUMN,
711
+ style=style,
712
+ key_bindings=kb,
713
+ complete_while_typing=True,
714
+ prompt_continuation=get_continuation,
715
+ )
716
+ else:
717
+ line = input(show)
718
+
719
+ # Check if we were interrupted by a file change
720
+ if self.interrupted:
721
+ line = line or ""
722
+ if self.file_watcher:
723
+ cmd = self.file_watcher.process_changes()
724
+ return cmd
725
+
726
+ except EOFError:
727
+ raise
728
+ except Exception as err:
729
+ import traceback
730
+
731
+ self.tool_error(str(err))
732
+ self.tool_error(traceback.format_exc())
733
+ return ""
734
+ except UnicodeEncodeError as err:
735
+ self.tool_error(str(err))
736
+ return ""
737
+ finally:
738
+ if self.file_watcher:
739
+ self.file_watcher.stop()
740
+ if self.clipboard_watcher:
741
+ self.clipboard_watcher.stop()
742
+
743
+ if line.strip("\r\n") and not multiline_input:
744
+ stripped = line.strip("\r\n")
745
+ if stripped == "{":
746
+ multiline_input = True
747
+ multiline_tag = None
748
+ inp += ""
749
+ elif stripped[0] == "{":
750
+ # Extract tag if it exists (only alphanumeric chars)
751
+ tag = "".join(c for c in stripped[1:] if c.isalnum())
752
+ if stripped == "{" + tag:
753
+ multiline_input = True
754
+ multiline_tag = tag
755
+ inp += ""
756
+ else:
757
+ inp = line
758
+ break
759
+ else:
760
+ inp = line
761
+ break
762
+ continue
763
+ elif multiline_input and line.strip():
764
+ if multiline_tag:
765
+ # Check if line is exactly "tag}"
766
+ if line.strip("\r\n") == f"{multiline_tag}}}":
767
+ break
768
+ else:
769
+ inp += line + "\n"
770
+ # Check if line is exactly "}"
771
+ elif line.strip("\r\n") == "}":
772
+ break
773
+ else:
774
+ inp += line + "\n"
775
+ elif multiline_input:
776
+ inp += line + "\n"
777
+ else:
778
+ inp = line
779
+ break
780
+
781
+ print()
782
+ self.user_input(inp)
783
+ return inp
784
+
785
+ def add_to_input_history(self, inp):
786
+ if not self.input_history_file:
787
+ return
788
+ try:
789
+ FileHistory(self.input_history_file).append_string(inp)
790
+ # Also add to the in-memory history if it exists
791
+ if self.prompt_session and self.prompt_session.history:
792
+ self.prompt_session.history.append_string(inp)
793
+ except OSError as err:
794
+ self.tool_warning(f"Unable to write to input history file: {err}")
795
+
796
+ def get_input_history(self):
797
+ if not self.input_history_file:
798
+ return []
799
+
800
+ fh = FileHistory(self.input_history_file)
801
+ return fh.load_history_strings()
802
+
803
+ def log_llm_history(self, role, content):
804
+ if not self.llm_history_file:
805
+ return
806
+ timestamp = datetime.now().isoformat(timespec="seconds")
807
+ try:
808
+ Path(self.llm_history_file).parent.mkdir(parents=True, exist_ok=True)
809
+ with open(self.llm_history_file, "a", encoding="utf-8") as log_file:
810
+ log_file.write(f"{role.upper()} {timestamp}\n")
811
+ log_file.write(content + "\n")
812
+ except (PermissionError, OSError) as err:
813
+ self.tool_warning(f"Unable to write to llm history file {self.llm_history_file}: {err}")
814
+ self.llm_history_file = None
815
+
816
+ def display_user_input(self, inp):
817
+ if self.pretty and self.user_input_color:
818
+ style = dict(style=self.user_input_color)
819
+ else:
820
+ style = dict()
821
+
822
+ self.console.print(Text(inp), **style)
823
+
824
+ def user_input(self, inp, log_only=True):
825
+ if not log_only:
826
+ self.display_user_input(inp)
827
+
828
+ prefix = "####"
829
+ if inp:
830
+ hist = inp.splitlines()
831
+ else:
832
+ hist = ["<blank>"]
833
+
834
+ hist = f" \n{prefix} ".join(hist)
835
+
836
+ hist = f"""
837
+ {prefix} {hist}"""
838
+ self.append_chat_history(hist, linebreak=True)
839
+
840
+ # OUTPUT
841
+
842
+ def ai_output(self, content):
843
+ hist = "\n" + content.strip() + "\n\n"
844
+ self.append_chat_history(hist)
845
+
846
+ def offer_url(self, url, prompt="Open URL for more info?", allow_never=True):
847
+ """Offer to open a URL in the browser, returns True if opened."""
848
+ if url in self.never_prompts:
849
+ return False
850
+ if self.confirm_ask(prompt, subject=url, allow_never=allow_never):
851
+ webbrowser.open(url)
852
+ return True
853
+ return False
854
+
855
+ @restore_multiline
856
+ def confirm_ask(
857
+ self,
858
+ question,
859
+ default="y",
860
+ subject=None,
861
+ explicit_yes_required=False,
862
+ group=None,
863
+ allow_never=False,
864
+ ):
865
+ self.num_user_asks += 1
866
+
867
+ # Ring the bell if needed
868
+ self.ring_bell()
869
+
870
+ question_id = (question, subject)
871
+
872
+ if question_id in self.never_prompts:
873
+ return False
874
+
875
+ if group and not group.show_group:
876
+ group = None
877
+ if group:
878
+ allow_never = True
879
+
880
+ valid_responses = ["yes", "no", "skip", "all"]
881
+ options = " (Y)es/(N)o"
882
+ if group:
883
+ if not explicit_yes_required:
884
+ options += "/(A)ll"
885
+ options += "/(S)kip all"
886
+ if allow_never:
887
+ options += "/(D)on't ask again"
888
+ valid_responses.append("don't")
889
+
890
+ if default.lower().startswith("y"):
891
+ question += options + " [Yes]: "
892
+ elif default.lower().startswith("n"):
893
+ question += options + " [No]: "
894
+ else:
895
+ question += options + f" [{default}]: "
896
+
897
+ if subject:
898
+ self.tool_output()
899
+ if "\n" in subject:
900
+ lines = subject.splitlines()
901
+ max_length = max(len(line) for line in lines)
902
+ padded_lines = [line.ljust(max_length) for line in lines]
903
+ padded_subject = "\n".join(padded_lines)
904
+ self.tool_output(padded_subject, bold=True)
905
+ else:
906
+ self.tool_output(subject, bold=True)
907
+
908
+ style = self._get_style()
909
+
910
+ def is_valid_response(text):
911
+ if not text:
912
+ return True
913
+ return text.lower() in valid_responses
914
+
915
+ if self.yes is True:
916
+ res = "n" if explicit_yes_required else "y"
917
+ elif self.yes is False:
918
+ res = "n"
919
+ elif group and group.preference:
920
+ res = group.preference
921
+ self.user_input(f"{question}{res}", log_only=False)
922
+ else:
923
+ while True:
924
+ try:
925
+ if self.prompt_session:
926
+ res = self.prompt_session.prompt(
927
+ question,
928
+ style=style,
929
+ complete_while_typing=False,
930
+ )
931
+ else:
932
+ res = input(question)
933
+ except EOFError:
934
+ # Treat EOF (Ctrl+D) as if the user pressed Enter
935
+ res = default
936
+ break
937
+
938
+ if not res:
939
+ res = default
940
+ break
941
+ res = res.lower()
942
+ good = any(valid_response.startswith(res) for valid_response in valid_responses)
943
+ if good:
944
+ break
945
+
946
+ error_message = f"Please answer with one of: {', '.join(valid_responses)}"
947
+ self.tool_error(error_message)
948
+
949
+ res = res.lower()[0]
950
+
951
+ if res == "d" and allow_never:
952
+ self.never_prompts.add(question_id)
953
+ hist = f"{question.strip()} {res}"
954
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
955
+ return False
956
+
957
+ if explicit_yes_required:
958
+ is_yes = res == "y"
959
+ else:
960
+ is_yes = res in ("y", "a")
961
+
962
+ is_all = res == "a" and group is not None and not explicit_yes_required
963
+ is_skip = res == "s" and group is not None
964
+
965
+ if group:
966
+ if is_all and not explicit_yes_required:
967
+ group.preference = "all"
968
+ elif is_skip:
969
+ group.preference = "skip"
970
+
971
+ hist = f"{question.strip()} {res}"
972
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
973
+
974
+ return is_yes
975
+
976
+ @restore_multiline
977
+ def prompt_ask(self, question, default="", subject=None):
978
+ self.num_user_asks += 1
979
+
980
+ # Ring the bell if needed
981
+ self.ring_bell()
982
+
983
+ if subject:
984
+ self.tool_output()
985
+ self.tool_output(subject, bold=True)
986
+
987
+ style = self._get_style()
988
+
989
+ if self.yes is True:
990
+ res = "yes"
991
+ elif self.yes is False:
992
+ res = "no"
993
+ else:
994
+ try:
995
+ if self.prompt_session:
996
+ res = self.prompt_session.prompt(
997
+ question + " ",
998
+ default=default,
999
+ style=style,
1000
+ complete_while_typing=True,
1001
+ )
1002
+ else:
1003
+ res = input(question + " ")
1004
+ except EOFError:
1005
+ # Treat EOF (Ctrl+D) as if the user pressed Enter
1006
+ res = default
1007
+
1008
+ hist = f"{question.strip()} {res.strip()}"
1009
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1010
+ if self.yes in (True, False):
1011
+ self.tool_output(hist)
1012
+
1013
+ return res
1014
+
1015
+ def _tool_message(self, message="", strip=True, color=None):
1016
+ if message.strip():
1017
+ if "\n" in message:
1018
+ for line in message.splitlines():
1019
+ self.append_chat_history(line, linebreak=True, blockquote=True, strip=strip)
1020
+ else:
1021
+ hist = message.strip() if strip else message
1022
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1023
+
1024
+ if not isinstance(message, Text):
1025
+ message = Text(message)
1026
+ color = ensure_hash_prefix(color) if color else None
1027
+ style = dict(style=color) if self.pretty and color else dict()
1028
+ try:
1029
+ self.console.print(message, **style)
1030
+ except UnicodeEncodeError:
1031
+ # Fallback to ASCII-safe output
1032
+ if isinstance(message, Text):
1033
+ message = message.plain
1034
+ message = str(message).encode("ascii", errors="replace").decode("ascii")
1035
+ self.console.print(message, **style)
1036
+
1037
+ def tool_error(self, message="", strip=True):
1038
+ self.num_error_outputs += 1
1039
+ self._tool_message(message, strip, self.tool_error_color)
1040
+
1041
+ def tool_warning(self, message="", strip=True):
1042
+ self._tool_message(message, strip, self.tool_warning_color)
1043
+
1044
+ def tool_output(self, *messages, log_only=False, bold=False):
1045
+ if messages:
1046
+ hist = " ".join(messages)
1047
+ hist = f"{hist.strip()}"
1048
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1049
+
1050
+ if log_only:
1051
+ return
1052
+
1053
+ messages = list(map(Text, messages))
1054
+ style = dict()
1055
+ if self.pretty:
1056
+ if self.tool_output_color:
1057
+ style["color"] = ensure_hash_prefix(self.tool_output_color)
1058
+ style["reverse"] = bold
1059
+
1060
+ style = RichStyle(**style)
1061
+ self.console.print(*messages, style=style)
1062
+
1063
+ def get_assistant_mdstream(self):
1064
+ mdargs = dict(
1065
+ style=self.assistant_output_color,
1066
+ code_theme=self.code_theme,
1067
+ inline_code_lexer="text",
1068
+ )
1069
+ mdStream = MarkdownStream(mdargs=mdargs)
1070
+ return mdStream
1071
+
1072
+ def assistant_output(self, message, pretty=None):
1073
+ if not message:
1074
+ self.tool_warning("Empty response received from LLM. Check your provider account?")
1075
+ return
1076
+
1077
+ show_resp = message
1078
+
1079
+ # Coder will force pretty off if fence is not triple-backticks
1080
+ if pretty is None:
1081
+ pretty = self.pretty
1082
+
1083
+ if pretty:
1084
+ show_resp = Markdown(
1085
+ message, style=self.assistant_output_color, code_theme=self.code_theme
1086
+ )
1087
+ else:
1088
+ show_resp = Text(message or "(empty response)")
1089
+
1090
+ self.console.print(show_resp)
1091
+
1092
+ def set_placeholder(self, placeholder):
1093
+ """Set a one-time placeholder text for the next input prompt."""
1094
+ self.placeholder = placeholder
1095
+
1096
+ def print(self, message=""):
1097
+ print(message)
1098
+
1099
+ def llm_started(self):
1100
+ """Mark that the LLM has started processing, so we should ring the bell on next input"""
1101
+ self.bell_on_next_input = True
1102
+
1103
+ def get_default_notification_command(self):
1104
+ """Return a default notification command based on the operating system."""
1105
+ import platform
1106
+
1107
+ system = platform.system()
1108
+
1109
+ if system == "Darwin": # macOS
1110
+ # Check for terminal-notifier first
1111
+ if shutil.which("terminal-notifier"):
1112
+ return f"terminal-notifier -title 'Aider' -message '{NOTIFICATION_MESSAGE}'"
1113
+ # Fall back to osascript
1114
+ return (
1115
+ f'osascript -e \'display notification "{NOTIFICATION_MESSAGE}" with title "Aider"\''
1116
+ )
1117
+ elif system == "Linux":
1118
+ # Check for common Linux notification tools
1119
+ for cmd in ["notify-send", "zenity"]:
1120
+ if shutil.which(cmd):
1121
+ if cmd == "notify-send":
1122
+ return f"notify-send 'Aider' '{NOTIFICATION_MESSAGE}'"
1123
+ elif cmd == "zenity":
1124
+ return f"zenity --notification --text='{NOTIFICATION_MESSAGE}'"
1125
+ return None # No known notification tool found
1126
+ elif system == "Windows":
1127
+ # PowerShell notification
1128
+ return (
1129
+ "powershell -command"
1130
+ " \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');"
1131
+ f" [System.Windows.Forms.MessageBox]::Show('{NOTIFICATION_MESSAGE}',"
1132
+ " 'Aider')\""
1133
+ )
1134
+
1135
+ return None # Unknown system
1136
+
1137
+ def ring_bell(self):
1138
+ """Ring the terminal bell if needed and clear the flag"""
1139
+ if self.bell_on_next_input and self.notifications:
1140
+ if self.notifications_command:
1141
+ try:
1142
+ result = subprocess.run(
1143
+ self.notifications_command, shell=True, capture_output=True
1144
+ )
1145
+ if result.returncode != 0 and result.stderr:
1146
+ error_msg = result.stderr.decode("utf-8", errors="replace")
1147
+ self.tool_warning(f"Failed to run notifications command: {error_msg}")
1148
+ except Exception as e:
1149
+ self.tool_warning(f"Failed to run notifications command: {e}")
1150
+ else:
1151
+ print("\a", end="", flush=True) # Ring the bell
1152
+ self.bell_on_next_input = False # Clear the flag
1153
+
1154
+ def toggle_multiline_mode(self):
1155
+ """Toggle between normal and multiline input modes"""
1156
+ self.multiline_mode = not self.multiline_mode
1157
+ if self.multiline_mode:
1158
+ self.tool_output(
1159
+ "Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text"
1160
+ )
1161
+ else:
1162
+ self.tool_output(
1163
+ "Multiline mode: Disabled. Alt-Enter inserts newline, Enter submits text"
1164
+ )
1165
+
1166
+ def append_chat_history(self, text, linebreak=False, blockquote=False, strip=True):
1167
+ if blockquote:
1168
+ if strip:
1169
+ text = text.strip()
1170
+ text = "> " + text
1171
+ if linebreak:
1172
+ if strip:
1173
+ text = text.rstrip()
1174
+ text = text + " \n"
1175
+ if not text.endswith("\n"):
1176
+ text += "\n"
1177
+ if self.chat_history_file is not None:
1178
+ try:
1179
+ self.chat_history_file.parent.mkdir(parents=True, exist_ok=True)
1180
+ with self.chat_history_file.open(
1181
+ "a", encoding=self.encoding or "utf-8", errors="ignore"
1182
+ ) as f:
1183
+ f.write(text)
1184
+ except (PermissionError, OSError) as err:
1185
+ print(f"Warning: Unable to write to chat history file {self.chat_history_file}.")
1186
+ print(err)
1187
+ self.chat_history_file = None # Disable further attempts to write
1188
+
1189
+ def format_files_for_input(self, rel_fnames, rel_read_only_fnames):
1190
+ # Optimization for large number of files
1191
+ total_files = len(rel_fnames) + len(rel_read_only_fnames or [])
1192
+
1193
+ # For very large numbers of files, use a summary display
1194
+ if total_files > 50:
1195
+ read_only_count = len(rel_read_only_fnames or [])
1196
+ editable_count = len([f for f in rel_fnames if f not in (rel_read_only_fnames or [])])
1197
+
1198
+ summary = f"{editable_count} editable file(s)"
1199
+ if read_only_count > 0:
1200
+ summary += f", {read_only_count} read-only file(s)"
1201
+ summary += " (use /ls to list all files)\n"
1202
+ return summary
1203
+
1204
+ # Original implementation for reasonable number of files
1205
+ if not self.pretty:
1206
+ read_only_files = []
1207
+ for full_path in sorted(rel_read_only_fnames or []):
1208
+ read_only_files.append(f"{full_path} (read only)")
1209
+
1210
+ editable_files = []
1211
+ for full_path in sorted(rel_fnames):
1212
+ if full_path in rel_read_only_fnames:
1213
+ continue
1214
+ editable_files.append(f"{full_path}")
1215
+
1216
+ return "\n".join(read_only_files + editable_files) + "\n"
1217
+
1218
+ output = StringIO()
1219
+ console = Console(file=output, force_terminal=False)
1220
+
1221
+ read_only_files = sorted(rel_read_only_fnames or [])
1222
+ editable_files = [f for f in sorted(rel_fnames) if f not in rel_read_only_fnames]
1223
+
1224
+ if read_only_files:
1225
+ # Use shorter of abs/rel paths for readonly files
1226
+ ro_paths = []
1227
+ for rel_path in read_only_files:
1228
+ abs_path = os.path.abspath(os.path.join(self.root, rel_path))
1229
+ ro_paths.append(Text(abs_path if len(abs_path) < len(rel_path) else rel_path))
1230
+
1231
+ files_with_label = [Text("Readonly:")] + ro_paths
1232
+ read_only_output = StringIO()
1233
+ Console(file=read_only_output, force_terminal=False).print(Columns(files_with_label))
1234
+ read_only_lines = read_only_output.getvalue().splitlines()
1235
+ console.print(Columns(files_with_label))
1236
+
1237
+ if editable_files:
1238
+ text_editable_files = [Text(f) for f in editable_files]
1239
+ files_with_label = text_editable_files
1240
+ if read_only_files:
1241
+ files_with_label = [Text("Editable:")] + text_editable_files
1242
+ editable_output = StringIO()
1243
+ Console(file=editable_output, force_terminal=False).print(Columns(files_with_label))
1244
+ editable_lines = editable_output.getvalue().splitlines()
1245
+
1246
+ if len(read_only_lines) > 1 or len(editable_lines) > 1:
1247
+ console.print()
1248
+ console.print(Columns(files_with_label))
1249
+
1250
+ return output.getvalue()
1251
+
1252
+
1253
+ def get_rel_fname(fname, root):
1254
+ try:
1255
+ return os.path.relpath(fname, root)
1256
+ except ValueError:
1257
+ return fname