vibecore 0.2.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 vibecore might be problematic. Click here for more details.

Files changed (63) hide show
  1. vibecore/__init__.py +0 -0
  2. vibecore/agents/default.py +79 -0
  3. vibecore/agents/prompts.py +12 -0
  4. vibecore/agents/task_agent.py +66 -0
  5. vibecore/cli.py +150 -0
  6. vibecore/context.py +24 -0
  7. vibecore/handlers/__init__.py +5 -0
  8. vibecore/handlers/stream_handler.py +231 -0
  9. vibecore/main.py +506 -0
  10. vibecore/main.tcss +0 -0
  11. vibecore/mcp/__init__.py +6 -0
  12. vibecore/mcp/manager.py +167 -0
  13. vibecore/mcp/server_wrapper.py +109 -0
  14. vibecore/models/__init__.py +5 -0
  15. vibecore/models/anthropic.py +239 -0
  16. vibecore/prompts/common_system_prompt.txt +64 -0
  17. vibecore/py.typed +0 -0
  18. vibecore/session/__init__.py +5 -0
  19. vibecore/session/file_lock.py +127 -0
  20. vibecore/session/jsonl_session.py +236 -0
  21. vibecore/session/loader.py +193 -0
  22. vibecore/session/path_utils.py +81 -0
  23. vibecore/settings.py +161 -0
  24. vibecore/tools/__init__.py +1 -0
  25. vibecore/tools/base.py +27 -0
  26. vibecore/tools/file/__init__.py +5 -0
  27. vibecore/tools/file/executor.py +282 -0
  28. vibecore/tools/file/tools.py +184 -0
  29. vibecore/tools/file/utils.py +78 -0
  30. vibecore/tools/python/__init__.py +1 -0
  31. vibecore/tools/python/backends/__init__.py +1 -0
  32. vibecore/tools/python/backends/terminal_backend.py +58 -0
  33. vibecore/tools/python/helpers.py +80 -0
  34. vibecore/tools/python/manager.py +208 -0
  35. vibecore/tools/python/tools.py +27 -0
  36. vibecore/tools/shell/__init__.py +5 -0
  37. vibecore/tools/shell/executor.py +223 -0
  38. vibecore/tools/shell/tools.py +156 -0
  39. vibecore/tools/task/__init__.py +5 -0
  40. vibecore/tools/task/executor.py +51 -0
  41. vibecore/tools/task/tools.py +51 -0
  42. vibecore/tools/todo/__init__.py +1 -0
  43. vibecore/tools/todo/manager.py +31 -0
  44. vibecore/tools/todo/models.py +36 -0
  45. vibecore/tools/todo/tools.py +111 -0
  46. vibecore/utils/__init__.py +5 -0
  47. vibecore/utils/text.py +28 -0
  48. vibecore/widgets/core.py +332 -0
  49. vibecore/widgets/core.tcss +63 -0
  50. vibecore/widgets/expandable.py +121 -0
  51. vibecore/widgets/expandable.tcss +69 -0
  52. vibecore/widgets/info.py +25 -0
  53. vibecore/widgets/info.tcss +17 -0
  54. vibecore/widgets/messages.py +232 -0
  55. vibecore/widgets/messages.tcss +85 -0
  56. vibecore/widgets/tool_message_factory.py +121 -0
  57. vibecore/widgets/tool_messages.py +483 -0
  58. vibecore/widgets/tool_messages.tcss +289 -0
  59. vibecore-0.2.0.dist-info/METADATA +407 -0
  60. vibecore-0.2.0.dist-info/RECORD +63 -0
  61. vibecore-0.2.0.dist-info/WHEEL +4 -0
  62. vibecore-0.2.0.dist-info/entry_points.txt +2 -0
  63. vibecore-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """File-related tools for Vibecore agents."""
2
+
3
+ from .tools import edit, multi_edit, read, write
4
+
5
+ __all__ = ["edit", "multi_edit", "read", "write"]
@@ -0,0 +1,282 @@
1
+ """File reading execution logic."""
2
+
3
+ from typing import Any
4
+
5
+ from .utils import PathValidationError, format_line_with_number, validate_file_path
6
+
7
+
8
+ async def read_file(file_path: str, offset: int | None = None, limit: int | None = None) -> str:
9
+ """Read a file and return its contents in cat -n format.
10
+
11
+ Args:
12
+ file_path: The path to the file to read
13
+ offset: The line number to start reading from (1-based)
14
+ limit: The maximum number of lines to read
15
+
16
+ Returns:
17
+ The file contents with line numbers, or an error message
18
+ """
19
+ try:
20
+ # Validate the file path
21
+ validated_path = validate_file_path(file_path)
22
+
23
+ # Check if file exists
24
+ if not validated_path.exists():
25
+ return f"Error: File does not exist: {file_path}"
26
+
27
+ # Check if it's a file (not a directory)
28
+ if not validated_path.is_file():
29
+ return f"Error: Path is not a file: {file_path}"
30
+
31
+ # Check for Jupyter notebooks
32
+ if validated_path.suffix == ".ipynb":
33
+ return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookRead tool instead"
34
+
35
+ # Set defaults
36
+ if offset is None:
37
+ offset = 1 # Line numbers start at 1
38
+ if limit is None:
39
+ limit = 2000
40
+
41
+ # Validate offset and limit
42
+ if offset < 1:
43
+ return "Error: Offset must be 1 or greater (line numbers start at 1)"
44
+ if limit < 1:
45
+ return "Error: Limit must be 1 or greater"
46
+
47
+ # Read the file
48
+ try:
49
+ with validated_path.open("r", encoding="utf-8", errors="replace") as f:
50
+ # Skip to the offset
51
+ for _ in range(offset - 1):
52
+ line = f.readline()
53
+ if not line:
54
+ return f"Error: Offset {offset} is beyond the end of file"
55
+
56
+ # Read the requested lines
57
+ lines = []
58
+ line_num = offset
59
+ for _ in range(limit):
60
+ line = f.readline()
61
+ if not line:
62
+ break
63
+ lines.append(format_line_with_number(line_num, line))
64
+ line_num += 1
65
+
66
+ # Handle empty file or no content in range
67
+ if not lines:
68
+ if offset == 1:
69
+ # Empty file
70
+ return "<system-reminder>Warning: The file exists but has empty contents</system-reminder>"
71
+ else:
72
+ return f"Error: No content found starting from line {offset}"
73
+
74
+ return "\n".join(lines)
75
+
76
+ except PermissionError:
77
+ return f"Error: Permission denied reading file: {file_path}"
78
+ except Exception as e:
79
+ return f"Error reading file: {e}"
80
+
81
+ except PathValidationError as e:
82
+ return f"Error: {e}"
83
+ except Exception as e:
84
+ return f"Error: Unexpected error reading file: {e}"
85
+
86
+
87
+ async def edit_file(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
88
+ """Edit a file by replacing strings.
89
+
90
+ Args:
91
+ file_path: The path to the file to edit
92
+ old_string: The text to replace
93
+ new_string: The text to replace it with
94
+ replace_all: Replace all occurrences (default: False)
95
+
96
+ Returns:
97
+ Success message or error message
98
+ """
99
+ try:
100
+ # Validate the file path
101
+ validated_path = validate_file_path(file_path)
102
+
103
+ # Check if file exists
104
+ if not validated_path.exists():
105
+ return f"Error: File does not exist: {file_path}"
106
+
107
+ # Check if it's a file (not a directory)
108
+ if not validated_path.is_file():
109
+ return f"Error: Path is not a file: {file_path}"
110
+
111
+ # Check for Jupyter notebooks
112
+ if validated_path.suffix == ".ipynb":
113
+ return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookEdit tool instead"
114
+
115
+ # Validate old_string != new_string
116
+ if old_string == new_string:
117
+ return "Error: old_string and new_string cannot be the same"
118
+
119
+ # Read the file
120
+ try:
121
+ with validated_path.open("r", encoding="utf-8") as f:
122
+ content = f.read()
123
+
124
+ # Check if old_string exists in the file
125
+ occurrences = content.count(old_string)
126
+ if occurrences == 0:
127
+ return f"Error: String not found in file: {old_string!r}"
128
+
129
+ # Check uniqueness if not replace_all
130
+ if not replace_all and occurrences > 1:
131
+ return (
132
+ f"Error: Multiple occurrences ({occurrences}) of old_string found. "
133
+ f"Use replace_all=True or provide more context to make the string unique"
134
+ )
135
+
136
+ # Perform the replacement
137
+ if replace_all:
138
+ new_content = content.replace(old_string, new_string)
139
+ replaced = occurrences
140
+ else:
141
+ new_content = content.replace(old_string, new_string, 1)
142
+ replaced = 1
143
+
144
+ # Write the file back
145
+ with validated_path.open("w", encoding="utf-8") as f:
146
+ f.write(new_content)
147
+
148
+ return f"Successfully replaced {replaced} occurrence(s) in {file_path}"
149
+
150
+ except PermissionError:
151
+ return f"Error: Permission denied accessing file: {file_path}"
152
+ except Exception as e:
153
+ return f"Error editing file: {e}"
154
+
155
+ except PathValidationError as e:
156
+ return f"Error: {e}"
157
+ except Exception as e:
158
+ return f"Error: Unexpected error editing file: {e}"
159
+
160
+
161
+ async def multi_edit_file(file_path: str, edits: list[dict[str, Any]]) -> str:
162
+ """Edit a file by applying multiple replacements sequentially.
163
+
164
+ Args:
165
+ file_path: The path to the file to edit
166
+ edits: List of edit operations, each containing old_string, new_string, and optional replace_all
167
+
168
+ Returns:
169
+ Success message or error message
170
+ """
171
+ try:
172
+ # Validate the file path
173
+ validated_path = validate_file_path(file_path)
174
+
175
+ # Check if file exists
176
+ if not validated_path.exists():
177
+ return f"Error: File does not exist: {file_path}"
178
+
179
+ # Check if it's a file (not a directory)
180
+ if not validated_path.is_file():
181
+ return f"Error: Path is not a file: {file_path}"
182
+
183
+ # Check for Jupyter notebooks
184
+ if validated_path.suffix == ".ipynb":
185
+ return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookEdit tool instead"
186
+
187
+ # Read the file
188
+ try:
189
+ with validated_path.open("r", encoding="utf-8") as f:
190
+ content = f.read()
191
+
192
+ # Apply each edit sequentially
193
+ total_replacements = 0
194
+ for i, edit in enumerate(edits):
195
+ old_string = str(edit["old_string"])
196
+ new_string = str(edit["new_string"])
197
+ replace_all = bool(edit.get("replace_all", False))
198
+
199
+ # Validate old_string != new_string
200
+ if old_string == new_string:
201
+ return f"Error: Edit {i + 1}: old_string and new_string cannot be the same"
202
+
203
+ # Check if old_string exists in the current content
204
+ occurrences = content.count(old_string)
205
+ if occurrences == 0:
206
+ return f"Error: Edit {i + 1}: String not found: {old_string!r}"
207
+
208
+ # Check uniqueness if not replace_all
209
+ if not replace_all and occurrences > 1:
210
+ return (
211
+ f"Error: Edit {i + 1}: Multiple occurrences ({occurrences}) of old_string found. "
212
+ f"Use replace_all=True or provide more context to make the string unique"
213
+ )
214
+
215
+ # Perform the replacement
216
+ if replace_all:
217
+ content = content.replace(old_string, new_string)
218
+ total_replacements += occurrences
219
+ else:
220
+ content = content.replace(old_string, new_string, 1)
221
+ total_replacements += 1
222
+
223
+ # Write the file back
224
+ with validated_path.open("w", encoding="utf-8") as f:
225
+ f.write(content)
226
+
227
+ return (
228
+ f"Successfully applied {len(edits)} edits with {total_replacements} total replacements in {file_path}"
229
+ )
230
+
231
+ except PermissionError:
232
+ return f"Error: Permission denied accessing file: {file_path}"
233
+ except Exception as e:
234
+ return f"Error editing file: {e}"
235
+
236
+ except PathValidationError as e:
237
+ return f"Error: {e}"
238
+ except Exception as e:
239
+ return f"Error: Unexpected error editing file: {e}"
240
+
241
+
242
+ async def write_file(file_path: str, content: str) -> str:
243
+ """Write content to a file.
244
+
245
+ Args:
246
+ file_path: The path to the file to write
247
+ content: The content to write to the file
248
+
249
+ Returns:
250
+ Success message or error message
251
+ """
252
+ try:
253
+ # Validate the file path
254
+ validated_path = validate_file_path(file_path)
255
+
256
+ # Check if it's a directory
257
+ if validated_path.exists() and validated_path.is_dir():
258
+ return f"Error: Path is a directory: {file_path}"
259
+
260
+ # Check for Jupyter notebooks
261
+ if validated_path.suffix == ".ipynb":
262
+ return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookEdit tool instead"
263
+
264
+ # Create parent directories if they don't exist
265
+ validated_path.parent.mkdir(parents=True, exist_ok=True)
266
+
267
+ # Write the file
268
+ try:
269
+ with validated_path.open("w", encoding="utf-8") as f:
270
+ f.write(content)
271
+
272
+ return f"Successfully wrote {len(content)} bytes to {file_path}"
273
+
274
+ except PermissionError:
275
+ return f"Error: Permission denied writing file: {file_path}"
276
+ except Exception as e:
277
+ return f"Error writing file: {e}"
278
+
279
+ except PathValidationError as e:
280
+ return f"Error: {e}"
281
+ except Exception as e:
282
+ return f"Error: Unexpected error writing file: {e}"
@@ -0,0 +1,184 @@
1
+ """File reading tool for Vibecore agents."""
2
+
3
+ from agents import RunContextWrapper, function_tool
4
+ from pydantic import BaseModel
5
+
6
+ from vibecore.context import VibecoreContext
7
+
8
+ from .executor import edit_file, multi_edit_file, read_file, write_file
9
+
10
+
11
+ class EditOperation(BaseModel):
12
+ """Represents a single edit operation."""
13
+
14
+ old_string: str
15
+ new_string: str
16
+ replace_all: bool = False
17
+
18
+
19
+ @function_tool
20
+ async def read(
21
+ ctx: RunContextWrapper[VibecoreContext],
22
+ file_path: str,
23
+ offset: int | None = None,
24
+ limit: int | None = None,
25
+ ) -> str:
26
+ """Reads a file from the local filesystem. You can access any file directly by using this tool.
27
+ Assume this tool is able to read all files on the machine. If the User provides a path to a file assume
28
+ that path is valid. It is okay to read a file that does not exist; an error will be returned.
29
+
30
+ Usage:
31
+ - The file_path parameter must be an absolute path, not a relative path
32
+ - By default, it reads up to 2000 lines starting from the beginning of the file
33
+ - You can optionally specify a line offset and limit (especially handy for long files), but it's
34
+ recommended to read the whole file by not providing these parameters
35
+ - Any lines longer than 2000 characters will be truncated
36
+ - Results are returned using cat -n format, with line numbers starting at 1
37
+ - For Jupyter notebooks (.ipynb files), use the NotebookRead instead
38
+ - You have the capability to call multiple tools in a single response. It is always better to
39
+ speculatively read multiple files as a batch that are potentially useful.
40
+ - If you read a file that exists but has empty contents you will receive a system reminder warning
41
+ in place of file contents.
42
+
43
+ Args:
44
+ ctx: The run context wrapper
45
+ file_path: The absolute path to the file to read
46
+ offset: The line number to start reading from. Only provide if the file is too large to read at once
47
+ limit: The number of lines to read. Only provide if the file is too large to read at once.
48
+
49
+ Returns:
50
+ The file contents with line numbers in cat -n format, or an error message
51
+ """
52
+ return await read_file(file_path, offset, limit)
53
+
54
+
55
+ @function_tool
56
+ async def edit(
57
+ ctx: RunContextWrapper[VibecoreContext],
58
+ file_path: str,
59
+ old_string: str,
60
+ new_string: str,
61
+ replace_all: bool = False,
62
+ ) -> str:
63
+ """Performs exact string replacements in files.
64
+
65
+ Usage:
66
+ - You must use your `Read` tool at least once in the conversation before editing. This tool will error if you
67
+ attempt an edit without reading the file.
68
+ - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears
69
+ AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after
70
+ that tab is the actual file content to match. Never include any part of the line number prefix in the
71
+ old_string or new_string.
72
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
73
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
74
+ - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more
75
+ surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
76
+ - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to
77
+ rename a variable for instance.
78
+
79
+ Args:
80
+ ctx: The run context wrapper
81
+ file_path: The absolute path to the file to modify
82
+ old_string: The text to replace
83
+ new_string: The text to replace it with (must be different from old_string)
84
+ replace_all: Replace all occurences of old_string (default false)
85
+
86
+ Returns:
87
+ Success message or error message
88
+ """
89
+ return await edit_file(file_path, old_string, new_string, replace_all)
90
+
91
+
92
+ @function_tool
93
+ async def multi_edit(
94
+ ctx: RunContextWrapper[VibecoreContext],
95
+ file_path: str,
96
+ edits: list[EditOperation],
97
+ ) -> str:
98
+ """This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit
99
+ tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit
100
+ tool when you need to make multiple edits to the same file.
101
+
102
+ Before using this tool:
103
+
104
+ 1. Use the Read tool to understand the file's contents and context
105
+ 2. Verify the directory path is correct
106
+
107
+ To make multiple file edits, provide the following:
108
+ 1. file_path: The absolute path to the file to modify (must be absolute, not relative)
109
+ 2. edits: An array of edit operations to perform, where each edit contains:
110
+ - old_string: The text to replace (must match the file contents exactly, including all whitespace and
111
+ indentation)
112
+ - new_string: The edited text to replace the old_string
113
+ - replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
114
+
115
+ IMPORTANT:
116
+ - All edits are applied in sequence, in the order they are provided
117
+ - Each edit operates on the result of the previous edit
118
+ - All edits must be valid for the operation to succeed - if any edit fails, none will be applied
119
+ - This tool is ideal when you need to make several changes to different parts of the same file
120
+ - For Jupyter notebooks (.ipynb files), use the NotebookEdit instead
121
+
122
+ CRITICAL REQUIREMENTS:
123
+ 1. All edits follow the same requirements as the single Edit tool
124
+ 2. The edits are atomic - either all succeed or none are applied
125
+ 3. Plan your edits carefully to avoid conflicts between sequential operations
126
+
127
+ WARNING:
128
+ - The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
129
+ - The tool will fail if edits.old_string and edits.new_string are the same
130
+ - Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are
131
+ trying to find
132
+
133
+ When making edits:
134
+ - Ensure all edits result in idiomatic, correct code
135
+ - Do not leave the code in a broken state
136
+ - Always use absolute file paths (starting with /)
137
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
138
+ - Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to
139
+ rename a variable for instance.
140
+
141
+ If you want to create a new file, use:
142
+ - A new file path, including dir name if needed
143
+ - First edit: empty old_string and the new file's contents as new_string
144
+ - Subsequent edits: normal edit operations on the created content
145
+
146
+ Args:
147
+ ctx: The run context wrapper
148
+ file_path: The absolute path to the file to modify
149
+ edits: Array of edit operations to perform sequentially on the file
150
+
151
+ Returns:
152
+ Success message or error message
153
+ """
154
+ # Convert EditOperation objects to dictionaries
155
+ edit_dicts = [edit.model_dump() for edit in edits]
156
+ return await multi_edit_file(file_path, edit_dicts)
157
+
158
+
159
+ @function_tool
160
+ async def write(
161
+ ctx: RunContextWrapper[VibecoreContext],
162
+ file_path: str,
163
+ content: str,
164
+ ) -> str:
165
+ """Writes a file to the local filesystem.
166
+
167
+ Usage:
168
+ - This tool will overwrite the existing file if there is one at the provided path.
169
+ - If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail
170
+ if you did not read the file first.
171
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
172
+ - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if
173
+ explicitly requested by the User.
174
+ - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
175
+
176
+ Args:
177
+ ctx: The run context wrapper
178
+ file_path: The absolute path to the file to write (must be absolute, not relative)
179
+ content: The content to write to the file
180
+
181
+ Returns:
182
+ Success message or error message
183
+ """
184
+ return await write_file(file_path, content)
@@ -0,0 +1,78 @@
1
+ """Path validation utilities for file tools."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class PathValidationError(Exception):
7
+ """Raised when a path validation fails."""
8
+
9
+ pass
10
+
11
+
12
+ def validate_file_path(file_path: str, base_dir: Path | None = None) -> Path:
13
+ """Validate and resolve a file path, ensuring it's within the allowed directory.
14
+
15
+ Args:
16
+ file_path: The file path to validate (can be relative or absolute)
17
+ base_dir: The base directory to restrict access to (defaults to CWD)
18
+
19
+ Returns:
20
+ The validated absolute Path object
21
+
22
+ Raises:
23
+ PathValidationError: If the path is invalid or outside the allowed directory
24
+ """
25
+ if base_dir is None:
26
+ base_dir = Path.cwd()
27
+
28
+ base_dir = base_dir.resolve()
29
+
30
+ try:
31
+ # Convert to Path object
32
+ path = Path(file_path)
33
+
34
+ # If relative, make it absolute relative to base_dir
35
+ if not path.is_absolute():
36
+ path = base_dir / path
37
+
38
+ # Resolve to get the canonical path (resolves symlinks, .., etc)
39
+ resolved_path = path.resolve()
40
+
41
+ # Check if the resolved path is within the base directory
42
+ # This prevents directory traversal attacks
43
+ try:
44
+ resolved_path.relative_to(base_dir)
45
+ except ValueError:
46
+ raise PathValidationError(
47
+ f"Path '{file_path}' is outside the allowed directory. "
48
+ f"Access is restricted to '{base_dir}' and its subdirectories."
49
+ ) from None
50
+
51
+ return resolved_path
52
+
53
+ except Exception as e:
54
+ if isinstance(e, PathValidationError):
55
+ raise
56
+ raise PathValidationError(f"Invalid path '{file_path}': {e}") from e
57
+
58
+
59
+ def format_line_with_number(line_num: int, line: str, max_length: int = 2000) -> str:
60
+ """Format a line with line number in cat -n style.
61
+
62
+ Args:
63
+ line_num: The line number (1-based)
64
+ line: The line content
65
+ max_length: Maximum length for the line content
66
+
67
+ Returns:
68
+ Formatted line with line number
69
+ """
70
+ # Truncate line if too long
71
+ if len(line) > max_length:
72
+ line = line[:max_length] + "... (truncated)"
73
+
74
+ # Remove trailing newline for consistent formatting
75
+ line = line.rstrip("\n")
76
+
77
+ # Format with right-aligned line number (6 spaces wide) followed by tab
78
+ return f"{line_num:6d}\t{line}"
@@ -0,0 +1 @@
1
+ """Python code execution tools."""
@@ -0,0 +1 @@
1
+ """Matplotlib backends for terminal rendering."""
@@ -0,0 +1,58 @@
1
+ """Terminal backend for matplotlib that captures images for later display."""
2
+
3
+ from io import BytesIO
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from matplotlib.backend_bases import FigureManagerBase # type: ignore[import-not-found]
8
+ from matplotlib.backends.backend_agg import FigureCanvasAgg # type: ignore[import-not-found]
9
+ else:
10
+ try:
11
+ from matplotlib.backend_bases import FigureManagerBase
12
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
13
+ except ImportError:
14
+ # Create dummy classes if matplotlib is not installed
15
+ FigureManagerBase = object # type: ignore[misc,assignment]
16
+ FigureCanvasAgg = object # type: ignore[misc,assignment]
17
+
18
+ __all__ = ["FigureCanvas", "FigureManager", "clear_captured_images", "get_captured_images"]
19
+
20
+ # Global list to store captured images
21
+ _captured_images: list[bytes] = []
22
+
23
+
24
+ class TerminalImageFigureManager(FigureManagerBase):
25
+ """Figure manager that captures plots for later display."""
26
+
27
+ def show(self):
28
+ global _captured_images
29
+
30
+ # Save figure to BytesIO buffer
31
+ buf = BytesIO()
32
+ self.canvas.figure.savefig(buf, format="png", bbox_inches="tight", dpi=150)
33
+ buf.seek(0)
34
+
35
+ # Store the image buffer in the global list
36
+ _captured_images.append(buf.getvalue())
37
+
38
+
39
+ class TerminalImageFigureCanvas(FigureCanvasAgg):
40
+ """Figure canvas for terminal image backend."""
41
+
42
+ manager_class = TerminalImageFigureManager # type: ignore[assignment]
43
+
44
+
45
+ # Provide the standard names that matplotlib is expecting
46
+ FigureCanvas = TerminalImageFigureCanvas
47
+ FigureManager = TerminalImageFigureManager
48
+
49
+
50
+ def get_captured_images() -> list[bytes]:
51
+ """Get the list of captured images."""
52
+ return _captured_images.copy()
53
+
54
+
55
+ def clear_captured_images() -> None:
56
+ """Clear the list of captured images."""
57
+ global _captured_images
58
+ _captured_images.clear()