aider-ce 0.87.13.dev3__py3-none-any.whl → 0.88.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (60) hide show
  1. aider/__init__.py +1 -1
  2. aider/_version.py +2 -2
  3. aider/args.py +6 -0
  4. aider/coders/architect_coder.py +3 -3
  5. aider/coders/base_coder.py +505 -184
  6. aider/coders/context_coder.py +1 -1
  7. aider/coders/editblock_func_coder.py +2 -2
  8. aider/coders/navigator_coder.py +451 -649
  9. aider/coders/navigator_legacy_prompts.py +49 -284
  10. aider/coders/navigator_prompts.py +46 -473
  11. aider/coders/search_replace.py +0 -0
  12. aider/coders/wholefile_func_coder.py +2 -2
  13. aider/commands.py +56 -44
  14. aider/history.py +14 -12
  15. aider/io.py +354 -117
  16. aider/llm.py +12 -4
  17. aider/main.py +22 -19
  18. aider/mcp/__init__.py +65 -2
  19. aider/mcp/server.py +37 -11
  20. aider/models.py +45 -20
  21. aider/onboarding.py +4 -4
  22. aider/repo.py +7 -7
  23. aider/resources/model-metadata.json +8 -8
  24. aider/scrape.py +2 -2
  25. aider/sendchat.py +185 -15
  26. aider/tools/__init__.py +44 -23
  27. aider/tools/command.py +18 -0
  28. aider/tools/command_interactive.py +18 -0
  29. aider/tools/delete_block.py +23 -0
  30. aider/tools/delete_line.py +19 -1
  31. aider/tools/delete_lines.py +20 -1
  32. aider/tools/extract_lines.py +25 -2
  33. aider/tools/git.py +142 -0
  34. aider/tools/grep.py +47 -2
  35. aider/tools/indent_lines.py +25 -0
  36. aider/tools/insert_block.py +26 -0
  37. aider/tools/list_changes.py +15 -0
  38. aider/tools/ls.py +24 -1
  39. aider/tools/make_editable.py +18 -0
  40. aider/tools/make_readonly.py +19 -0
  41. aider/tools/remove.py +22 -0
  42. aider/tools/replace_all.py +21 -0
  43. aider/tools/replace_line.py +20 -1
  44. aider/tools/replace_lines.py +21 -1
  45. aider/tools/replace_text.py +22 -0
  46. aider/tools/show_numbered_context.py +18 -0
  47. aider/tools/undo_change.py +15 -0
  48. aider/tools/update_todo_list.py +131 -0
  49. aider/tools/view.py +23 -0
  50. aider/tools/view_files_at_glob.py +32 -27
  51. aider/tools/view_files_matching.py +51 -37
  52. aider/tools/view_files_with_symbol.py +41 -54
  53. aider/tools/view_todo_list.py +57 -0
  54. aider/waiting.py +20 -203
  55. {aider_ce-0.87.13.dev3.dist-info → aider_ce-0.88.0.dist-info}/METADATA +21 -5
  56. {aider_ce-0.87.13.dev3.dist-info → aider_ce-0.88.0.dist-info}/RECORD +59 -56
  57. {aider_ce-0.87.13.dev3.dist-info → aider_ce-0.88.0.dist-info}/WHEEL +0 -0
  58. {aider_ce-0.87.13.dev3.dist-info → aider_ce-0.88.0.dist-info}/entry_points.txt +0 -0
  59. {aider_ce-0.87.13.dev3.dist-info → aider_ce-0.88.0.dist-info}/licenses/LICENSE.txt +0 -0
  60. {aider_ce-0.87.13.dev3.dist-info → aider_ce-0.88.0.dist-info}/top_level.txt +0 -0
@@ -1,10 +1,28 @@
1
1
  import fnmatch
2
2
  import os
3
3
 
4
+ view_files_at_glob_schema = {
5
+ "type": "function",
6
+ "function": {
7
+ "name": "ViewFilesAtGlob",
8
+ "description": "View files matching a glob pattern.",
9
+ "parameters": {
10
+ "type": "object",
11
+ "properties": {
12
+ "pattern": {
13
+ "type": "string",
14
+ "description": "The glob pattern to match files.",
15
+ },
16
+ },
17
+ "required": ["pattern"],
18
+ },
19
+ },
20
+ }
21
+
4
22
 
5
23
  def execute_view_files_at_glob(coder, pattern):
6
24
  """
7
- Execute a glob pattern and add matching files to context as read-only.
25
+ Execute a glob pattern and return matching files as text.
8
26
 
9
27
  This tool helps the LLM find files by pattern matching, similar to
10
28
  how a developer would use glob patterns to find files.
@@ -25,38 +43,25 @@ def execute_view_files_at_glob(coder, pattern):
25
43
  if fnmatch.fnmatch(file, pattern):
26
44
  matching_files.append(file)
27
45
 
28
- # Limit the number of files added if there are too many matches
29
- if len(matching_files) > coder.max_files_per_glob:
30
- coder.io.tool_output(
31
- f"⚠️ Found {len(matching_files)} files matching '{pattern}', "
32
- f"limiting to {coder.max_files_per_glob} most relevant files."
33
- )
34
- # Sort by modification time (most recent first)
35
- matching_files.sort(
36
- key=lambda f: os.path.getmtime(coder.abs_root_path(f)), reverse=True
37
- )
38
- matching_files = matching_files[: coder.max_files_per_glob]
39
-
40
- # Add files to context
41
- for file in matching_files:
42
- # Use the coder's internal method to add files
43
- coder._add_file_to_context(file)
44
-
45
- # Return a user-friendly result
46
+ # Return formatted text instead of adding to context
46
47
  if matching_files:
47
48
  if len(matching_files) > 10:
48
- brief = ", ".join(matching_files[:5]) + f", and {len(matching_files) - 5} more"
49
- coder.io.tool_output(
50
- f"📂 Added {len(matching_files)} files matching '{pattern}': {brief}"
49
+ result = (
50
+ f"Found {len(matching_files)} files matching '{pattern}':"
51
+ f" {', '.join(matching_files[:10])} and {len(matching_files) - 10} more"
51
52
  )
53
+ coder.io.tool_output(f"📂 Found {len(matching_files)} files matching '{pattern}'")
52
54
  else:
55
+ result = (
56
+ f"Found {len(matching_files)} files matching '{pattern}':"
57
+ f" {', '.join(matching_files)}"
58
+ )
53
59
  coder.io.tool_output(
54
- f"📂 Added files matching '{pattern}': {', '.join(matching_files)}"
60
+ f"📂 Found files matching '{pattern}':"
61
+ f" {', '.join(matching_files[:5])}{' and more' if len(matching_files) > 5 else ''}"
55
62
  )
56
- return (
57
- f"Added {len(matching_files)} files:"
58
- f" {', '.join(matching_files[:5])}{' and more' if len(matching_files) > 5 else ''}"
59
- )
63
+
64
+ return result
60
65
  else:
61
66
  coder.io.tool_output(f"⚠️ No files found matching '{pattern}'")
62
67
  return f"No files found matching '{pattern}'"
@@ -1,18 +1,46 @@
1
1
  import fnmatch
2
2
  import re
3
3
 
4
+ view_files_matching_schema = {
5
+ "type": "function",
6
+ "function": {
7
+ "name": "ViewFilesMatching",
8
+ "description": "View files containing a specific pattern.",
9
+ "parameters": {
10
+ "type": "object",
11
+ "properties": {
12
+ "pattern": {
13
+ "type": "string",
14
+ "description": "The pattern to search for in file contents.",
15
+ },
16
+ "file_pattern": {
17
+ "type": "string",
18
+ "description": "An optional glob pattern to filter which files are searched.",
19
+ },
20
+ "regex": {
21
+ "type": "boolean",
22
+ "description": (
23
+ "Whether the pattern is a regular expression. Defaults to False."
24
+ ),
25
+ },
26
+ },
27
+ "required": ["pattern"],
28
+ },
29
+ },
30
+ }
4
31
 
5
- def execute_view_files_matching(coder, search_pattern, file_pattern=None, regex=False):
32
+
33
+ def execute_view_files_matching(coder, pattern, file_pattern=None, regex=False):
6
34
  """
7
- Search for pattern (literal string or regex) in files and add matching files to context as read-only.
35
+ Search for pattern (literal string or regex) in files and return matching files as text.
8
36
 
9
37
  Args:
10
38
  coder: The Coder instance.
11
- search_pattern (str): The pattern to search for.
39
+ pattern (str): The pattern to search for.
12
40
  Treated as a literal string by default.
13
41
  file_pattern (str, optional): Glob pattern to filter which files are searched.
14
42
  Defaults to None (search all files).
15
- regex (bool, optional): If True, treat search_pattern as a regular expression.
43
+ regex (bool, optional): If True, treat pattern as a regular expression.
16
44
  Defaults to False.
17
45
 
18
46
  This tool lets the LLM search for content within files, mimicking
@@ -29,9 +57,7 @@ def execute_view_files_matching(coder, search_pattern, file_pattern=None, regex=
29
57
  files_to_search.append(file)
30
58
 
31
59
  if not files_to_search:
32
- return (
33
- f"No files matching '{file_pattern}' to search for pattern '{search_pattern}'"
34
- )
60
+ return f"No files matching '{file_pattern}' to search for pattern '{pattern}'"
35
61
  else:
36
62
  # Search all files if no pattern provided
37
63
  files_to_search = coder.get_all_relative_files()
@@ -46,16 +72,16 @@ def execute_view_files_matching(coder, search_pattern, file_pattern=None, regex=
46
72
  match_count = 0
47
73
  if regex:
48
74
  try:
49
- matches_found = re.findall(search_pattern, content)
75
+ matches_found = re.findall(pattern, content)
50
76
  match_count = len(matches_found)
51
77
  except re.error as e:
52
78
  # Handle invalid regex patterns gracefully
53
- coder.io.tool_error(f"Invalid regex pattern '{search_pattern}': {e}")
79
+ coder.io.tool_error(f"Invalid regex pattern '{pattern}': {e}")
54
80
  # Skip this file for this search if regex is invalid
55
81
  continue
56
82
  else:
57
83
  # Exact string matching
58
- match_count = content.count(search_pattern)
84
+ match_count = content.count(pattern)
59
85
 
60
86
  if match_count > 0:
61
87
  matches[file] = match_count
@@ -63,40 +89,28 @@ def execute_view_files_matching(coder, search_pattern, file_pattern=None, regex=
63
89
  # Skip files that can't be read (binary, etc.)
64
90
  pass
65
91
 
66
- # Limit the number of files added if there are too many matches
67
- if len(matches) > coder.max_files_per_glob:
68
- coder.io.tool_output(
69
- f"⚠️ Found '{search_pattern}' in {len(matches)} files, "
70
- f"limiting to {coder.max_files_per_glob} files with most matches."
71
- )
72
- # Sort by number of matches (most matches first)
73
- sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True)
74
- matches = dict(sorted_matches[: coder.max_files_per_glob])
75
-
76
- # Add matching files to context
77
- for file in matches:
78
- coder._add_file_to_context(file)
79
-
80
- # Return a user-friendly result
92
+ # Return formatted text instead of adding to context
81
93
  if matches:
82
94
  # Sort by number of matches (most matches first)
83
95
  sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True)
84
- match_list = [f"{file} ({count} matches)" for file, count in sorted_matches[:5]]
96
+ match_list = [f"{file} ({count} matches)" for file, count in sorted_matches]
85
97
 
86
- if len(sorted_matches) > 5:
87
- coder.io.tool_output(
88
- f"🔍 Found '{search_pattern}' in {len(matches)} files:"
89
- f" {', '.join(match_list)} and {len(matches) - 5} more"
90
- )
91
- return (
92
- f"Found in {len(matches)} files: {', '.join(match_list)} and"
93
- f" {len(matches) - 5} more"
98
+ if len(matches) > 10:
99
+ result = (
100
+ f"Found '{pattern}' in {len(matches)} files: {', '.join(match_list[:10])} and"
101
+ f" {len(matches) - 10} more"
94
102
  )
103
+ coder.io.tool_output(f"🔍 Found '{pattern}' in {len(matches)} files")
95
104
  else:
96
- coder.io.tool_output(f"🔍 Found '{search_pattern}' in: {', '.join(match_list)}")
97
- return f"Found in {len(matches)} files: {', '.join(match_list)}"
105
+ result = f"Found '{pattern}' in {len(matches)} files: {', '.join(match_list)}"
106
+ coder.io.tool_output(
107
+ f"🔍 Found '{pattern}' in:"
108
+ f" {', '.join(match_list[:5])}{' and more' if len(matches) > 5 else ''}"
109
+ )
110
+
111
+ return result
98
112
  else:
99
- coder.io.tool_output(f"⚠️ Pattern '{search_pattern}' not found in any files")
113
+ coder.io.tool_output(f"⚠️ Pattern '{pattern}' not found in any files")
100
114
  return "Pattern not found in any files"
101
115
  except Exception as e:
102
116
  coder.io.tool_error(f"Error in ViewFilesMatching: {str(e)}")
@@ -1,9 +1,25 @@
1
- import os
1
+ view_files_with_symbol_schema = {
2
+ "type": "function",
3
+ "function": {
4
+ "name": "ViewFilesWithSymbol",
5
+ "description": "View files that contain a specific symbol (e.g., class, function).",
6
+ "parameters": {
7
+ "type": "object",
8
+ "properties": {
9
+ "symbol": {
10
+ "type": "string",
11
+ "description": "The symbol to search for.",
12
+ },
13
+ },
14
+ "required": ["symbol"],
15
+ },
16
+ },
17
+ }
2
18
 
3
19
 
4
20
  def _execute_view_files_with_symbol(coder, symbol):
5
21
  """
6
- Find files containing a symbol using RepoMap and add them to context.
22
+ Find files containing a symbol using RepoMap and return them as text.
7
23
  Checks files already in context first.
8
24
  """
9
25
  if not coder.repo_map:
@@ -13,7 +29,6 @@ def _execute_view_files_with_symbol(coder, symbol):
13
29
  if not symbol:
14
30
  return "Error: Missing 'symbol' parameter for ViewFilesWithSymbol"
15
31
 
16
- # --- Start Modification ---
17
32
  # 1. Check files already in context
18
33
  files_in_context = list(coder.abs_fnames) + list(coder.abs_read_only_fnames)
19
34
  found_in_context = []
@@ -34,20 +49,11 @@ def _execute_view_files_with_symbol(coder, symbol):
34
49
  if found_in_context:
35
50
  # Symbol found in already loaded files. Report this and stop.
36
51
  file_list = ", ".join(sorted(list(set(found_in_context))))
37
- coder.io.tool_output(
38
- f"Symbol '{symbol}' found in already loaded file(s): {file_list}. No external search"
39
- " performed."
40
- )
41
- return (
42
- f"Symbol '{symbol}' found in already loaded file(s): {file_list}. No external search"
43
- " performed."
44
- )
45
- # --- End Modification ---
52
+ coder.io.tool_output(f"Symbol '{symbol}' found in already loaded file(s): {file_list}")
53
+ return f"Symbol '{symbol}' found in already loaded file(s): {file_list}"
46
54
 
47
55
  # 2. If not found in context, search the repository using RepoMap
48
- coder.io.tool_output(
49
- f"🔎 Searching for symbol '{symbol}' in repository (excluding current context)..."
50
- )
56
+ coder.io.tool_output(f"🔎 Searching for symbol '{symbol}' in repository...")
51
57
  try:
52
58
  found_files = set()
53
59
  current_context_files = coder.abs_fnames | coder.abs_read_only_fnames
@@ -71,50 +77,31 @@ def _execute_view_files_with_symbol(coder, symbol):
71
77
  # Use absolute path directly if available, otherwise resolve from relative path
72
78
  abs_fname = rel_fname_to_abs.get(tag.rel_fname) or coder.abs_root_path(tag.fname)
73
79
  if abs_fname in files_to_search: # Ensure we only add files we intended to search
74
- found_files.add(abs_fname)
80
+ found_files.add(coder.get_rel_fname(abs_fname))
75
81
 
76
- # Limit the number of files added
77
- if len(found_files) > coder.max_files_per_glob:
78
- coder.io.tool_output(
79
- f"⚠️ Found symbol '{symbol}' in {len(found_files)} files, "
80
- f"limiting to {coder.max_files_per_glob} most relevant files."
81
- )
82
- # Sort by modification time (most recent first) - approximate relevance
83
- sorted_found_files = sorted(
84
- list(found_files), key=lambda f: os.path.getmtime(f), reverse=True
85
- )
86
- found_files = set(sorted_found_files[: coder.max_files_per_glob])
87
-
88
- # Add files to context (as read-only)
89
- added_count = 0
90
- added_files_rel = []
91
- for abs_file_path in found_files:
92
- rel_path = coder.get_rel_fname(abs_file_path)
93
- # Double check it's not already added somehow
94
- if (
95
- abs_file_path not in coder.abs_fnames
96
- and abs_file_path not in coder.abs_read_only_fnames
97
- ):
98
- # Use explicit=True for clear output, even though it's an external search result
99
- add_result = coder._add_file_to_context(rel_path, explicit=True)
100
- if "Added" in add_result or "Viewed" in add_result: # Count successful adds/views
101
- added_count += 1
102
- added_files_rel.append(rel_path)
103
-
104
- if added_count > 0:
105
- if added_count > 5:
106
- brief = ", ".join(added_files_rel[:5]) + f", and {added_count - 5} more"
107
- coder.io.tool_output(f"🔎 Found '{symbol}' and added {added_count} files: {brief}")
82
+ # Return formatted text instead of adding to context
83
+ if found_files:
84
+ found_files_list = sorted(list(found_files))
85
+ if len(found_files) > 10:
86
+ result = (
87
+ f"Found symbol '{symbol}' in {len(found_files)} files:"
88
+ f" {', '.join(found_files_list[:10])} and {len(found_files) - 10} more"
89
+ )
90
+ coder.io.tool_output(f"🔎 Found '{symbol}' in {len(found_files)} files")
108
91
  else:
92
+ result = (
93
+ f"Found symbol '{symbol}' in {len(found_files)} files:"
94
+ f" {', '.join(found_files_list)}"
95
+ )
109
96
  coder.io.tool_output(
110
- f"🔎 Found '{symbol}' and added files: {', '.join(added_files_rel)}"
97
+ f"🔎 Found '{symbol}' in files:"
98
+ f" {', '.join(found_files_list[:5])}{' and more' if len(found_files) > 5 else ''}"
111
99
  )
112
- return f"Found symbol '{symbol}' and added {added_count} files as read-only."
100
+
101
+ return result
113
102
  else:
114
- coder.io.tool_output(
115
- f"⚠️ Symbol '{symbol}' not found in searchable files (outside current context)."
116
- )
117
- return f"Symbol '{symbol}' not found in searchable files (outside current context)."
103
+ coder.io.tool_output(f"⚠️ Symbol '{symbol}' not found in searchable files")
104
+ return f"Symbol '{symbol}' not found in searchable files"
118
105
 
119
106
  except Exception as e:
120
107
  coder.io.tool_error(f"Error in ViewFilesWithSymbol: {str(e)}")
@@ -0,0 +1,57 @@
1
+ from .tool_utils import ToolError, format_tool_result, handle_tool_error
2
+
3
+ view_todo_list_schema = {
4
+ "type": "function",
5
+ "function": {
6
+ "name": "ViewTodoList",
7
+ "description": "View the current todo list for tracking conversation steps and progress.",
8
+ "parameters": {
9
+ "type": "object",
10
+ "properties": {},
11
+ "required": [],
12
+ },
13
+ },
14
+ }
15
+
16
+
17
+ def _execute_view_todo_list(coder):
18
+ """
19
+ View the current todo list from .aider.todo.txt file.
20
+ Returns the todo list content or creates an empty one if it doesn't exist.
21
+ """
22
+ tool_name = "ViewTodoList"
23
+ try:
24
+ # Define the todo file path
25
+ todo_file_path = ".aider.todo.txt"
26
+ abs_path = coder.abs_root_path(todo_file_path)
27
+
28
+ # Check if file exists
29
+ import os
30
+
31
+ if os.path.isfile(abs_path):
32
+ # Read existing todo list
33
+ content = coder.io.read_text(abs_path)
34
+ if content is None:
35
+ raise ToolError(f"Could not read todo list file: {todo_file_path}")
36
+
37
+ # Check if content exceeds 4096 characters and warn
38
+ if len(content) > 4096:
39
+ coder.io.tool_warning(
40
+ "⚠️ Todo list content exceeds 4096 characters. Consider summarizing the plan"
41
+ " before proceeding."
42
+ )
43
+
44
+ if content.strip():
45
+ result_message = f"Current todo list:\n```\n{content}\n```"
46
+ else:
47
+ result_message = "Todo list is empty. Use UpdateTodoList to add items."
48
+ else:
49
+ # Create empty todo list
50
+ result_message = "Todo list is empty. Use UpdateTodoList to add items."
51
+
52
+ return format_tool_result(coder, tool_name, result_message)
53
+
54
+ except ToolError as e:
55
+ return handle_tool_error(coder, tool_name, e, add_traceback=False)
56
+ except Exception as e:
57
+ return handle_tool_error(coder, tool_name, e)
aider/waiting.py CHANGED
@@ -1,221 +1,38 @@
1
1
  #!/usr/bin/env python
2
2
 
3
3
  """
4
- Thread-based, killable spinner utility.
5
-
6
- Use it like:
7
-
8
- from aider.waiting import WaitingSpinner
9
-
10
- spinner = WaitingSpinner("Waiting for LLM")
11
- spinner.start()
12
- ... # long task
13
- spinner.stop()
4
+ A simple wrapper for rich.status to provide a spinner.
14
5
  """
15
6
 
16
- import sys
17
- import threading
18
- import time
19
-
20
7
  from rich.console import Console
21
8
 
22
9
 
23
10
  class Spinner:
24
- """
25
- Minimal spinner that scans a single marker back and forth across a line.
26
-
27
- The animation is pre-rendered into a list of frames. If the terminal
28
- cannot display unicode the frames are converted to plain ASCII.
29
- """
11
+ """A wrapper around rich.status.Status for displaying a spinner."""
30
12
 
31
- last_frame_idx = 0 # Class variable to store the last frame index
32
-
33
- def __init__(self, text: str, width: int = 7):
13
+ def __init__(self, text: str = "Waiting..."):
34
14
  self.text = text
35
- self.start_time = time.time()
36
- self.last_update = 0.0
37
- self.visible = False
38
- self.is_tty = sys.stdout.isatty()
39
15
  self.console = Console()
40
-
41
- # Pre-render the animation frames using pure ASCII so they will
42
- # always display, even on very limited terminals.
43
- ascii_frames = [
44
- "#= ", # C1 C2 space(8)
45
- "=# ", # C2 C1 space(8)
46
- " =# ", # space(1) C2 C1 space(7)
47
- " =# ", # space(2) C2 C1 space(6)
48
- " =# ", # space(3) C2 C1 space(5)
49
- " =# ", # space(4) C2 C1 space(4)
50
- " =# ", # space(5) C2 C1 space(3)
51
- " =# ", # space(6) C2 C1 space(2)
52
- " =# ", # space(7) C2 C1 space(1)
53
- " =#", # space(8) C2 C1
54
- " #=", # space(8) C1 C2
55
- " #= ", # space(7) C1 C2 space(1)
56
- " #= ", # space(6) C1 C2 space(2)
57
- " #= ", # space(5) C1 C2 space(3)
58
- " #= ", # space(4) C1 C2 space(4)
59
- " #= ", # space(3) C1 C2 space(5)
60
- " #= ", # space(2) C1 C2 space(6)
61
- " #= ", # space(1) C1 C2 space(7)
62
- ]
63
-
64
- self.unicode_palette = "░█"
65
- xlate_from, xlate_to = ("=#", self.unicode_palette)
66
-
67
- # If unicode is supported, swap the ASCII chars for nicer glyphs.
68
- if self._supports_unicode():
69
- translation_table = str.maketrans(xlate_from, xlate_to)
70
- frames = [f.translate(translation_table) for f in ascii_frames]
71
- self.scan_char = xlate_to[xlate_from.find("#")]
72
- else:
73
- frames = ascii_frames
74
- self.scan_char = "#"
75
-
76
- # Bounce the scanner back and forth.
77
- self.frames = frames
78
- self.frame_idx = Spinner.last_frame_idx # Initialize from class variable
79
- self.width = len(frames[0]) - 2 # number of chars between the brackets
80
- self.animation_len = len(frames[0])
81
- self.last_display_len = 0 # Length of the last spinner line (frame + text)
82
-
83
- def _supports_unicode(self) -> bool:
84
- if not self.is_tty:
85
- return False
86
- try:
87
- out = self.unicode_palette
88
- out += "\b" * len(self.unicode_palette)
89
- out += " " * len(self.unicode_palette)
90
- out += "\b" * len(self.unicode_palette)
91
- sys.stdout.write(out)
92
- sys.stdout.flush()
93
- return True
94
- except UnicodeEncodeError:
95
- return False
96
- except Exception:
97
- return False
98
-
99
- def _next_frame(self) -> str:
100
- frame = self.frames[self.frame_idx]
101
- self.frame_idx = (self.frame_idx + 1) % len(self.frames)
102
- Spinner.last_frame_idx = self.frame_idx # Update class variable
103
- return frame
104
-
105
- def step(self, text: str = None) -> None:
106
- if text is not None:
107
- self.text = text
108
-
109
- if not self.is_tty:
110
- return
111
-
112
- now = time.time()
113
- if not self.visible and now - self.start_time >= 0.5:
114
- self.visible = True
115
- self.last_update = 0.0
116
- if self.is_tty:
117
- self.console.show_cursor(False)
118
-
119
- if not self.visible or now - self.last_update < 0.1:
120
- return
121
-
122
- self.last_update = now
123
- frame_str = self._next_frame()
124
-
125
- # Determine the maximum width for the spinner line
126
- # Subtract 2 as requested, to leave a margin or prevent cursor wrapping issues
127
- max_spinner_width = self.console.width - 2
128
- if max_spinner_width < 0: # Handle extremely narrow terminals
129
- max_spinner_width = 0
130
-
131
- current_text_payload = f" {self.text}"
132
- line_to_display = f"{frame_str}{current_text_payload}"
133
-
134
- # Truncate the line if it's too long for the console width
135
- if len(line_to_display) > max_spinner_width:
136
- line_to_display = line_to_display[:max_spinner_width]
137
-
138
- len_line_to_display = len(line_to_display)
139
-
140
- # Calculate padding to clear any remnants from a longer previous line
141
- padding_to_clear = " " * max(0, self.last_display_len - len_line_to_display)
142
-
143
- # Write the spinner frame, text, and any necessary clearing spaces
144
- sys.stdout.write(f"\r{line_to_display}{padding_to_clear}")
145
- self.last_display_len = len_line_to_display
146
-
147
- # Calculate number of backspaces to position cursor at the scanner character
148
- scan_char_abs_pos = frame_str.find(self.scan_char)
149
-
150
- # Total characters written to the line (frame + text + padding)
151
- total_chars_written_on_line = len_line_to_display + len(padding_to_clear)
152
-
153
- # num_backspaces will be non-positive if scan_char_abs_pos is beyond
154
- # total_chars_written_on_line (e.g., if the scan char itself was truncated).
155
- # (e.g., if the scan char itself was truncated).
156
- # In such cases, (effectively) 0 backspaces are written,
157
- # and the cursor stays at the end of the line.
158
- num_backspaces = total_chars_written_on_line - scan_char_abs_pos
159
- sys.stdout.write("\b" * num_backspaces)
160
- sys.stdout.flush()
161
-
162
- def end(self) -> None:
163
- if self.visible and self.is_tty:
164
- clear_len = self.last_display_len # Use the length of the last displayed content
165
- sys.stdout.write("\r" + " " * clear_len + "\r")
166
- sys.stdout.flush()
167
- self.console.show_cursor(True)
168
- self.visible = False
169
-
170
-
171
- class WaitingSpinner:
172
- """Background spinner that can be started/stopped safely."""
173
-
174
- def __init__(self, text: str = "Waiting for LLM", delay: float = 0.15):
175
- self.spinner = Spinner(text)
176
- self.delay = delay
177
- self._stop_event = threading.Event()
178
- self._thread = threading.Thread(target=self._spin, daemon=True)
179
-
180
- def _spin(self):
181
- while not self._stop_event.is_set():
182
- self.spinner.step()
183
- time.sleep(self.delay)
184
- self.spinner.end()
185
-
186
- def start(self):
187
- """Start the spinner in a background thread."""
188
- if not self._thread.is_alive():
189
- self._thread.start()
190
-
191
- def stop(self):
192
- """Request the spinner to stop and wait briefly for the thread to exit."""
193
- self._stop_event.set()
194
- if self._thread.is_alive():
195
- self._thread.join(timeout=self.delay)
196
- self.spinner.end()
16
+ self.status = None
17
+
18
+ def step(self, message=None):
19
+ """Start the spinner or update its text."""
20
+ if self.status is None:
21
+ self.status = self.console.status(self.text, spinner="dots2")
22
+ self.status.start()
23
+ elif message:
24
+ self.status.update(message)
25
+
26
+ def end(self):
27
+ """Stop the spinner."""
28
+ if self.status:
29
+ self.status.stop()
30
+ self.status = None
197
31
 
198
32
  # Allow use as a context-manager
199
33
  def __enter__(self):
200
- self.start()
34
+ self.step()
201
35
  return self
202
36
 
203
37
  def __exit__(self, exc_type, exc_val, exc_tb):
204
- self.stop()
205
-
206
-
207
- def main():
208
- spinner = Spinner("Running spinner...")
209
- try:
210
- for _ in range(100):
211
- time.sleep(0.15)
212
- spinner.step()
213
- print("Success!")
214
- except KeyboardInterrupt:
215
- print("\nInterrupted by user.")
216
- finally:
217
- spinner.end()
218
-
219
-
220
- if __name__ == "__main__":
221
- main()
38
+ self.end()