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.
- aider/__init__.py +20 -0
- aider/__main__.py +4 -0
- aider/_version.py +34 -0
- aider/analytics.py +258 -0
- aider/args.py +1056 -0
- aider/args_formatter.py +228 -0
- aider/change_tracker.py +133 -0
- aider/coders/__init__.py +36 -0
- aider/coders/agent_coder.py +2166 -0
- aider/coders/agent_prompts.py +104 -0
- aider/coders/architect_coder.py +48 -0
- aider/coders/architect_prompts.py +40 -0
- aider/coders/ask_coder.py +9 -0
- aider/coders/ask_prompts.py +35 -0
- aider/coders/base_coder.py +3613 -0
- aider/coders/base_prompts.py +87 -0
- aider/coders/chat_chunks.py +64 -0
- aider/coders/context_coder.py +53 -0
- aider/coders/context_prompts.py +75 -0
- aider/coders/editblock_coder.py +657 -0
- aider/coders/editblock_fenced_coder.py +10 -0
- aider/coders/editblock_fenced_prompts.py +143 -0
- aider/coders/editblock_func_coder.py +141 -0
- aider/coders/editblock_func_prompts.py +27 -0
- aider/coders/editblock_prompts.py +175 -0
- aider/coders/editor_diff_fenced_coder.py +9 -0
- aider/coders/editor_diff_fenced_prompts.py +11 -0
- aider/coders/editor_editblock_coder.py +9 -0
- aider/coders/editor_editblock_prompts.py +21 -0
- aider/coders/editor_whole_coder.py +9 -0
- aider/coders/editor_whole_prompts.py +12 -0
- aider/coders/help_coder.py +16 -0
- aider/coders/help_prompts.py +46 -0
- aider/coders/patch_coder.py +706 -0
- aider/coders/patch_prompts.py +159 -0
- aider/coders/search_replace.py +757 -0
- aider/coders/shell.py +37 -0
- aider/coders/single_wholefile_func_coder.py +102 -0
- aider/coders/single_wholefile_func_prompts.py +27 -0
- aider/coders/udiff_coder.py +429 -0
- aider/coders/udiff_prompts.py +115 -0
- aider/coders/udiff_simple.py +14 -0
- aider/coders/udiff_simple_prompts.py +25 -0
- aider/coders/wholefile_coder.py +144 -0
- aider/coders/wholefile_func_coder.py +134 -0
- aider/coders/wholefile_func_prompts.py +27 -0
- aider/coders/wholefile_prompts.py +65 -0
- aider/commands.py +2173 -0
- aider/copypaste.py +72 -0
- aider/deprecated.py +126 -0
- aider/diffs.py +128 -0
- aider/dump.py +29 -0
- aider/editor.py +147 -0
- aider/exceptions.py +115 -0
- aider/format_settings.py +26 -0
- aider/gui.py +545 -0
- aider/help.py +163 -0
- aider/help_pats.py +19 -0
- aider/helpers/__init__.py +9 -0
- aider/helpers/similarity.py +98 -0
- aider/history.py +180 -0
- aider/io.py +1608 -0
- aider/linter.py +304 -0
- aider/llm.py +55 -0
- aider/main.py +1415 -0
- aider/mcp/__init__.py +174 -0
- aider/mcp/server.py +149 -0
- aider/mdstream.py +243 -0
- aider/models.py +1313 -0
- aider/onboarding.py +429 -0
- aider/openrouter.py +129 -0
- aider/prompts.py +56 -0
- aider/queries/tree-sitter-language-pack/README.md +7 -0
- aider/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
- aider/queries/tree-sitter-language-pack/c-tags.scm +9 -0
- aider/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
- aider/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
- aider/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
- aider/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
- aider/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
- aider/queries/tree-sitter-language-pack/d-tags.scm +26 -0
- aider/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
- aider/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
- aider/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
- aider/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
- aider/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
- aider/queries/tree-sitter-language-pack/go-tags.scm +42 -0
- aider/queries/tree-sitter-language-pack/java-tags.scm +20 -0
- aider/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
- aider/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
- aider/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
- aider/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
- aider/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
- aider/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
- aider/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
- aider/queries/tree-sitter-language-pack/python-tags.scm +14 -0
- aider/queries/tree-sitter-language-pack/r-tags.scm +21 -0
- aider/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
- aider/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
- aider/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
- aider/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
- aider/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
- aider/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
- aider/queries/tree-sitter-languages/README.md +24 -0
- aider/queries/tree-sitter-languages/c-tags.scm +9 -0
- aider/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
- aider/queries/tree-sitter-languages/cpp-tags.scm +15 -0
- aider/queries/tree-sitter-languages/dart-tags.scm +91 -0
- aider/queries/tree-sitter-languages/elisp-tags.scm +8 -0
- aider/queries/tree-sitter-languages/elixir-tags.scm +54 -0
- aider/queries/tree-sitter-languages/elm-tags.scm +19 -0
- aider/queries/tree-sitter-languages/fortran-tags.scm +15 -0
- aider/queries/tree-sitter-languages/go-tags.scm +30 -0
- aider/queries/tree-sitter-languages/haskell-tags.scm +3 -0
- aider/queries/tree-sitter-languages/hcl-tags.scm +77 -0
- aider/queries/tree-sitter-languages/java-tags.scm +20 -0
- aider/queries/tree-sitter-languages/javascript-tags.scm +88 -0
- aider/queries/tree-sitter-languages/julia-tags.scm +60 -0
- aider/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
- aider/queries/tree-sitter-languages/matlab-tags.scm +10 -0
- aider/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
- aider/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
- aider/queries/tree-sitter-languages/php-tags.scm +26 -0
- aider/queries/tree-sitter-languages/python-tags.scm +12 -0
- aider/queries/tree-sitter-languages/ql-tags.scm +26 -0
- aider/queries/tree-sitter-languages/ruby-tags.scm +64 -0
- aider/queries/tree-sitter-languages/rust-tags.scm +60 -0
- aider/queries/tree-sitter-languages/scala-tags.scm +65 -0
- aider/queries/tree-sitter-languages/typescript-tags.scm +41 -0
- aider/queries/tree-sitter-languages/zig-tags.scm +3 -0
- aider/reasoning_tags.py +82 -0
- aider/repo.py +621 -0
- aider/repomap.py +1174 -0
- aider/report.py +260 -0
- aider/resources/__init__.py +3 -0
- aider/resources/model-metadata.json +776 -0
- aider/resources/model-settings.yml +2068 -0
- aider/run_cmd.py +133 -0
- aider/scrape.py +293 -0
- aider/sendchat.py +242 -0
- aider/sessions.py +256 -0
- aider/special.py +203 -0
- aider/tools/__init__.py +72 -0
- aider/tools/command.py +105 -0
- aider/tools/command_interactive.py +122 -0
- aider/tools/delete_block.py +182 -0
- aider/tools/delete_line.py +155 -0
- aider/tools/delete_lines.py +184 -0
- aider/tools/extract_lines.py +341 -0
- aider/tools/finished.py +48 -0
- aider/tools/git_branch.py +129 -0
- aider/tools/git_diff.py +60 -0
- aider/tools/git_log.py +57 -0
- aider/tools/git_remote.py +53 -0
- aider/tools/git_show.py +51 -0
- aider/tools/git_status.py +46 -0
- aider/tools/grep.py +256 -0
- aider/tools/indent_lines.py +221 -0
- aider/tools/insert_block.py +288 -0
- aider/tools/list_changes.py +86 -0
- aider/tools/ls.py +93 -0
- aider/tools/make_editable.py +85 -0
- aider/tools/make_readonly.py +69 -0
- aider/tools/remove.py +91 -0
- aider/tools/replace_all.py +126 -0
- aider/tools/replace_line.py +173 -0
- aider/tools/replace_lines.py +217 -0
- aider/tools/replace_text.py +187 -0
- aider/tools/show_numbered_context.py +147 -0
- aider/tools/tool_utils.py +313 -0
- aider/tools/undo_change.py +95 -0
- aider/tools/update_todo_list.py +156 -0
- aider/tools/view.py +57 -0
- aider/tools/view_files_matching.py +141 -0
- aider/tools/view_files_with_symbol.py +129 -0
- aider/urls.py +17 -0
- aider/utils.py +456 -0
- aider/versioncheck.py +113 -0
- aider/voice.py +205 -0
- aider/waiting.py +38 -0
- aider/watch.py +318 -0
- aider/watch_prompts.py +12 -0
- aider/website/Gemfile +8 -0
- aider/website/_includes/blame.md +162 -0
- aider/website/_includes/get-started.md +22 -0
- aider/website/_includes/help-tip.md +5 -0
- aider/website/_includes/help.md +24 -0
- aider/website/_includes/install.md +5 -0
- aider/website/_includes/keys.md +4 -0
- aider/website/_includes/model-warnings.md +67 -0
- aider/website/_includes/multi-line.md +22 -0
- aider/website/_includes/python-m-aider.md +5 -0
- aider/website/_includes/recording.css +228 -0
- aider/website/_includes/recording.md +34 -0
- aider/website/_includes/replit-pipx.md +9 -0
- aider/website/_includes/works-best.md +1 -0
- aider/website/_sass/custom/custom.scss +103 -0
- aider/website/docs/config/adv-model-settings.md +2261 -0
- aider/website/docs/config/agent-mode.md +194 -0
- aider/website/docs/config/aider_conf.md +548 -0
- aider/website/docs/config/api-keys.md +90 -0
- aider/website/docs/config/dotenv.md +493 -0
- aider/website/docs/config/editor.md +127 -0
- aider/website/docs/config/mcp.md +95 -0
- aider/website/docs/config/model-aliases.md +104 -0
- aider/website/docs/config/options.md +890 -0
- aider/website/docs/config/reasoning.md +210 -0
- aider/website/docs/config.md +44 -0
- aider/website/docs/faq.md +384 -0
- aider/website/docs/git.md +76 -0
- aider/website/docs/index.md +47 -0
- aider/website/docs/install/codespaces.md +39 -0
- aider/website/docs/install/docker.md +57 -0
- aider/website/docs/install/optional.md +100 -0
- aider/website/docs/install/replit.md +8 -0
- aider/website/docs/install.md +115 -0
- aider/website/docs/languages.md +264 -0
- aider/website/docs/legal/contributor-agreement.md +111 -0
- aider/website/docs/legal/privacy.md +104 -0
- aider/website/docs/llms/anthropic.md +77 -0
- aider/website/docs/llms/azure.md +48 -0
- aider/website/docs/llms/bedrock.md +132 -0
- aider/website/docs/llms/cohere.md +34 -0
- aider/website/docs/llms/deepseek.md +32 -0
- aider/website/docs/llms/gemini.md +49 -0
- aider/website/docs/llms/github.md +111 -0
- aider/website/docs/llms/groq.md +36 -0
- aider/website/docs/llms/lm-studio.md +39 -0
- aider/website/docs/llms/ollama.md +75 -0
- aider/website/docs/llms/openai-compat.md +39 -0
- aider/website/docs/llms/openai.md +58 -0
- aider/website/docs/llms/openrouter.md +78 -0
- aider/website/docs/llms/other.md +117 -0
- aider/website/docs/llms/vertex.md +50 -0
- aider/website/docs/llms/warnings.md +10 -0
- aider/website/docs/llms/xai.md +53 -0
- aider/website/docs/llms.md +54 -0
- aider/website/docs/more/analytics.md +127 -0
- aider/website/docs/more/edit-formats.md +116 -0
- aider/website/docs/more/infinite-output.md +165 -0
- aider/website/docs/more-info.md +8 -0
- aider/website/docs/recordings/auto-accept-architect.md +31 -0
- aider/website/docs/recordings/dont-drop-original-read-files.md +35 -0
- aider/website/docs/recordings/index.md +21 -0
- aider/website/docs/recordings/model-accepts-settings.md +69 -0
- aider/website/docs/recordings/tree-sitter-language-pack.md +80 -0
- aider/website/docs/repomap.md +112 -0
- aider/website/docs/scripting.md +100 -0
- aider/website/docs/sessions.md +203 -0
- aider/website/docs/troubleshooting/aider-not-found.md +24 -0
- aider/website/docs/troubleshooting/edit-errors.md +76 -0
- aider/website/docs/troubleshooting/imports.md +62 -0
- aider/website/docs/troubleshooting/models-and-keys.md +54 -0
- aider/website/docs/troubleshooting/support.md +79 -0
- aider/website/docs/troubleshooting/token-limits.md +96 -0
- aider/website/docs/troubleshooting/warnings.md +12 -0
- aider/website/docs/troubleshooting.md +11 -0
- aider/website/docs/usage/browser.md +57 -0
- aider/website/docs/usage/caching.md +49 -0
- aider/website/docs/usage/commands.md +133 -0
- aider/website/docs/usage/conventions.md +119 -0
- aider/website/docs/usage/copypaste.md +121 -0
- aider/website/docs/usage/images-urls.md +48 -0
- aider/website/docs/usage/lint-test.md +118 -0
- aider/website/docs/usage/modes.md +211 -0
- aider/website/docs/usage/not-code.md +179 -0
- aider/website/docs/usage/notifications.md +87 -0
- aider/website/docs/usage/tips.md +79 -0
- aider/website/docs/usage/tutorials.md +30 -0
- aider/website/docs/usage/voice.md +121 -0
- aider/website/docs/usage/watch.md +294 -0
- aider/website/docs/usage.md +102 -0
- aider/website/share/index.md +101 -0
- aider_ce-0.88.20.dist-info/METADATA +187 -0
- aider_ce-0.88.20.dist-info/RECORD +279 -0
- aider_ce-0.88.20.dist-info/WHEEL +5 -0
- aider_ce-0.88.20.dist-info/entry_points.txt +2 -0
- aider_ce-0.88.20.dist-info/licenses/LICENSE.txt +202 -0
- 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
|