aider-ce 0.88.20__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 (279) 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 +1056 -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/agent_coder.py +2166 -0
  10. aider/coders/agent_prompts.py +104 -0
  11. aider/coders/architect_coder.py +48 -0
  12. aider/coders/architect_prompts.py +40 -0
  13. aider/coders/ask_coder.py +9 -0
  14. aider/coders/ask_prompts.py +35 -0
  15. aider/coders/base_coder.py +3613 -0
  16. aider/coders/base_prompts.py +87 -0
  17. aider/coders/chat_chunks.py +64 -0
  18. aider/coders/context_coder.py +53 -0
  19. aider/coders/context_prompts.py +75 -0
  20. aider/coders/editblock_coder.py +657 -0
  21. aider/coders/editblock_fenced_coder.py +10 -0
  22. aider/coders/editblock_fenced_prompts.py +143 -0
  23. aider/coders/editblock_func_coder.py +141 -0
  24. aider/coders/editblock_func_prompts.py +27 -0
  25. aider/coders/editblock_prompts.py +175 -0
  26. aider/coders/editor_diff_fenced_coder.py +9 -0
  27. aider/coders/editor_diff_fenced_prompts.py +11 -0
  28. aider/coders/editor_editblock_coder.py +9 -0
  29. aider/coders/editor_editblock_prompts.py +21 -0
  30. aider/coders/editor_whole_coder.py +9 -0
  31. aider/coders/editor_whole_prompts.py +12 -0
  32. aider/coders/help_coder.py +16 -0
  33. aider/coders/help_prompts.py +46 -0
  34. aider/coders/patch_coder.py +706 -0
  35. aider/coders/patch_prompts.py +159 -0
  36. aider/coders/search_replace.py +757 -0
  37. aider/coders/shell.py +37 -0
  38. aider/coders/single_wholefile_func_coder.py +102 -0
  39. aider/coders/single_wholefile_func_prompts.py +27 -0
  40. aider/coders/udiff_coder.py +429 -0
  41. aider/coders/udiff_prompts.py +115 -0
  42. aider/coders/udiff_simple.py +14 -0
  43. aider/coders/udiff_simple_prompts.py +25 -0
  44. aider/coders/wholefile_coder.py +144 -0
  45. aider/coders/wholefile_func_coder.py +134 -0
  46. aider/coders/wholefile_func_prompts.py +27 -0
  47. aider/coders/wholefile_prompts.py +65 -0
  48. aider/commands.py +2173 -0
  49. aider/copypaste.py +72 -0
  50. aider/deprecated.py +126 -0
  51. aider/diffs.py +128 -0
  52. aider/dump.py +29 -0
  53. aider/editor.py +147 -0
  54. aider/exceptions.py +115 -0
  55. aider/format_settings.py +26 -0
  56. aider/gui.py +545 -0
  57. aider/help.py +163 -0
  58. aider/help_pats.py +19 -0
  59. aider/helpers/__init__.py +9 -0
  60. aider/helpers/similarity.py +98 -0
  61. aider/history.py +180 -0
  62. aider/io.py +1608 -0
  63. aider/linter.py +304 -0
  64. aider/llm.py +55 -0
  65. aider/main.py +1415 -0
  66. aider/mcp/__init__.py +174 -0
  67. aider/mcp/server.py +149 -0
  68. aider/mdstream.py +243 -0
  69. aider/models.py +1313 -0
  70. aider/onboarding.py +429 -0
  71. aider/openrouter.py +129 -0
  72. aider/prompts.py +56 -0
  73. aider/queries/tree-sitter-language-pack/README.md +7 -0
  74. aider/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  75. aider/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  76. aider/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  77. aider/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  78. aider/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  79. aider/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  80. aider/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  81. aider/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  82. aider/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  83. aider/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  84. aider/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  85. aider/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  86. aider/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  87. aider/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  88. aider/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  89. aider/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  90. aider/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  91. aider/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  92. aider/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  93. aider/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  94. aider/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  95. aider/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  96. aider/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  97. aider/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  98. aider/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  99. aider/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  100. aider/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  101. aider/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  102. aider/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  103. aider/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  104. aider/queries/tree-sitter-languages/README.md +24 -0
  105. aider/queries/tree-sitter-languages/c-tags.scm +9 -0
  106. aider/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  107. aider/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  108. aider/queries/tree-sitter-languages/dart-tags.scm +91 -0
  109. aider/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  110. aider/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  111. aider/queries/tree-sitter-languages/elm-tags.scm +19 -0
  112. aider/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  113. aider/queries/tree-sitter-languages/go-tags.scm +30 -0
  114. aider/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  115. aider/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  116. aider/queries/tree-sitter-languages/java-tags.scm +20 -0
  117. aider/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  118. aider/queries/tree-sitter-languages/julia-tags.scm +60 -0
  119. aider/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  120. aider/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  121. aider/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  122. aider/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  123. aider/queries/tree-sitter-languages/php-tags.scm +26 -0
  124. aider/queries/tree-sitter-languages/python-tags.scm +12 -0
  125. aider/queries/tree-sitter-languages/ql-tags.scm +26 -0
  126. aider/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  127. aider/queries/tree-sitter-languages/rust-tags.scm +60 -0
  128. aider/queries/tree-sitter-languages/scala-tags.scm +65 -0
  129. aider/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  130. aider/queries/tree-sitter-languages/zig-tags.scm +3 -0
  131. aider/reasoning_tags.py +82 -0
  132. aider/repo.py +621 -0
  133. aider/repomap.py +1174 -0
  134. aider/report.py +260 -0
  135. aider/resources/__init__.py +3 -0
  136. aider/resources/model-metadata.json +776 -0
  137. aider/resources/model-settings.yml +2068 -0
  138. aider/run_cmd.py +133 -0
  139. aider/scrape.py +293 -0
  140. aider/sendchat.py +242 -0
  141. aider/sessions.py +256 -0
  142. aider/special.py +203 -0
  143. aider/tools/__init__.py +72 -0
  144. aider/tools/command.py +105 -0
  145. aider/tools/command_interactive.py +122 -0
  146. aider/tools/delete_block.py +182 -0
  147. aider/tools/delete_line.py +155 -0
  148. aider/tools/delete_lines.py +184 -0
  149. aider/tools/extract_lines.py +341 -0
  150. aider/tools/finished.py +48 -0
  151. aider/tools/git_branch.py +129 -0
  152. aider/tools/git_diff.py +60 -0
  153. aider/tools/git_log.py +57 -0
  154. aider/tools/git_remote.py +53 -0
  155. aider/tools/git_show.py +51 -0
  156. aider/tools/git_status.py +46 -0
  157. aider/tools/grep.py +256 -0
  158. aider/tools/indent_lines.py +221 -0
  159. aider/tools/insert_block.py +288 -0
  160. aider/tools/list_changes.py +86 -0
  161. aider/tools/ls.py +93 -0
  162. aider/tools/make_editable.py +85 -0
  163. aider/tools/make_readonly.py +69 -0
  164. aider/tools/remove.py +91 -0
  165. aider/tools/replace_all.py +126 -0
  166. aider/tools/replace_line.py +173 -0
  167. aider/tools/replace_lines.py +217 -0
  168. aider/tools/replace_text.py +187 -0
  169. aider/tools/show_numbered_context.py +147 -0
  170. aider/tools/tool_utils.py +313 -0
  171. aider/tools/undo_change.py +95 -0
  172. aider/tools/update_todo_list.py +156 -0
  173. aider/tools/view.py +57 -0
  174. aider/tools/view_files_matching.py +141 -0
  175. aider/tools/view_files_with_symbol.py +129 -0
  176. aider/urls.py +17 -0
  177. aider/utils.py +456 -0
  178. aider/versioncheck.py +113 -0
  179. aider/voice.py +205 -0
  180. aider/waiting.py +38 -0
  181. aider/watch.py +318 -0
  182. aider/watch_prompts.py +12 -0
  183. aider/website/Gemfile +8 -0
  184. aider/website/_includes/blame.md +162 -0
  185. aider/website/_includes/get-started.md +22 -0
  186. aider/website/_includes/help-tip.md +5 -0
  187. aider/website/_includes/help.md +24 -0
  188. aider/website/_includes/install.md +5 -0
  189. aider/website/_includes/keys.md +4 -0
  190. aider/website/_includes/model-warnings.md +67 -0
  191. aider/website/_includes/multi-line.md +22 -0
  192. aider/website/_includes/python-m-aider.md +5 -0
  193. aider/website/_includes/recording.css +228 -0
  194. aider/website/_includes/recording.md +34 -0
  195. aider/website/_includes/replit-pipx.md +9 -0
  196. aider/website/_includes/works-best.md +1 -0
  197. aider/website/_sass/custom/custom.scss +103 -0
  198. aider/website/docs/config/adv-model-settings.md +2261 -0
  199. aider/website/docs/config/agent-mode.md +194 -0
  200. aider/website/docs/config/aider_conf.md +548 -0
  201. aider/website/docs/config/api-keys.md +90 -0
  202. aider/website/docs/config/dotenv.md +493 -0
  203. aider/website/docs/config/editor.md +127 -0
  204. aider/website/docs/config/mcp.md +95 -0
  205. aider/website/docs/config/model-aliases.md +104 -0
  206. aider/website/docs/config/options.md +890 -0
  207. aider/website/docs/config/reasoning.md +210 -0
  208. aider/website/docs/config.md +44 -0
  209. aider/website/docs/faq.md +384 -0
  210. aider/website/docs/git.md +76 -0
  211. aider/website/docs/index.md +47 -0
  212. aider/website/docs/install/codespaces.md +39 -0
  213. aider/website/docs/install/docker.md +57 -0
  214. aider/website/docs/install/optional.md +100 -0
  215. aider/website/docs/install/replit.md +8 -0
  216. aider/website/docs/install.md +115 -0
  217. aider/website/docs/languages.md +264 -0
  218. aider/website/docs/legal/contributor-agreement.md +111 -0
  219. aider/website/docs/legal/privacy.md +104 -0
  220. aider/website/docs/llms/anthropic.md +77 -0
  221. aider/website/docs/llms/azure.md +48 -0
  222. aider/website/docs/llms/bedrock.md +132 -0
  223. aider/website/docs/llms/cohere.md +34 -0
  224. aider/website/docs/llms/deepseek.md +32 -0
  225. aider/website/docs/llms/gemini.md +49 -0
  226. aider/website/docs/llms/github.md +111 -0
  227. aider/website/docs/llms/groq.md +36 -0
  228. aider/website/docs/llms/lm-studio.md +39 -0
  229. aider/website/docs/llms/ollama.md +75 -0
  230. aider/website/docs/llms/openai-compat.md +39 -0
  231. aider/website/docs/llms/openai.md +58 -0
  232. aider/website/docs/llms/openrouter.md +78 -0
  233. aider/website/docs/llms/other.md +117 -0
  234. aider/website/docs/llms/vertex.md +50 -0
  235. aider/website/docs/llms/warnings.md +10 -0
  236. aider/website/docs/llms/xai.md +53 -0
  237. aider/website/docs/llms.md +54 -0
  238. aider/website/docs/more/analytics.md +127 -0
  239. aider/website/docs/more/edit-formats.md +116 -0
  240. aider/website/docs/more/infinite-output.md +165 -0
  241. aider/website/docs/more-info.md +8 -0
  242. aider/website/docs/recordings/auto-accept-architect.md +31 -0
  243. aider/website/docs/recordings/dont-drop-original-read-files.md +35 -0
  244. aider/website/docs/recordings/index.md +21 -0
  245. aider/website/docs/recordings/model-accepts-settings.md +69 -0
  246. aider/website/docs/recordings/tree-sitter-language-pack.md +80 -0
  247. aider/website/docs/repomap.md +112 -0
  248. aider/website/docs/scripting.md +100 -0
  249. aider/website/docs/sessions.md +203 -0
  250. aider/website/docs/troubleshooting/aider-not-found.md +24 -0
  251. aider/website/docs/troubleshooting/edit-errors.md +76 -0
  252. aider/website/docs/troubleshooting/imports.md +62 -0
  253. aider/website/docs/troubleshooting/models-and-keys.md +54 -0
  254. aider/website/docs/troubleshooting/support.md +79 -0
  255. aider/website/docs/troubleshooting/token-limits.md +96 -0
  256. aider/website/docs/troubleshooting/warnings.md +12 -0
  257. aider/website/docs/troubleshooting.md +11 -0
  258. aider/website/docs/usage/browser.md +57 -0
  259. aider/website/docs/usage/caching.md +49 -0
  260. aider/website/docs/usage/commands.md +133 -0
  261. aider/website/docs/usage/conventions.md +119 -0
  262. aider/website/docs/usage/copypaste.md +121 -0
  263. aider/website/docs/usage/images-urls.md +48 -0
  264. aider/website/docs/usage/lint-test.md +118 -0
  265. aider/website/docs/usage/modes.md +211 -0
  266. aider/website/docs/usage/not-code.md +179 -0
  267. aider/website/docs/usage/notifications.md +87 -0
  268. aider/website/docs/usage/tips.md +79 -0
  269. aider/website/docs/usage/tutorials.md +30 -0
  270. aider/website/docs/usage/voice.md +121 -0
  271. aider/website/docs/usage/watch.md +294 -0
  272. aider/website/docs/usage.md +102 -0
  273. aider/website/share/index.md +101 -0
  274. aider_ce-0.88.20.dist-info/METADATA +187 -0
  275. aider_ce-0.88.20.dist-info/RECORD +279 -0
  276. aider_ce-0.88.20.dist-info/WHEEL +5 -0
  277. aider_ce-0.88.20.dist-info/entry_points.txt +2 -0
  278. aider_ce-0.88.20.dist-info/licenses/LICENSE.txt +202 -0
  279. aider_ce-0.88.20.dist-info/top_level.txt +1 -0
aider/io.py ADDED
@@ -0,0 +1,1608 @@
1
+ import asyncio
2
+ import base64
3
+ import functools
4
+ import os
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ import webbrowser
11
+ from collections import defaultdict
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from io import StringIO
15
+ from pathlib import Path
16
+
17
+ from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter
18
+ from prompt_toolkit.cursor_shapes import ModalCursorShapeConfig
19
+ from prompt_toolkit.enums import EditingMode
20
+ from prompt_toolkit.filters import Condition, is_searching
21
+ from prompt_toolkit.history import FileHistory
22
+ from prompt_toolkit.key_binding import KeyBindings
23
+ from prompt_toolkit.key_binding.vi_state import InputMode
24
+ from prompt_toolkit.keys import Keys
25
+ from prompt_toolkit.lexers import PygmentsLexer
26
+ from prompt_toolkit.output.vt100 import is_dumb_terminal
27
+ from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
28
+ from prompt_toolkit.styles import Style
29
+ from pygments.lexers import MarkdownLexer, guess_lexer_for_filename
30
+ from pygments.token import Token
31
+ from rich.color import ColorParseError
32
+ from rich.columns import Columns
33
+ from rich.console import Console
34
+ from rich.markdown import Markdown
35
+ from rich.spinner import SPINNERS
36
+ from rich.style import Style as RichStyle
37
+ from rich.text import Text
38
+
39
+ from .dump import dump # noqa: F401
40
+ from .editor import pipe_editor
41
+ from .utils import is_image_file, run_fzf
42
+ from .waiting import Spinner
43
+
44
+ # Constants
45
+ NOTIFICATION_MESSAGE = "Aider is waiting for your input"
46
+
47
+
48
+ def ensure_hash_prefix(color):
49
+ """Ensure hex color values have a # prefix."""
50
+ if not color:
51
+ return color
52
+ if isinstance(color, str) and color.strip() and not color.startswith("#"):
53
+ # Check if it's a valid hex color (3 or 6 hex digits)
54
+ if all(c in "0123456789ABCDEFabcdef" for c in color) and len(color) in (3, 6):
55
+ return f"#{color}"
56
+ return color
57
+
58
+
59
+ def restore_multiline(func):
60
+ """Decorator to restore multiline mode after function execution"""
61
+
62
+ @functools.wraps(func)
63
+ def wrapper(self, *args, **kwargs):
64
+ orig_multiline = self.multiline_mode
65
+ self.multiline_mode = False
66
+ try:
67
+ return func(self, *args, **kwargs)
68
+ except Exception:
69
+ raise
70
+ finally:
71
+ self.multiline_mode = orig_multiline
72
+
73
+ return wrapper
74
+
75
+
76
+ def restore_multiline_async(func):
77
+ """Decorator to restore multiline mode after async function execution"""
78
+
79
+ @functools.wraps(func)
80
+ async def wrapper(self, *args, **kwargs):
81
+ orig_multiline = self.multiline_mode
82
+ self.multiline_mode = False
83
+ try:
84
+ return await func(self, *args, **kwargs)
85
+ except Exception:
86
+ raise
87
+ finally:
88
+ self.multiline_mode = orig_multiline
89
+
90
+ return wrapper
91
+
92
+
93
+ def without_input_history(func):
94
+ """Decorator to temporarily disable history saving for the prompt session buffer."""
95
+
96
+ @functools.wraps(func)
97
+ async def wrapper(self, *args, **kwargs):
98
+ orig_buf_append = None
99
+ try:
100
+ orig_buf_append = self.prompt_session.default_buffer.append_to_history
101
+ self.prompt_session.default_buffer.append_to_history = (
102
+ lambda: None
103
+ ) # Replace with no-op
104
+ except AttributeError:
105
+ pass
106
+
107
+ try:
108
+ return await func(self, *args, **kwargs)
109
+ except Exception:
110
+ raise
111
+ finally:
112
+ if orig_buf_append:
113
+ self.prompt_session.default_buffer.append_to_history = orig_buf_append
114
+
115
+ return wrapper
116
+
117
+
118
+ class CommandCompletionException(Exception):
119
+ """Raised when a command should use the normal autocompleter instead of
120
+ command-specific completion."""
121
+
122
+ pass
123
+
124
+
125
+ @dataclass
126
+ class ConfirmGroup:
127
+ preference: str = None
128
+ show_group: bool = True
129
+
130
+ def __init__(self, items=None):
131
+ if items is not None:
132
+ self.show_group = len(items) > 1
133
+
134
+
135
+ class AutoCompleter(Completer):
136
+ def __init__(
137
+ self, root, rel_fnames, addable_rel_fnames, commands, encoding, abs_read_only_fnames=None
138
+ ):
139
+ self.addable_rel_fnames = addable_rel_fnames
140
+ self.rel_fnames = rel_fnames
141
+ self.encoding = encoding
142
+ self.abs_read_only_fnames = abs_read_only_fnames or []
143
+
144
+ fname_to_rel_fnames = defaultdict(list)
145
+ for rel_fname in addable_rel_fnames:
146
+ fname = os.path.basename(rel_fname)
147
+ if fname != rel_fname:
148
+ fname_to_rel_fnames[fname].append(rel_fname)
149
+ self.fname_to_rel_fnames = fname_to_rel_fnames
150
+
151
+ self.words = set()
152
+
153
+ self.commands = commands
154
+ self.command_completions = dict()
155
+ if commands:
156
+ self.command_names = self.commands.get_commands()
157
+ else:
158
+ self.command_names = []
159
+
160
+ for rel_fname in addable_rel_fnames:
161
+ self.words.add(rel_fname)
162
+
163
+ for rel_fname in rel_fnames:
164
+ self.words.add(rel_fname)
165
+
166
+ all_fnames = [Path(root) / rel_fname for rel_fname in rel_fnames]
167
+ if abs_read_only_fnames:
168
+ all_fnames.extend(abs_read_only_fnames)
169
+
170
+ self.all_fnames = all_fnames
171
+ self.tokenized = False
172
+
173
+ def tokenize(self):
174
+ if self.tokenized:
175
+ return
176
+ self.tokenized = True
177
+
178
+ # Performance optimization for large file sets
179
+ if len(self.all_fnames) > 100:
180
+ # Skip tokenization for very large numbers of files to avoid input lag
181
+ self.tokenized = True
182
+ return
183
+
184
+ # Limit number of files to process to avoid excessive tokenization time
185
+ process_fnames = self.all_fnames
186
+ if len(process_fnames) > 50:
187
+ # Only process a subset of files to maintain responsiveness
188
+ process_fnames = process_fnames[:50]
189
+
190
+ for fname in process_fnames:
191
+ try:
192
+ with open(fname, "r", encoding=self.encoding) as f:
193
+ content = f.read()
194
+ except (FileNotFoundError, UnicodeDecodeError, IsADirectoryError):
195
+ continue
196
+ try:
197
+ lexer = guess_lexer_for_filename(fname, content)
198
+ except Exception: # On Windows, bad ref to time.clock which is deprecated
199
+ continue
200
+
201
+ tokens = list(lexer.get_tokens(content))
202
+ self.words.update(
203
+ (token[1], f"`{token[1]}`") for token in tokens if token[0] in Token.Name
204
+ )
205
+
206
+ def get_command_completions(self, document, complete_event, text, words):
207
+ if len(words) == 1 and not text[-1].isspace():
208
+ partial = words[0].lower()
209
+ candidates = [cmd for cmd in self.command_names if cmd.startswith(partial)]
210
+ for candidate in sorted(candidates):
211
+ yield Completion(candidate, start_position=-len(words[-1]))
212
+ return
213
+
214
+ if len(words) <= 1 or text[-1].isspace():
215
+ return
216
+
217
+ cmd = words[0]
218
+ partial = words[-1].lower()
219
+
220
+ matches, _, _ = self.commands.matching_commands(cmd)
221
+ if len(matches) == 1:
222
+ cmd = matches[0]
223
+ elif cmd not in matches:
224
+ return
225
+
226
+ raw_completer = self.commands.get_raw_completions(cmd)
227
+ if raw_completer:
228
+ yield from raw_completer(document, complete_event)
229
+ return
230
+
231
+ if cmd not in self.command_completions:
232
+ candidates = self.commands.get_completions(cmd)
233
+ self.command_completions[cmd] = candidates
234
+ else:
235
+ candidates = self.command_completions[cmd]
236
+
237
+ if candidates is None:
238
+ return
239
+
240
+ candidates = [word for word in candidates if partial in word.lower()]
241
+ for candidate in sorted(candidates):
242
+ yield Completion(candidate, start_position=-len(words[-1]))
243
+
244
+ def get_completions(self, document, complete_event):
245
+ self.tokenize()
246
+
247
+ text = document.text_before_cursor
248
+ words = text.split()
249
+ if not words:
250
+ return
251
+
252
+ if text and text[-1].isspace():
253
+ # don't keep completing after a space
254
+ return
255
+
256
+ if text[0] == "/":
257
+ try:
258
+ yield from self.get_command_completions(document, complete_event, text, words)
259
+ return
260
+ except CommandCompletionException:
261
+ # Fall through to normal completion
262
+ pass
263
+
264
+ candidates = self.words
265
+ candidates.update(set(self.fname_to_rel_fnames))
266
+ candidates = [word if type(word) is tuple else (word, word) for word in candidates]
267
+
268
+ last_word = words[-1]
269
+
270
+ # Only provide completions if the user has typed at least 3 characters
271
+ if len(last_word) < 3:
272
+ return
273
+
274
+ completions = []
275
+ for word_match, word_insert in candidates:
276
+ if word_match.lower().startswith(last_word.lower()):
277
+ completions.append((word_insert, -len(last_word), word_match))
278
+
279
+ rel_fnames = self.fname_to_rel_fnames.get(word_match, [])
280
+ if rel_fnames:
281
+ for rel_fname in rel_fnames:
282
+ completions.append((rel_fname, -len(last_word), rel_fname))
283
+
284
+ for ins, pos, match in sorted(completions):
285
+ yield Completion(ins, start_position=pos, display=match)
286
+
287
+
288
+ class InputOutput:
289
+ num_error_outputs = 0
290
+ num_user_asks = 0
291
+ clipboard_watcher = None
292
+ bell_on_next_input = False
293
+ notifications_command = None
294
+ encoding = "utf-8"
295
+
296
+ def __init__(
297
+ self,
298
+ pretty=True,
299
+ yes=None,
300
+ input_history_file=None,
301
+ chat_history_file=None,
302
+ input=None,
303
+ output=None,
304
+ user_input_color="blue",
305
+ tool_output_color=None,
306
+ tool_error_color="red",
307
+ tool_warning_color="#FFA500",
308
+ assistant_output_color="blue",
309
+ completion_menu_color=None,
310
+ completion_menu_bg_color=None,
311
+ completion_menu_current_color=None,
312
+ completion_menu_current_bg_color=None,
313
+ code_theme="default",
314
+ encoding="utf-8",
315
+ line_endings="platform",
316
+ dry_run=False,
317
+ llm_history_file=None,
318
+ editingmode=EditingMode.EMACS,
319
+ fancy_input=True,
320
+ file_watcher=None,
321
+ multiline_mode=False,
322
+ root=".",
323
+ notifications=False,
324
+ notifications_command=None,
325
+ verbose=False,
326
+ ):
327
+ self.console = Console()
328
+ self.pretty = pretty
329
+ if chat_history_file is not None:
330
+ self.chat_history_file = Path(chat_history_file)
331
+ else:
332
+ self.chat_history_file = None
333
+
334
+ self.placeholder = None
335
+ self.fallback_spinner = None
336
+ self.prompt_session = None
337
+ self.interrupted = False
338
+ self.never_prompts = set()
339
+ self.editingmode = editingmode
340
+ self.multiline_mode = multiline_mode
341
+ self.bell_on_next_input = False
342
+ self.notifications = notifications
343
+ self.verbose = verbose
344
+
345
+ # Variables used to interface with base_coder
346
+ self.coder = None
347
+ self.input_task = None
348
+ self.output_task = None
349
+
350
+ # State tracking for confirmation input
351
+ self.confirmation_in_progress = False
352
+ self.confirmation_acknowledgement = False
353
+ self.confirmation_input_active = False
354
+ self.saved_input_text = ""
355
+
356
+ if notifications and notifications_command is None:
357
+ self.notifications_command = self.get_default_notification_command()
358
+ else:
359
+ self.notifications_command = notifications_command
360
+
361
+ no_color = os.environ.get("NO_COLOR")
362
+ if no_color is not None and no_color != "":
363
+ pretty = False
364
+
365
+ self.user_input_color = ensure_hash_prefix(user_input_color) if pretty else None
366
+ self.tool_output_color = ensure_hash_prefix(tool_output_color) if pretty else None
367
+ self.tool_error_color = ensure_hash_prefix(tool_error_color) if pretty else None
368
+ self.tool_warning_color = ensure_hash_prefix(tool_warning_color) if pretty else None
369
+ self.assistant_output_color = ensure_hash_prefix(assistant_output_color)
370
+ self.completion_menu_color = ensure_hash_prefix(completion_menu_color) if pretty else None
371
+ self.completion_menu_bg_color = (
372
+ ensure_hash_prefix(completion_menu_bg_color) if pretty else None
373
+ )
374
+ self.completion_menu_current_color = (
375
+ ensure_hash_prefix(completion_menu_current_color) if pretty else None
376
+ )
377
+ self.completion_menu_current_bg_color = (
378
+ ensure_hash_prefix(completion_menu_current_bg_color) if pretty else None
379
+ )
380
+
381
+ self.fzf_available = shutil.which("fzf")
382
+ if not self.fzf_available and self.verbose:
383
+ self.tool_warning(
384
+ "fzf not found, fuzzy finder features will be disabled. Install it for enhanced"
385
+ " file/history search."
386
+ )
387
+
388
+ self.code_theme = code_theme
389
+
390
+ self._stream_buffer = ""
391
+ self._stream_line_count = 0
392
+
393
+ self.input = input
394
+ self.output = output
395
+
396
+ self.pretty = pretty
397
+ if self.output:
398
+ self.pretty = False
399
+
400
+ self.yes = yes
401
+ self.group_responses = dict()
402
+
403
+ self.input_history_file = input_history_file
404
+ if self.input_history_file:
405
+ try:
406
+ Path(self.input_history_file).parent.mkdir(parents=True, exist_ok=True)
407
+ except (PermissionError, OSError) as e:
408
+ self.tool_warning(f"Could not create directory for input history: {e}")
409
+ self.input_history_file = None
410
+ self.llm_history_file = llm_history_file
411
+ if chat_history_file is not None:
412
+ self.chat_history_file = Path(chat_history_file)
413
+ else:
414
+ self.chat_history_file = None
415
+
416
+ self.encoding = encoding
417
+ valid_line_endings = {"platform", "lf", "crlf"}
418
+ if line_endings not in valid_line_endings:
419
+ raise ValueError(
420
+ f"Invalid line_endings value: {line_endings}. "
421
+ f"Must be one of: {', '.join(valid_line_endings)}"
422
+ )
423
+ self.newline = (
424
+ None if line_endings == "platform" else "\n" if line_endings == "lf" else "\r\n"
425
+ )
426
+ self.dry_run = dry_run
427
+
428
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
429
+
430
+ self.is_dumb_terminal = is_dumb_terminal()
431
+ self.is_tty = sys.stdout.isatty()
432
+
433
+ if self.is_dumb_terminal:
434
+ self.pretty = False
435
+ fancy_input = False
436
+
437
+ # Spinner state
438
+ self.spinner_running = False
439
+ self.spinner_text = ""
440
+ self.last_spinner_text = ""
441
+ self.spinner_frame_index = 0
442
+ self.spinner_last_frame_index = 0
443
+ self.unicode_palette = "░█"
444
+
445
+ if fancy_input:
446
+ # If unicode is supported, use the rich 'dots2' spinner, otherwise an ascii fallback
447
+ if self._spinner_supports_unicode():
448
+ self.spinner_frames = SPINNERS["dots2"]["frames"]
449
+ else:
450
+ # A simple ascii spinner
451
+ self.spinner_frames = SPINNERS["line"]["frames"]
452
+
453
+ # Initialize PromptSession only if we have a capable terminal
454
+ session_kwargs = {
455
+ "input": self.input,
456
+ "output": self.output,
457
+ "lexer": PygmentsLexer(MarkdownLexer),
458
+ "editing_mode": self.editingmode,
459
+ "bottom_toolbar": self.get_bottom_toolbar,
460
+ "refresh_interval": 0.1,
461
+ }
462
+ if self.editingmode == EditingMode.VI:
463
+ session_kwargs["cursor"] = ModalCursorShapeConfig()
464
+ if self.input_history_file is not None:
465
+ session_kwargs["history"] = FileHistory(self.input_history_file)
466
+ try:
467
+ self.prompt_session = PromptSession(**session_kwargs)
468
+ self.console = Console() # pretty console
469
+ except Exception as err:
470
+ self.console = Console(force_terminal=False, no_color=True)
471
+ self.tool_error(f"Can't initialize prompt toolkit: {err}") # non-pretty
472
+ else:
473
+ self.console = Console(force_terminal=False, no_color=True) # non-pretty
474
+ if self.is_dumb_terminal:
475
+ self.tool_output("Detected dumb terminal, disabling fancy input and pretty output.")
476
+
477
+ self.file_watcher = file_watcher
478
+ self.root = root
479
+
480
+ # Validate color settings after console is initialized
481
+ self._validate_color_settings()
482
+ self.append_chat_history(f"\n# aider chat started at {current_time}\n\n")
483
+
484
+ def _spinner_supports_unicode(self) -> bool:
485
+ if not self.is_tty:
486
+ return False
487
+ try:
488
+ out = self.unicode_palette
489
+ out += "\b" * len(self.unicode_palette)
490
+ out += " " * len(self.unicode_palette)
491
+ out += "\b" * len(self.unicode_palette)
492
+ sys.stdout.write(out)
493
+ sys.stdout.flush()
494
+ return True
495
+ except UnicodeEncodeError:
496
+ return False
497
+ except Exception:
498
+ return False
499
+
500
+ def start_spinner(self, text, update_last_text=True):
501
+ """Start the spinner."""
502
+ self.stop_spinner()
503
+
504
+ if self.prompt_session:
505
+ self.spinner_running = True
506
+ self.spinner_text = text
507
+ self.spinner_frame_index = self.spinner_last_frame_index
508
+
509
+ if update_last_text:
510
+ self.last_spinner_text = text
511
+ else:
512
+ self.fallback_spinner = Spinner(text)
513
+ self.fallback_spinner.step()
514
+
515
+ def update_spinner(self, text):
516
+ self.spinner_text = text
517
+
518
+ def stop_spinner(self):
519
+ """Stop the spinner."""
520
+ self.spinner_running = False
521
+ self.spinner_text = ""
522
+ # Keep last frame index to avoid spinner "jumping" on restart
523
+ self.spinner_last_frame_index = self.spinner_frame_index
524
+ if self.fallback_spinner:
525
+ self.fallback_spinner.end()
526
+ self.fallback_spinner = None
527
+
528
+ def get_bottom_toolbar(self):
529
+ """Get the current spinner frame and text for the bottom toolbar."""
530
+ if not self.spinner_running or not self.spinner_frames:
531
+ return None
532
+
533
+ frame = self.spinner_frames[self.spinner_frame_index]
534
+ self.spinner_frame_index = (self.spinner_frame_index + 1) % len(self.spinner_frames)
535
+
536
+ return f"{frame} {self.spinner_text}"
537
+
538
+ def _validate_color_settings(self):
539
+ """Validate configured color strings and reset invalid ones."""
540
+ color_attributes = [
541
+ "user_input_color",
542
+ "tool_output_color",
543
+ "tool_error_color",
544
+ "tool_warning_color",
545
+ "assistant_output_color",
546
+ "completion_menu_color",
547
+ "completion_menu_bg_color",
548
+ "completion_menu_current_color",
549
+ "completion_menu_current_bg_color",
550
+ ]
551
+ for attr_name in color_attributes:
552
+ color_value = getattr(self, attr_name, None)
553
+ if color_value:
554
+ try:
555
+ # Try creating a style to validate the color
556
+ RichStyle(color=color_value)
557
+ except ColorParseError as e:
558
+ self.console.print(
559
+ "[bold red]Warning:[/bold red] Invalid configuration for"
560
+ f" {attr_name}: '{color_value}'. {e}. Disabling this color."
561
+ )
562
+ setattr(self, attr_name, None) # Reset invalid color to None
563
+
564
+ def _get_style(self):
565
+ style_dict = {}
566
+ if not self.pretty:
567
+ return Style.from_dict(style_dict)
568
+
569
+ if self.user_input_color:
570
+ style_dict.setdefault("", self.user_input_color)
571
+ style_dict.update(
572
+ {
573
+ "pygments.literal.string": f"bold italic {self.user_input_color}",
574
+ }
575
+ )
576
+ style_dict["bottom-toolbar"] = f"{self.user_input_color} noreverse"
577
+
578
+ # Conditionally add 'completion-menu' style
579
+ completion_menu_style = []
580
+ if self.completion_menu_bg_color:
581
+ completion_menu_style.append(f"bg:{self.completion_menu_bg_color}")
582
+ if self.completion_menu_color:
583
+ completion_menu_style.append(self.completion_menu_color)
584
+ if completion_menu_style:
585
+ style_dict["completion-menu"] = " ".join(completion_menu_style)
586
+
587
+ # Conditionally add 'completion-menu.completion.current' style
588
+ completion_menu_current_style = []
589
+ if self.completion_menu_current_bg_color:
590
+ completion_menu_current_style.append(self.completion_menu_current_bg_color)
591
+ if self.completion_menu_current_color:
592
+ completion_menu_current_style.append(f"bg:{self.completion_menu_current_color}")
593
+ if completion_menu_current_style:
594
+ style_dict["completion-menu.completion.current"] = " ".join(
595
+ completion_menu_current_style
596
+ )
597
+
598
+ return Style.from_dict(style_dict)
599
+
600
+ def read_image(self, filename):
601
+ try:
602
+ with open(str(filename), "rb") as image_file:
603
+ encoded_string = base64.b64encode(image_file.read())
604
+ return encoded_string.decode("utf-8")
605
+ except OSError as err:
606
+ self.tool_error(f"{filename}: unable to read: {err}")
607
+ return
608
+ except FileNotFoundError:
609
+ self.tool_error(f"{filename}: file not found error")
610
+ return
611
+ except IsADirectoryError:
612
+ self.tool_error(f"{filename}: is a directory")
613
+ return
614
+ except Exception as e:
615
+ self.tool_error(f"{filename}: {e}")
616
+ return
617
+
618
+ def read_text(self, filename, silent=False):
619
+ if is_image_file(filename):
620
+ return self.read_image(filename)
621
+
622
+ try:
623
+ with open(str(filename), "r", encoding=self.encoding) as f:
624
+ return f.read()
625
+ except FileNotFoundError:
626
+ if not silent:
627
+ self.tool_error(f"{filename}: file not found error")
628
+ return
629
+ except IsADirectoryError:
630
+ if not silent:
631
+ self.tool_error(f"{filename}: is a directory")
632
+ return
633
+ except OSError as err:
634
+ if not silent:
635
+ self.tool_error(f"{filename}: unable to read: {err}")
636
+ return
637
+ except UnicodeError as e:
638
+ if not silent:
639
+ self.tool_error(f"{filename}: {e}")
640
+ self.tool_error("Use --encoding to set the unicode encoding.")
641
+ return
642
+
643
+ def write_text(self, filename, content, max_retries=5, initial_delay=0.1):
644
+ """
645
+ Writes content to a file, retrying with progressive backoff if the file is locked.
646
+
647
+ :param filename: Path to the file to write.
648
+ :param content: Content to write to the file.
649
+ :param max_retries: Maximum number of retries if a file lock is encountered.
650
+ :param initial_delay: Initial delay (in seconds) before the first retry.
651
+ """
652
+ if self.dry_run:
653
+ return
654
+
655
+ delay = initial_delay
656
+ for attempt in range(max_retries):
657
+ try:
658
+ with open(str(filename), "w", encoding=self.encoding, newline=self.newline) as f:
659
+ f.write(content)
660
+ return # Successfully wrote the file
661
+ except PermissionError as err:
662
+ if attempt < max_retries - 1:
663
+ time.sleep(delay)
664
+ delay *= 2 # Exponential backoff
665
+ else:
666
+ self.tool_error(
667
+ f"Unable to write file {filename} after {max_retries} attempts: {err}"
668
+ )
669
+ raise
670
+ except OSError as err:
671
+ self.tool_error(f"Unable to write file {filename}: {err}")
672
+ raise
673
+
674
+ def rule(self):
675
+ if self.pretty:
676
+ style = dict(style=self.user_input_color) if self.user_input_color else dict()
677
+ self.console.rule(**style)
678
+ else:
679
+ print()
680
+
681
+ def interrupt_input(self):
682
+ if self.prompt_session and self.prompt_session.app:
683
+ # Store any partial input before interrupting
684
+ self.placeholder = self.prompt_session.app.current_buffer.text
685
+ self.interrupted = True
686
+
687
+ try:
688
+ self.prompt_session.app.exit()
689
+ finally:
690
+ pass
691
+
692
+ def reject_outstanding_confirmations(self):
693
+ """Reject all outstanding confirmation dialogs."""
694
+ # This method is now a no-op since we removed the confirmation_future logic
695
+ pass
696
+
697
+ async def recreate_input(self, future=None):
698
+ if not self.input_task or self.input_task.done() or self.input_task.cancelled():
699
+ coder = self.coder() if self.coder else None
700
+
701
+ if coder:
702
+ self.input_task = asyncio.create_task(coder.get_input())
703
+ await asyncio.sleep(0)
704
+ else:
705
+ self.input_task = asyncio.create_task(self.get_input(None, [], [], []))
706
+
707
+ async def get_input(
708
+ self,
709
+ root,
710
+ rel_fnames,
711
+ addable_rel_fnames,
712
+ commands,
713
+ abs_read_only_fnames=None,
714
+ abs_read_only_stubs_fnames=None,
715
+ edit_format=None,
716
+ ):
717
+ self.rule()
718
+
719
+ rel_fnames = list(rel_fnames)
720
+ show = ""
721
+ if rel_fnames:
722
+ rel_read_only_fnames = [
723
+ get_rel_fname(fname, root) for fname in (abs_read_only_fnames or [])
724
+ ]
725
+ rel_read_only_stubs_fnames = [
726
+ get_rel_fname(fname, root) for fname in (abs_read_only_stubs_fnames or [])
727
+ ]
728
+ show = self.format_files_for_input(
729
+ rel_fnames, rel_read_only_fnames, rel_read_only_stubs_fnames
730
+ )
731
+
732
+ prompt_prefix = ""
733
+
734
+ if edit_format:
735
+ prompt_prefix += edit_format
736
+ if self.multiline_mode:
737
+ prompt_prefix += (" " if edit_format else "") + "multi"
738
+ prompt_prefix += "> "
739
+
740
+ show += prompt_prefix
741
+ self.prompt_prefix = prompt_prefix
742
+
743
+ inp = ""
744
+ multiline_input = False
745
+
746
+ style = self._get_style()
747
+
748
+ completer_instance = ThreadedCompleter(
749
+ AutoCompleter(
750
+ root,
751
+ rel_fnames,
752
+ addable_rel_fnames,
753
+ commands,
754
+ self.encoding,
755
+ abs_read_only_fnames=(abs_read_only_fnames or set())
756
+ | (abs_read_only_stubs_fnames or set()),
757
+ )
758
+ )
759
+
760
+ def suspend_to_bg(event):
761
+ """Suspend currently running application."""
762
+ event.app.suspend_to_background()
763
+
764
+ kb = KeyBindings()
765
+
766
+ @kb.add(Keys.ControlZ, filter=Condition(lambda: hasattr(signal, "SIGTSTP")))
767
+ def _(event):
768
+ "Suspend to background with ctrl-z"
769
+ suspend_to_bg(event)
770
+
771
+ @kb.add("c-space")
772
+ def _(event):
773
+ "Ignore Ctrl when pressing space bar"
774
+ event.current_buffer.insert_text(" ")
775
+
776
+ @kb.add("c-up")
777
+ def _(event):
778
+ "Navigate backward through history"
779
+ event.current_buffer.history_backward()
780
+
781
+ @kb.add("c-down")
782
+ def _(event):
783
+ "Navigate forward through history"
784
+ event.current_buffer.history_forward()
785
+
786
+ @kb.add("c-x", "c-e")
787
+ def _(event):
788
+ "Edit current input in external editor (like Bash)"
789
+ buffer = event.current_buffer
790
+ current_text = buffer.text
791
+
792
+ # Open the editor with the current text
793
+ edited_text = pipe_editor(input_data=current_text, suffix="md")
794
+
795
+ # Replace the buffer with the edited text, strip any trailing newlines
796
+ buffer.text = edited_text.rstrip("\n")
797
+
798
+ # Move cursor to the end of the text
799
+ buffer.cursor_position = len(buffer.text)
800
+
801
+ @kb.add("c-t", filter=Condition(lambda: self.fzf_available))
802
+ def _(event):
803
+ "Fuzzy find files to add to the chat"
804
+ buffer = event.current_buffer
805
+ if not buffer.text.strip().startswith("/add "):
806
+ return
807
+
808
+ files = run_fzf(addable_rel_fnames, multi=True)
809
+ if files:
810
+ buffer.text = "/add " + " ".join(files)
811
+ buffer.cursor_position = len(buffer.text)
812
+
813
+ @kb.add("c-r", filter=Condition(lambda: self.fzf_available))
814
+ def _(event):
815
+ "Fuzzy search in history and paste it in the prompt"
816
+ buffer = event.current_buffer
817
+ history_lines = self.get_input_history()
818
+ selected_lines = run_fzf(history_lines)
819
+ if selected_lines:
820
+ buffer.text = "".join(selected_lines)
821
+ buffer.cursor_position = len(buffer.text)
822
+
823
+ @kb.add("enter", eager=True, filter=~is_searching)
824
+ def _(event):
825
+ "Handle Enter key press"
826
+ if self.multiline_mode and not (
827
+ self.editingmode == EditingMode.VI
828
+ and event.app.vi_state.input_mode == InputMode.NAVIGATION
829
+ ):
830
+ # In multiline mode and if not in vi-mode or vi navigation/normal mode,
831
+ # Enter adds a newline
832
+ event.current_buffer.insert_text("\n")
833
+ else:
834
+ # In normal mode, Enter submits
835
+ event.current_buffer.validate_and_handle()
836
+
837
+ @kb.add("escape", "enter", eager=True, filter=~is_searching) # This is Alt+Enter
838
+ def _(event):
839
+ "Handle Alt+Enter key press"
840
+ if self.multiline_mode:
841
+ # In multiline mode, Alt+Enter submits
842
+ event.current_buffer.validate_and_handle()
843
+ else:
844
+ # In normal mode, Alt+Enter adds a newline
845
+ event.current_buffer.insert_text("\n")
846
+
847
+ while True:
848
+ if multiline_input:
849
+ show = self.prompt_prefix
850
+
851
+ try:
852
+ if self.prompt_session:
853
+ # Use placeholder if set, then clear it
854
+ default = self.placeholder or ""
855
+ self.placeholder = None
856
+
857
+ self.interrupted = False
858
+ if not multiline_input:
859
+ if self.file_watcher:
860
+ self.file_watcher.start()
861
+ if self.clipboard_watcher:
862
+ self.clipboard_watcher.start()
863
+
864
+ def get_continuation(width, line_number, is_soft_wrap):
865
+ return self.prompt_prefix
866
+
867
+ line = await self.prompt_session.prompt_async(
868
+ show,
869
+ default=default,
870
+ completer=completer_instance,
871
+ reserve_space_for_menu=4,
872
+ complete_style=CompleteStyle.MULTI_COLUMN,
873
+ style=style,
874
+ key_bindings=kb,
875
+ complete_while_typing=True,
876
+ prompt_continuation=get_continuation,
877
+ )
878
+ else:
879
+ line = await asyncio.get_event_loop().run_in_executor(None, input, show)
880
+
881
+ # Check if we were interrupted by a file change
882
+ if self.interrupted:
883
+ line = line or ""
884
+ if self.file_watcher:
885
+ cmd = self.file_watcher.process_changes()
886
+ return cmd
887
+
888
+ except EOFError:
889
+ raise
890
+ except KeyboardInterrupt:
891
+ await self.cancel_output_task()
892
+ self.console.print()
893
+ return ""
894
+ except UnicodeEncodeError as err:
895
+ self.tool_error(str(err))
896
+ return ""
897
+ except Exception as err:
898
+ try:
899
+ self.prompt_session.app.exit()
900
+ except Exception:
901
+ pass
902
+
903
+ import traceback
904
+
905
+ self.tool_error(str(err))
906
+ self.tool_error(traceback.format_exc())
907
+ return ""
908
+ finally:
909
+ if self.file_watcher:
910
+ self.file_watcher.stop()
911
+ if self.clipboard_watcher:
912
+ self.clipboard_watcher.stop()
913
+
914
+ line = line or ""
915
+
916
+ if line.strip("\r\n") and not multiline_input:
917
+ stripped = line.strip("\r\n")
918
+ if stripped == "{":
919
+ multiline_input = True
920
+ multiline_tag = None
921
+ inp += ""
922
+ elif stripped[0] == "{":
923
+ # Extract tag if it exists (only alphanumeric chars)
924
+ tag = "".join(c for c in stripped[1:] if c.isalnum())
925
+ if stripped == "{" + tag:
926
+ multiline_input = True
927
+ multiline_tag = tag
928
+ inp += ""
929
+ else:
930
+ inp = line
931
+ break
932
+ else:
933
+ inp = line
934
+ break
935
+ continue
936
+ elif multiline_input and line.strip():
937
+ if multiline_tag:
938
+ # Check if line is exactly "tag}"
939
+ if line.strip("\r\n") == f"{multiline_tag}}}":
940
+ break
941
+ else:
942
+ inp += line + "\n"
943
+ # Check if line is exactly "}"
944
+ elif line.strip("\r\n") == "}":
945
+ break
946
+ else:
947
+ inp += line + "\n"
948
+ elif multiline_input:
949
+ inp += line + "\n"
950
+ else:
951
+ inp = line
952
+ break
953
+
954
+ self.user_input(inp)
955
+ return inp
956
+
957
+ async def cancel_input_task(self):
958
+ if self.input_task:
959
+ input_task = self.input_task
960
+ self.input_task = None
961
+ try:
962
+ input_task.cancel()
963
+ await input_task
964
+ except (asyncio.CancelledError, EOFError, IndexError):
965
+ pass
966
+
967
+ async def cancel_output_task(self):
968
+ if self.output_task:
969
+ output_task = self.output_task
970
+ self.output_task = None
971
+ try:
972
+ output_task.cancel()
973
+ await output_task
974
+ except (asyncio.CancelledError, EOFError, IndexError):
975
+ pass
976
+
977
+ def add_to_input_history(self, inp):
978
+ if not self.input_history_file:
979
+ return
980
+ try:
981
+ FileHistory(self.input_history_file).append_string(inp)
982
+ # Also add to the in-memory history if it exists
983
+ if self.prompt_session and self.prompt_session.history:
984
+ self.prompt_session.history.append_string(inp)
985
+ except OSError as err:
986
+ self.tool_warning(f"Unable to write to input history file: {err}")
987
+
988
+ def get_input_history(self):
989
+ if not self.input_history_file:
990
+ return []
991
+
992
+ fh = FileHistory(self.input_history_file)
993
+ return fh.load_history_strings()
994
+
995
+ def log_llm_history(self, role, content):
996
+ if not self.llm_history_file:
997
+ return
998
+ timestamp = datetime.now().isoformat(timespec="seconds")
999
+ try:
1000
+ Path(self.llm_history_file).parent.mkdir(parents=True, exist_ok=True)
1001
+ with open(self.llm_history_file, "a", encoding="utf-8") as log_file:
1002
+ log_file.write(f"{role.upper()} {timestamp}\n")
1003
+ log_file.write(content + "\n")
1004
+ except (PermissionError, OSError) as err:
1005
+ self.tool_warning(f"Unable to write to llm history file {self.llm_history_file}: {err}")
1006
+ self.llm_history_file = None
1007
+
1008
+ def display_user_input(self, inp):
1009
+ if self.pretty and self.user_input_color:
1010
+ style = dict(style=self.user_input_color)
1011
+ else:
1012
+ style = dict()
1013
+
1014
+ self.stream_print(Text(inp), **style)
1015
+
1016
+ def user_input(self, inp, log_only=True):
1017
+ if not log_only:
1018
+ self.display_user_input(inp)
1019
+
1020
+ if (
1021
+ len(inp) <= 1
1022
+ or self.confirmation_in_progress
1023
+ or self.get_confirmation_acknowledgement()
1024
+ ):
1025
+ return
1026
+
1027
+ prefix = "####"
1028
+ if inp:
1029
+ hist = inp.splitlines()
1030
+ else:
1031
+ hist = ["<blank>"]
1032
+
1033
+ hist = f" \n{prefix} ".join(hist)
1034
+
1035
+ hist = f"""
1036
+ {prefix} {hist}"""
1037
+ self.append_chat_history(hist, linebreak=True)
1038
+
1039
+ # OUTPUT
1040
+
1041
+ def ai_output(self, content):
1042
+ hist = "\n" + content.strip() + "\n\n"
1043
+ self.append_chat_history(hist)
1044
+
1045
+ async def offer_url(
1046
+ self, url, prompt="Open URL for more info?", allow_never=True, acknowledge=False
1047
+ ):
1048
+ """Offer to open a URL in the browser, returns True if opened."""
1049
+ if url in self.never_prompts:
1050
+ return False
1051
+ if await self.confirm_ask(
1052
+ prompt, subject=url, allow_never=allow_never, acknowledge=acknowledge
1053
+ ):
1054
+ webbrowser.open(url)
1055
+ return True
1056
+ return False
1057
+
1058
+ def set_confirmation_acknowledgement(self):
1059
+ self.confirmation_acknowledgement = True
1060
+
1061
+ def get_confirmation_acknowledgement(self):
1062
+ return self.confirmation_acknowledgement
1063
+
1064
+ def acknowledge_confirmation(self):
1065
+ outstanding_confirmation = self.confirmation_acknowledgement
1066
+ self.confirmation_acknowledgement = False
1067
+ return outstanding_confirmation
1068
+
1069
+ @restore_multiline_async
1070
+ @without_input_history
1071
+ async def confirm_ask(
1072
+ self,
1073
+ *args,
1074
+ **kwargs,
1075
+ ):
1076
+ self.confirmation_in_progress = True
1077
+
1078
+ try:
1079
+ return await asyncio.create_task(self._confirm_ask(*args, **kwargs))
1080
+ except KeyboardInterrupt:
1081
+ # Re-raise KeyboardInterrupt to allow it to propagate
1082
+ raise
1083
+ finally:
1084
+ self.confirmation_in_progress = False
1085
+
1086
+ async def _confirm_ask(
1087
+ self,
1088
+ question,
1089
+ default="y",
1090
+ subject=None,
1091
+ explicit_yes_required=False,
1092
+ group=None,
1093
+ group_response=None,
1094
+ allow_never=False,
1095
+ acknowledge=False,
1096
+ ):
1097
+ self.num_user_asks += 1
1098
+
1099
+ question_id = (question, subject)
1100
+
1101
+ try:
1102
+ if question_id in self.never_prompts:
1103
+ return False
1104
+
1105
+ if group and not group.show_group:
1106
+ group = None
1107
+ if group:
1108
+ allow_never = True
1109
+
1110
+ valid_responses = ["yes", "no", "skip", "all"]
1111
+ options = " (Y)es/(N)o"
1112
+
1113
+ if group or group_response:
1114
+ if not explicit_yes_required or group_response:
1115
+ options += "/(A)ll"
1116
+ options += "/(S)kip all"
1117
+ if allow_never:
1118
+ options += "/(D)on't ask again"
1119
+ valid_responses.append("don't")
1120
+
1121
+ if default.lower().startswith("y"):
1122
+ question += options + " [Yes]: "
1123
+ elif default.lower().startswith("n"):
1124
+ question += options + " [No]: "
1125
+ else:
1126
+ question += options + f" [{default}]: "
1127
+
1128
+ if subject:
1129
+ self.tool_output()
1130
+ if "\n" in subject:
1131
+ lines = subject.splitlines()
1132
+ max_length = max(len(line) for line in lines)
1133
+ padded_lines = [line.ljust(max_length) for line in lines]
1134
+ padded_subject = "\n".join(padded_lines)
1135
+ self.tool_output(padded_subject, bold=True)
1136
+ else:
1137
+ self.tool_output(subject, bold=True)
1138
+
1139
+ if self.yes is True and not explicit_yes_required:
1140
+ res = "y"
1141
+ elif group and group.preference:
1142
+ res = group.preference
1143
+ self.user_input(f"{question} - {res}", log_only=False)
1144
+ elif group_response and group_response in self.group_responses:
1145
+ return self.group_responses[group_response]
1146
+ else:
1147
+ # Ring the bell if needed
1148
+ self.ring_bell()
1149
+ self.start_spinner("Awaiting Confirmation...", False)
1150
+
1151
+ while True:
1152
+ try:
1153
+ if self.prompt_session:
1154
+ await self.recreate_input()
1155
+
1156
+ if (
1157
+ self.input_task
1158
+ and not self.input_task.done()
1159
+ and not self.input_task.cancelled()
1160
+ ):
1161
+ self.prompt_session.message = question
1162
+ self.prompt_session.app.invalidate()
1163
+ else:
1164
+ await asyncio.sleep(0)
1165
+
1166
+ res = await self.input_task
1167
+ await asyncio.sleep(0)
1168
+ else:
1169
+ res = await asyncio.get_event_loop().run_in_executor(
1170
+ None, input, question
1171
+ )
1172
+
1173
+ except EOFError:
1174
+ # Treat EOF (Ctrl+D) as if the user pressed Enter
1175
+ res = default
1176
+ break
1177
+ except asyncio.CancelledError:
1178
+ return False
1179
+
1180
+ if not res:
1181
+ res = default
1182
+ break
1183
+ res = res.lower()
1184
+ good = any(valid_response.startswith(res) for valid_response in valid_responses)
1185
+
1186
+ if good:
1187
+ if not acknowledge:
1188
+ self.set_confirmation_acknowledgement()
1189
+ self.start_spinner(self.last_spinner_text)
1190
+ break
1191
+
1192
+ error_message = f"Please answer with one of: {', '.join(valid_responses)}"
1193
+ self.tool_error(error_message)
1194
+
1195
+ res = res.lower()[0]
1196
+
1197
+ if res == "d" and allow_never:
1198
+ self.never_prompts.add(question_id)
1199
+ hist = f"{question.strip()} {res}"
1200
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1201
+ return False
1202
+
1203
+ if explicit_yes_required and not group_response:
1204
+ is_yes = res == "y"
1205
+ else:
1206
+ is_yes = res in ("y", "a")
1207
+
1208
+ is_all = res == "a" and (
1209
+ (group is not None and not explicit_yes_required) or group_response
1210
+ )
1211
+ is_skip = res == "s" and (group is not None or group_response)
1212
+
1213
+ if group:
1214
+ if is_all and not explicit_yes_required:
1215
+ group.preference = "all"
1216
+ elif is_skip:
1217
+ group.preference = "skip"
1218
+
1219
+ hist = f"{question.strip()} {res}"
1220
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1221
+ except asyncio.CancelledError:
1222
+ return False
1223
+ finally:
1224
+ pass
1225
+
1226
+ if group_response and (is_all or is_skip):
1227
+ self.group_responses[group_response] = is_yes
1228
+
1229
+ return is_yes
1230
+
1231
+ @restore_multiline
1232
+ def prompt_ask(self, question, default="", subject=None):
1233
+ self.num_user_asks += 1
1234
+
1235
+ # Ring the bell if needed
1236
+ self.ring_bell()
1237
+
1238
+ if subject:
1239
+ self.tool_output()
1240
+ self.tool_output(subject, bold=True)
1241
+
1242
+ style = self._get_style()
1243
+
1244
+ if self.yes is True:
1245
+ res = "yes"
1246
+ elif self.yes is False:
1247
+ res = "no"
1248
+ else:
1249
+ try:
1250
+ if self.prompt_session:
1251
+ res = self.prompt_session.prompt(
1252
+ question + " ",
1253
+ default=default,
1254
+ style=style,
1255
+ complete_while_typing=True,
1256
+ )
1257
+ else:
1258
+ res = input(question + " ")
1259
+ except EOFError:
1260
+ # Treat EOF (Ctrl+D) as if the user pressed Enter
1261
+ res = default
1262
+
1263
+ hist = f"{question.strip()} {res.strip()}"
1264
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1265
+ if self.yes in (True, False):
1266
+ self.tool_output(hist)
1267
+
1268
+ return res
1269
+
1270
+ def _tool_message(self, message="", strip=True, color=None):
1271
+ if message.strip():
1272
+ if "\n" in message:
1273
+ for line in message.splitlines():
1274
+ self.append_chat_history(line, linebreak=True, blockquote=True, strip=strip)
1275
+ else:
1276
+ hist = message.strip() if strip else message
1277
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1278
+
1279
+ if not isinstance(message, Text):
1280
+ message = Text(message)
1281
+
1282
+ style = dict()
1283
+ if self.pretty:
1284
+ if color:
1285
+ style["color"] = ensure_hash_prefix(color)
1286
+
1287
+ style = RichStyle(**style)
1288
+
1289
+ try:
1290
+ self.stream_print(message, style=style)
1291
+ except UnicodeEncodeError:
1292
+ # Fallback to ASCII-safe output
1293
+ if isinstance(message, Text):
1294
+ message = message.plain
1295
+ message = str(message).encode("ascii", errors="replace").decode("ascii")
1296
+ self.stream_print(message, style=style)
1297
+
1298
+ def tool_success(self, message="", strip=True):
1299
+ self._tool_message(message, strip, self.user_input_color)
1300
+
1301
+ def tool_error(self, message="", strip=True):
1302
+ self.num_error_outputs += 1
1303
+ self._tool_message(message, strip, self.tool_error_color)
1304
+
1305
+ def tool_warning(self, message="", strip=True):
1306
+ self._tool_message(message, strip, self.tool_warning_color)
1307
+
1308
+ def tool_output(self, *messages, log_only=False, bold=False):
1309
+ if messages:
1310
+ hist = " ".join(messages)
1311
+ hist = f"{hist.strip()}"
1312
+ self.append_chat_history(hist, linebreak=True, blockquote=True)
1313
+
1314
+ if log_only:
1315
+ return
1316
+
1317
+ messages = list(map(Text, messages))
1318
+ style = dict()
1319
+ if self.pretty:
1320
+ if self.tool_output_color:
1321
+ style["color"] = ensure_hash_prefix(self.tool_output_color)
1322
+ # if bold:
1323
+ # style["bold"] = True
1324
+
1325
+ style = RichStyle(**style)
1326
+
1327
+ self.stream_print(*messages, style=style)
1328
+
1329
+ def assistant_output(self, message, pretty=None):
1330
+ if not message:
1331
+ self.tool_warning("Empty response received from LLM. Check your provider account?")
1332
+ return
1333
+
1334
+ show_resp = message
1335
+
1336
+ # Coder will force pretty off if fence is not triple-backticks
1337
+ if pretty is None:
1338
+ pretty = self.pretty
1339
+
1340
+ show_resp = Text(message or "(empty response)")
1341
+
1342
+ self.stream_print(show_resp)
1343
+
1344
+ def render_markdown(self, text):
1345
+ output = StringIO()
1346
+ console = Console(file=output, force_terminal=True, color_system="truecolor")
1347
+ md = Markdown(text, style=self.assistant_output_color, code_theme=self.code_theme)
1348
+ console.print(md)
1349
+ return output.getvalue()
1350
+
1351
+ def stream_output(self, text, final=False):
1352
+ """
1353
+ Stream output using Rich console to respect pretty print settings.
1354
+ This preserves formatting, colors, and other Rich features during streaming.
1355
+ """
1356
+ # Initialize buffer if not exists
1357
+ if not hasattr(self, "_stream_buffer"):
1358
+ self._stream_buffer = ""
1359
+
1360
+ # Initialize buffer if not exists
1361
+ if not hasattr(self, "_stream_line_count"):
1362
+ self._stream_line_count = 0
1363
+
1364
+ self._stream_buffer += text
1365
+
1366
+ # Process the buffer to find complete lines
1367
+ lines = self._stream_buffer.split("\n")
1368
+ complete_lines = []
1369
+ incomplete_line = ""
1370
+ output = ""
1371
+
1372
+ lines = self.remove_consecutive_empty_strings(lines)
1373
+ needs_new_line = False if len(lines) == 2 and lines[0] and not lines[-1] else True
1374
+
1375
+ if len(lines) > 1 or final:
1376
+ # All lines except the last one are complete
1377
+ complete_lines = lines[:-1] if not final else lines
1378
+ incomplete_line = lines[-1] if not final else ""
1379
+ last_index = len(complete_lines) - 1
1380
+
1381
+ for index, complete_line in enumerate(complete_lines):
1382
+ output += complete_line
1383
+ output += "\n" if needs_new_line and index != last_index else ""
1384
+ self._stream_line_count += 1
1385
+
1386
+ self._stream_buffer = incomplete_line
1387
+
1388
+ if not final:
1389
+ if len(lines) > 1:
1390
+ self.console.print(
1391
+ Text.from_ansi(output) if self.has_ansi_codes(output) else output
1392
+ )
1393
+ else:
1394
+ # Ensure any remaining buffered content is printed using the full response
1395
+ self.console.print(Text.from_ansi(output) if self.has_ansi_codes(output) else output)
1396
+ self.reset_streaming_response()
1397
+
1398
+ def remove_consecutive_empty_strings(self, string_list):
1399
+ new_list = []
1400
+ first_item = True
1401
+
1402
+ for item in string_list:
1403
+ if first_item or item != "" or (new_list and new_list[-1] != ""):
1404
+ first_item = False
1405
+ new_list.append(item)
1406
+
1407
+ return new_list
1408
+
1409
+ def has_ansi_codes(self, s: str) -> bool:
1410
+ """Check if a string contains the ANSI escape character."""
1411
+ return "\x1b" in s
1412
+
1413
+ def reset_streaming_response(self):
1414
+ self._stream_buffer = ""
1415
+ self._stream_line_count = 0
1416
+
1417
+ def stream_print(self, *messages, **kwargs):
1418
+ with self.console.capture() as capture:
1419
+ self.console.print(*messages, **kwargs)
1420
+ capture_text = capture.get()
1421
+ self.stream_output(capture_text, final=False)
1422
+
1423
+ def set_placeholder(self, placeholder):
1424
+ """Set a one-time placeholder text for the next input prompt."""
1425
+ self.placeholder = placeholder
1426
+
1427
+ def print(self, message=""):
1428
+ print(message)
1429
+
1430
+ def llm_started(self):
1431
+ """Mark that the LLM has started processing, so we should ring the bell on next input"""
1432
+ self.bell_on_next_input = True
1433
+
1434
+ def get_default_notification_command(self):
1435
+ """Return a default notification command based on the operating system."""
1436
+ import platform
1437
+
1438
+ system = platform.system()
1439
+
1440
+ if system == "Darwin": # macOS
1441
+ # Check for terminal-notifier first
1442
+ if shutil.which("terminal-notifier"):
1443
+ return f"terminal-notifier -title 'Aider' -message '{NOTIFICATION_MESSAGE}'"
1444
+ # Fall back to osascript
1445
+ return (
1446
+ f'osascript -e \'display notification "{NOTIFICATION_MESSAGE}" with title "Aider"\''
1447
+ )
1448
+ elif system == "Linux":
1449
+ # Check for common Linux notification tools
1450
+ for cmd in ["notify-send", "zenity"]:
1451
+ if shutil.which(cmd):
1452
+ if cmd == "notify-send":
1453
+ return f"notify-send 'Aider' '{NOTIFICATION_MESSAGE}'"
1454
+ elif cmd == "zenity":
1455
+ return f"zenity --notification --text='{NOTIFICATION_MESSAGE}'"
1456
+ return None # No known notification tool found
1457
+ elif system == "Windows":
1458
+ # PowerShell notification
1459
+ return (
1460
+ "powershell -command"
1461
+ " \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');"
1462
+ f" [System.Windows.Forms.MessageBox]::Show('{NOTIFICATION_MESSAGE}',"
1463
+ " 'Aider')\""
1464
+ )
1465
+
1466
+ return None # Unknown system
1467
+
1468
+ def ring_bell(self):
1469
+ """Ring the terminal bell if needed and clear the flag"""
1470
+ if self.bell_on_next_input and self.notifications:
1471
+ if self.notifications_command:
1472
+ try:
1473
+ result = subprocess.run(
1474
+ self.notifications_command, shell=True, capture_output=True
1475
+ )
1476
+ if result.returncode != 0 and result.stderr:
1477
+ error_msg = result.stderr.decode("utf-8", errors="replace")
1478
+ self.tool_warning(f"Failed to run notifications command: {error_msg}")
1479
+ except Exception as e:
1480
+ self.tool_warning(f"Failed to run notifications command: {e}")
1481
+ else:
1482
+ print("\a", end="", flush=True) # Ring the bell
1483
+ self.bell_on_next_input = False # Clear the flag
1484
+
1485
+ def toggle_multiline_mode(self):
1486
+ """Toggle between normal and multiline input modes"""
1487
+ self.multiline_mode = not self.multiline_mode
1488
+ if self.multiline_mode:
1489
+ self.tool_output(
1490
+ "Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text"
1491
+ )
1492
+ else:
1493
+ self.tool_output(
1494
+ "Multiline mode: Disabled. Alt-Enter inserts newline, Enter submits text"
1495
+ )
1496
+
1497
+ def append_chat_history(self, text, linebreak=False, blockquote=False, strip=True):
1498
+ if self.confirmation_in_progress or self.get_confirmation_acknowledgement():
1499
+ return
1500
+
1501
+ if blockquote:
1502
+ if strip:
1503
+ text = text.strip()
1504
+ text = "> " + text
1505
+ if linebreak:
1506
+ if strip:
1507
+ text = text.rstrip()
1508
+ text = text + " \n"
1509
+ if not text.endswith("\n"):
1510
+ text += "\n"
1511
+ if self.chat_history_file is not None:
1512
+ try:
1513
+ self.chat_history_file.parent.mkdir(parents=True, exist_ok=True)
1514
+ with self.chat_history_file.open(
1515
+ "a", encoding=self.encoding or "utf-8", errors="ignore"
1516
+ ) as f:
1517
+ f.write(text)
1518
+ except (PermissionError, OSError) as err:
1519
+ print(f"Warning: Unable to write to chat history file {self.chat_history_file}.")
1520
+ print(err)
1521
+ self.chat_history_file = None # Disable further attempts to write
1522
+
1523
+ def format_files_for_input(self, rel_fnames, rel_read_only_fnames, rel_read_only_stubs_fnames):
1524
+ # Optimization for large number of files
1525
+ total_files = (
1526
+ len(rel_fnames)
1527
+ + len(rel_read_only_fnames or [])
1528
+ + len(rel_read_only_stubs_fnames or [])
1529
+ )
1530
+
1531
+ # For very large numbers of files, use a summary display
1532
+ if total_files > 50:
1533
+ read_only_count = len(rel_read_only_fnames or [])
1534
+ stub_file_count = len(rel_read_only_stubs_fnames or [])
1535
+ editable_count = len([f for f in rel_fnames if f not in (rel_read_only_fnames or [])])
1536
+
1537
+ summary = f"{editable_count} editable file(s)"
1538
+ if read_only_count > 0:
1539
+ summary += f", {read_only_count} read-only file(s)"
1540
+ if stub_file_count > 0:
1541
+ summary += f", {stub_file_count} stub file(s)"
1542
+ summary += " (use /ls to list all files)\n"
1543
+ return summary
1544
+
1545
+ # Original implementation for reasonable number of files
1546
+ if not self.pretty:
1547
+ lines = []
1548
+ # Handle regular read-only files
1549
+ for fname in sorted(rel_read_only_fnames or []):
1550
+ lines.append(f"{fname} (read only)")
1551
+ # Handle stub files separately
1552
+ for fname in sorted(rel_read_only_stubs_fnames or []):
1553
+ lines.append(f"{fname} (read only stub)")
1554
+ # Handle editable files
1555
+ for fname in sorted(rel_fnames):
1556
+ if fname not in rel_read_only_fnames and fname not in rel_read_only_stubs_fnames:
1557
+ lines.append(fname)
1558
+ return "\n".join(lines) + "\n"
1559
+
1560
+ output = StringIO()
1561
+ console = Console(file=output, force_terminal=False)
1562
+
1563
+ # Handle read-only files
1564
+ if rel_read_only_fnames or rel_read_only_stubs_fnames:
1565
+ ro_paths = []
1566
+ # Regular read-only files
1567
+ for rel_path in sorted(rel_read_only_fnames or []):
1568
+ abs_path = os.path.abspath(os.path.join(self.root, rel_path))
1569
+ ro_paths.append(abs_path if len(abs_path) < len(rel_path) else rel_path)
1570
+ # Stub files with (stub) marker
1571
+ for rel_path in sorted(rel_read_only_stubs_fnames or []):
1572
+ abs_path = os.path.abspath(os.path.join(self.root, rel_path))
1573
+ path = abs_path if len(abs_path) < len(rel_path) else rel_path
1574
+ ro_paths.append(f"{path} (stub)")
1575
+
1576
+ if ro_paths:
1577
+ files_with_label = ["Readonly:"] + ro_paths
1578
+ read_only_output = StringIO()
1579
+ Console(file=read_only_output, force_terminal=False).print(
1580
+ Columns(files_with_label)
1581
+ )
1582
+ read_only_lines = read_only_output.getvalue().splitlines()
1583
+ console.print(Columns(files_with_label))
1584
+
1585
+ # Handle editable files
1586
+ editable_files = [
1587
+ f
1588
+ for f in sorted(rel_fnames)
1589
+ if f not in rel_read_only_fnames and f not in rel_read_only_stubs_fnames
1590
+ ]
1591
+ if editable_files:
1592
+ files_with_label = editable_files
1593
+ if rel_read_only_fnames or rel_read_only_stubs_fnames:
1594
+ files_with_label = ["Editable:"] + editable_files
1595
+ editable_output = StringIO()
1596
+ Console(file=editable_output, force_terminal=False).print(Columns(files_with_label))
1597
+ editable_lines = editable_output.getvalue().splitlines()
1598
+ if len(read_only_lines) > 1 or len(editable_lines) > 1:
1599
+ console.print()
1600
+ console.print(Columns(files_with_label))
1601
+ return output.getvalue()
1602
+
1603
+
1604
+ def get_rel_fname(fname, root):
1605
+ try:
1606
+ return os.path.relpath(fname, root)
1607
+ except ValueError:
1608
+ return fname