vtx-coding-agent 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/tools/edit.py ADDED
@@ -0,0 +1,261 @@
1
+ import asyncio
2
+ import difflib
3
+ from pathlib import Path
4
+
5
+ import aiofiles
6
+ from pydantic import BaseModel, Field
7
+ from rich.markup import escape
8
+
9
+ from vtx import config
10
+ from vtx.diff_display import DIFF_BG_PAD_MARKER, blend_hex
11
+
12
+ from ..core.types import FileChanges
13
+ from ._tool_utils import shorten_path
14
+ from .base import BaseTool, ToolResult
15
+
16
+ CONTEXT_LINES = 4
17
+
18
+
19
+ class EditParams(BaseModel):
20
+ path: str = Field(description="Absolute path of the file to edit")
21
+ old_string: str = Field(description="The text to replace")
22
+ new_string: str = Field(
23
+ description="The text to replace it with (must be different from old_string)"
24
+ )
25
+ replace_all: bool = Field(
26
+ description="Replace all occurrences of old_string (default false)", default=False
27
+ )
28
+
29
+
30
+ def _ellipsis(line_num_width: int, skipped: int) -> str:
31
+ return f" {''.rjust(line_num_width)} \u22ef {skipped} lines \u22ef" # ⋯ N lines ⋯
32
+
33
+
34
+ def generate_diff(
35
+ old_content: str, new_content: str, context_lines: int = CONTEXT_LINES
36
+ ) -> tuple[str, int, int]:
37
+ """
38
+ Generate a diff with line numbers and context.
39
+
40
+ Returns:
41
+ tuple: (diff_string, added_count, removed_count)
42
+
43
+ Format:
44
+ " 42 context line" (space, num, three spaces = empty change marker)
45
+ " 42 - removed line" (space, num, space-minus-space = removed)
46
+ " 42 + added line" (space, num, space-plus-space = added)
47
+ " ⋯ N lines ⋯" (ellipsis = skipped lines with count)
48
+ """
49
+ old_lines = old_content.splitlines()
50
+ new_lines = new_content.splitlines()
51
+
52
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
53
+ opcodes = matcher.get_opcodes()
54
+
55
+ max_line_num = max(len(old_lines), len(new_lines))
56
+ line_num_width = len(str(max_line_num))
57
+
58
+ def _num(n: int) -> str:
59
+ return str(n).rjust(line_num_width)
60
+
61
+ output: list[str] = []
62
+ added, removed = 0, 0
63
+ last_was_change = False
64
+
65
+ for i, (tag, i1, i2, j1, j2) in enumerate(opcodes):
66
+ if tag == "equal":
67
+ equal_lines = old_lines[i1:i2]
68
+ next_is_change = i < len(opcodes) - 1 and opcodes[i + 1][0] != "equal"
69
+
70
+ if last_was_change or next_is_change:
71
+ if last_was_change and next_is_change:
72
+ if len(equal_lines) > context_lines * 2:
73
+ for idx, line in enumerate(equal_lines[:context_lines]):
74
+ line_num = i1 + idx + 1
75
+ output.append(f" {_num(line_num)} {line}")
76
+ skipped = len(equal_lines) - context_lines * 2
77
+ output.append(_ellipsis(line_num_width, skipped))
78
+ for idx, line in enumerate(equal_lines[-context_lines:]):
79
+ line_num = i1 + len(equal_lines) - context_lines + idx + 1
80
+ output.append(f" {_num(line_num)} {line}")
81
+ else:
82
+ for idx, line in enumerate(equal_lines):
83
+ line_num = i1 + idx + 1
84
+ output.append(f" {_num(line_num)} {line}")
85
+ elif last_was_change:
86
+ if len(equal_lines) > context_lines:
87
+ for idx, line in enumerate(equal_lines[:context_lines]):
88
+ line_num = i1 + idx + 1
89
+ output.append(f" {_num(line_num)} {line}")
90
+ skipped = len(equal_lines) - context_lines
91
+ output.append(_ellipsis(line_num_width, skipped))
92
+ else:
93
+ for idx, line in enumerate(equal_lines):
94
+ line_num = i1 + idx + 1
95
+ output.append(f" {_num(line_num)} {line}")
96
+ else:
97
+ if len(equal_lines) > context_lines:
98
+ skipped = len(equal_lines) - context_lines
99
+ output.append(_ellipsis(line_num_width, skipped))
100
+ for idx, line in enumerate(equal_lines[-context_lines:]):
101
+ line_num = i1 + len(equal_lines) - context_lines + idx + 1
102
+ output.append(f" {_num(line_num)} {line}")
103
+ else:
104
+ for idx, line in enumerate(equal_lines):
105
+ line_num = i1 + idx + 1
106
+ output.append(f" {_num(line_num)} {line}")
107
+
108
+ last_was_change = False
109
+
110
+ elif tag == "replace":
111
+ for idx, line in enumerate(old_lines[i1:i2]):
112
+ line_num = i1 + idx + 1
113
+ output.append(f" {_num(line_num)} - {line}")
114
+ removed += 1
115
+ for idx, line in enumerate(new_lines[j1:j2]):
116
+ line_num = j1 + idx + 1
117
+ output.append(f" {_num(line_num)} + {line}")
118
+ added += 1
119
+ last_was_change = True
120
+
121
+ elif tag == "delete":
122
+ for idx, line in enumerate(old_lines[i1:i2]):
123
+ line_num = i1 + idx + 1
124
+ output.append(f" {_num(line_num)} - {line}")
125
+ removed += 1
126
+ last_was_change = True
127
+
128
+ elif tag == "insert":
129
+ for idx, line in enumerate(new_lines[j1:j2]):
130
+ line_num = j1 + idx + 1
131
+ output.append(f" {_num(line_num)} + {line}")
132
+ added += 1
133
+ last_was_change = True
134
+
135
+ return "\n".join(output), added, removed
136
+
137
+
138
+ def _parse_diff_line(line: str) -> tuple[str, str, str] | None:
139
+ """Parse a formatted diff line into (line_number_part, sign, content_part)."""
140
+ num_start = next((i for i, char in enumerate(line) if char.isdigit()), -1)
141
+ if num_start == -1:
142
+ return None
143
+
144
+ num_end = num_start
145
+ while num_end < len(line) and line[num_end].isdigit():
146
+ num_end += 1
147
+
148
+ sign_index = num_end + 1
149
+ if sign_index >= len(line):
150
+ return None
151
+
152
+ # Includes leading padding and the separator space after the line number.
153
+ line_number_part = line[:sign_index]
154
+ sign = line[sign_index]
155
+ content_part = line[sign_index + 1 :]
156
+ return line_number_part, sign, content_part
157
+
158
+
159
+ def format_diff_display(diff: str) -> str:
160
+ colors = config.ui.colors
161
+ lines = diff.split("\n")
162
+ formatted = []
163
+
164
+ bg_added = blend_hex(colors.diff_added, colors.bg)
165
+ bg_removed = blend_hex(colors.diff_removed, colors.bg)
166
+
167
+ for line in lines:
168
+ if not line:
169
+ continue
170
+
171
+ truncated = line[:200] + "\u2026" if len(line) > 203 else line # … ellipsis
172
+ parsed = _parse_diff_line(truncated)
173
+
174
+ if parsed and parsed[1] == "-":
175
+ line_num, sign, content_part = parsed
176
+ content = (
177
+ f" [{colors.dim}]{escape(line_num)}[/{colors.dim}]"
178
+ f"[{colors.diff_removed}]{sign}{escape(content_part)}[/{colors.diff_removed}]"
179
+ )
180
+ formatted.append(f"[on {bg_removed}]{content}{DIFF_BG_PAD_MARKER}[/]")
181
+ elif parsed and parsed[1] == "+":
182
+ line_num, sign, content_part = parsed
183
+ content = (
184
+ f" [{colors.dim}]{escape(line_num)}[/{colors.dim}]"
185
+ f"[{colors.diff_added}]{sign}{escape(content_part)}[/{colors.diff_added}]"
186
+ )
187
+ formatted.append(f"[on {bg_added}]{content}{DIFF_BG_PAD_MARKER}[/]")
188
+ elif "\u22ef" in line:
189
+ escaped = escape(truncated)
190
+ formatted.append(f"[{colors.dim}]{escaped}[/{colors.dim}]")
191
+ else:
192
+ escaped = escape(truncated)
193
+ formatted.append(f"[{colors.dim}] {escaped}[/{colors.dim}]")
194
+
195
+ return "\n".join(formatted)
196
+
197
+
198
+ class EditTool(BaseTool):
199
+ name = "edit"
200
+ tool_icon = "←"
201
+ params = EditParams
202
+ prompt_guidelines = ("Use edit for precise changes (NOT sed/awk)",)
203
+ description = (
204
+ "Edit a file by replacing exact text. The old_string must match exactly "
205
+ "(including whitespaces). Use this for precise, surgical edits."
206
+ )
207
+
208
+ def format_call(self, params: EditParams) -> str:
209
+ return shorten_path(params.path)
210
+
211
+ def format_preview(self, params: EditParams) -> str | None:
212
+ diff, _, _ = generate_diff(params.old_string, params.new_string)
213
+ return format_diff_display(diff)
214
+
215
+ async def execute(
216
+ self, params: EditParams, cancel_event: asyncio.Event | None = None
217
+ ) -> ToolResult:
218
+ file_path = Path(params.path)
219
+
220
+ if not file_path.exists():
221
+ msg = f"File not found: {file_path}"
222
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
223
+
224
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
225
+ content = await f.read()
226
+
227
+ if params.old_string not in content:
228
+ msg = "old_string not found in file"
229
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
230
+
231
+ if params.replace_all:
232
+ new_content = content.replace(params.old_string, params.new_string)
233
+ else:
234
+ new_content = content.replace(params.old_string, params.new_string, 1)
235
+
236
+ async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
237
+ await f.write(new_content)
238
+
239
+ diff, added, removed = generate_diff(content, new_content)
240
+ diff_display = format_diff_display(diff)
241
+
242
+ # Full diff for expanded view (no context truncation)
243
+ total_lines = max(content.count("\n"), new_content.count("\n")) + 1
244
+ diff_full, _, _ = generate_diff(content, new_content, context_lines=total_lines)
245
+ diff_full_display = format_diff_display(diff_full)
246
+
247
+ colors = config.ui.colors
248
+ result = f"Updated {file_path} +{added} -{removed}"
249
+ ui_summary = (
250
+ f"[{colors.diff_added}]+{added}[/{colors.diff_added}] "
251
+ f"[{colors.diff_removed}]-{removed}[/{colors.diff_removed}]"
252
+ )
253
+
254
+ return ToolResult(
255
+ success=True,
256
+ result=result,
257
+ ui_summary=ui_summary,
258
+ ui_details=diff_display,
259
+ ui_details_full=diff_full_display,
260
+ file_changes=FileChanges(path=str(file_path), added=added, removed=removed),
261
+ )
vtx/tools/find.py ADDED
@@ -0,0 +1,132 @@
1
+ import asyncio
2
+ import os
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ..core.types import ToolResult
7
+ from ..tools_manager import ensure_tool
8
+ from ._tool_utils import (
9
+ ToolCancelledError,
10
+ communicate_or_cancel,
11
+ shorten_path,
12
+ truncate_lines_by_bytes,
13
+ )
14
+ from .base import BaseTool
15
+
16
+ MAX_RESULTS = 100
17
+ MAX_OUTPUT_BYTES = 20 * 1024
18
+
19
+
20
+ class FindParams(BaseModel):
21
+ pattern: str = Field(
22
+ description="Glob pattern to match files, e.g. '*.py', '**/*.json', or 'src/**/*.spec.ts'"
23
+ )
24
+ path: str | None = Field(
25
+ description="Directory to search in (default: current directory)", default=None
26
+ )
27
+
28
+
29
+ class FindTool(BaseTool):
30
+ name = "find"
31
+ tool_icon = "*"
32
+ params = FindParams
33
+ mutating = False
34
+ prompt_guidelines = ("Use find to search for files by name/glob (NOT find or ls via bash)",)
35
+ description = (
36
+ "Search for files by glob pattern using fd. "
37
+ "Returns matching file paths relative to the search directory, "
38
+ "sorted by modification time."
39
+ f"Respects .gitignore. Truncated to {MAX_RESULTS} results."
40
+ )
41
+
42
+ def format_call(self, params: FindParams) -> str:
43
+ pattern = params.pattern.replace('"', '\\"')
44
+ parts = [f'"{pattern}"']
45
+ if params.path:
46
+ parts.append(f"in {shorten_path(params.path)}")
47
+ return " ".join(parts)
48
+
49
+ async def execute(
50
+ self, params: FindParams, cancel_event: asyncio.Event | None = None
51
+ ) -> ToolResult:
52
+ fd_path = await ensure_tool("fd", silent=True)
53
+ if not fd_path:
54
+ msg = "fd is not available and could not be downloaded"
55
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
56
+
57
+ search_path = params.path or os.getcwd()
58
+ if not os.path.isabs(search_path):
59
+ search_path = os.path.join(os.getcwd(), search_path)
60
+
61
+ if not os.path.exists(search_path):
62
+ msg = f"Path not found: {search_path}"
63
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
64
+
65
+ args = [
66
+ fd_path,
67
+ "--glob",
68
+ "--color=never",
69
+ "--hidden",
70
+ "--max-results",
71
+ str(MAX_RESULTS),
72
+ params.pattern,
73
+ search_path,
74
+ ]
75
+
76
+ proc = await asyncio.create_subprocess_exec(
77
+ *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
78
+ )
79
+
80
+ try:
81
+ stdout, stderr = await communicate_or_cancel(proc, cancel_event)
82
+ except ToolCancelledError:
83
+ return ToolResult(success=False, result="Search aborted")
84
+
85
+ exit_code = proc.returncode
86
+ output = stdout.decode("utf-8", errors="replace").strip()
87
+ error_output = stderr.decode("utf-8", errors="replace").strip()
88
+
89
+ if exit_code not in (0, 1) and not output:
90
+ msg = f"fd failed: {error_output}"
91
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
92
+
93
+ if not output:
94
+ return ToolResult(
95
+ success=True,
96
+ result="No files found matching pattern",
97
+ ui_summary="[dim]No files found[/dim]",
98
+ )
99
+
100
+ lines = [line.strip() for line in output.split("\n") if line.strip()]
101
+
102
+ # Relativize and collect mtime for sorting
103
+ files: list[tuple[str, float]] = []
104
+ for line in lines:
105
+ if line.startswith(search_path):
106
+ rel = line[len(search_path) :].lstrip(os.sep)
107
+ rel = rel if rel else line
108
+ else:
109
+ rel = os.path.relpath(line, search_path)
110
+ try:
111
+ mtime = os.path.getmtime(line)
112
+ except OSError:
113
+ mtime = 0.0
114
+ files.append((rel, mtime))
115
+
116
+ files.sort(key=lambda f: f[1], reverse=True)
117
+
118
+ relativized = [f[0] for f in files]
119
+ truncated = len(relativized) >= MAX_RESULTS
120
+
121
+ result_text, _ = truncate_lines_by_bytes(relativized, MAX_OUTPUT_BYTES)
122
+
123
+ if truncated:
124
+ result_text += (
125
+ f"\n\n[{MAX_RESULTS} results limit reached; "
126
+ "refine the pattern or path for more specific results]"
127
+ )
128
+
129
+ count = len(relativized)
130
+ display = f"[dim]({count} files)[/dim]"
131
+
132
+ return ToolResult(success=True, result=result_text, ui_summary=display)
vtx/tools/read.py ADDED
@@ -0,0 +1,238 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ import aiofiles
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ..core.types import ImageContent
9
+ from ..tools_manager import ensure_tool
10
+ from ._read_image import is_image_file, read_and_process_image
11
+ from ._tool_utils import ToolCancelledError, communicate_or_cancel, shorten_path
12
+ from .base import BaseTool, ToolResult
13
+
14
+ MAX_CHARS_PER_LINE = 2000
15
+ MAX_LINES_PER_FILE = 2000
16
+ DIRECTORY_DEPTH_ROW_LIMIT = 200
17
+ MAX_DIRECTORY_ROWS = 1000
18
+
19
+
20
+ class ReadParams(BaseModel):
21
+ path: str = Field(description="Absolute path of the file or directory to read")
22
+ offset: int | None = Field(
23
+ description="Line number to start reading from. "
24
+ "Only provide if the file is too large to read at once.",
25
+ default=None,
26
+ )
27
+ limit: int | None = Field(
28
+ description="Number of lines to read. "
29
+ "Only provide if the file is too large to read at once.",
30
+ default=None,
31
+ )
32
+
33
+
34
+ class ReadTool(BaseTool):
35
+ name = "read"
36
+ tool_icon = "→"
37
+ params = ReadParams
38
+ mutating = False
39
+ prompt_guidelines = ("Use read to view files (NOT cat/head/tail)",)
40
+ description = (
41
+ "Read the contents of a file or directory. "
42
+ f"File reads truncate to {MAX_LINES_PER_FILE} lines and "
43
+ f"{MAX_CHARS_PER_LINE} chars per line. "
44
+ "Use offset/limit to paginate large files. "
45
+ "Supports reading jpg/jpeg/png/gif/webp images."
46
+ )
47
+
48
+ def format_call(self, params: ReadParams) -> str:
49
+ path = shorten_path(params.path)
50
+ if params.offset or params.limit:
51
+ start = params.offset or 1
52
+ end = (start + params.limit - 1) if params.limit else "?"
53
+ return f"{path}:{start}-{end}"
54
+ return path
55
+
56
+ async def read_file(self, file_path: Path, offset: int | None, limit: int | None) -> str:
57
+ lines = []
58
+ start = (offset - 1) if offset else 0
59
+ effective_limit = min(limit, MAX_LINES_PER_FILE) if limit else MAX_LINES_PER_FILE
60
+ line_number = 0
61
+
62
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
63
+ async for line in f:
64
+ line_number += 1
65
+ if line_number <= start:
66
+ continue
67
+ if len(lines) == effective_limit:
68
+ if effective_limit == MAX_LINES_PER_FILE:
69
+ lines.append(f"[output truncated after {MAX_LINES_PER_FILE} lines]")
70
+ break
71
+
72
+ if len(line) > MAX_CHARS_PER_LINE:
73
+ line = (
74
+ line[:MAX_CHARS_PER_LINE]
75
+ + f" [output truncated after {MAX_CHARS_PER_LINE} chars]\n"
76
+ )
77
+ lines.append(f"{line_number:6d}\t{line}")
78
+
79
+ return "".join(lines)
80
+
81
+ def _format_directory_entry(self, entry_path: Path, relative: Path) -> str:
82
+ modified = datetime.fromtimestamp(entry_path.stat().st_mtime)
83
+ timestamp = f"{modified.day:2d} {modified.strftime('%b %H:%M')}"
84
+ display = relative.as_posix()
85
+ if entry_path.is_dir():
86
+ display += "/"
87
+ return f"{timestamp} {display}"
88
+
89
+ async def _list_directory_entries(
90
+ self,
91
+ fd_path: str,
92
+ dir_path: Path,
93
+ max_depth: int,
94
+ max_results: int,
95
+ cancel_event: asyncio.Event | None,
96
+ ) -> list[str]:
97
+ proc = await asyncio.create_subprocess_exec(
98
+ fd_path,
99
+ "--hidden",
100
+ "--color=never",
101
+ "--max-depth",
102
+ str(max_depth),
103
+ "--max-results",
104
+ str(max_results),
105
+ ".",
106
+ str(dir_path),
107
+ stdout=asyncio.subprocess.PIPE,
108
+ stderr=asyncio.subprocess.PIPE,
109
+ )
110
+
111
+ stdout, stderr = await communicate_or_cancel(proc, cancel_event)
112
+ output = stdout.decode("utf-8", errors="replace").strip()
113
+ error_output = stderr.decode("utf-8", errors="replace").strip()
114
+
115
+ if proc.returncode not in (0, 1):
116
+ raise RuntimeError(f"fd failed: {error_output or 'unknown error'}")
117
+
118
+ if not output:
119
+ return []
120
+
121
+ entries: list[str] = []
122
+ for line in output.split("\n"):
123
+ if not line.strip():
124
+ continue
125
+
126
+ entry_path = Path(line)
127
+ if entry_path.is_absolute():
128
+ try:
129
+ relative = entry_path.relative_to(dir_path)
130
+ except ValueError:
131
+ relative = entry_path
132
+ else:
133
+ relative = entry_path
134
+ entry_path = dir_path / entry_path
135
+
136
+ entries.append(self._format_directory_entry(entry_path, relative))
137
+
138
+ return entries
139
+
140
+ async def read_directory(
141
+ self, dir_path: Path, cancel_event: asyncio.Event | None = None
142
+ ) -> ToolResult:
143
+ fd_path = await ensure_tool("fd", silent=True)
144
+ if not fd_path:
145
+ msg = "fd is not available and could not be downloaded"
146
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
147
+
148
+ try:
149
+ for max_depth in (3, 2):
150
+ entries = await self._list_directory_entries(
151
+ fd_path,
152
+ dir_path,
153
+ max_depth=max_depth,
154
+ max_results=DIRECTORY_DEPTH_ROW_LIMIT + 1,
155
+ cancel_event=cancel_event,
156
+ )
157
+ if len(entries) <= DIRECTORY_DEPTH_ROW_LIMIT:
158
+ result = "\n".join(entries) if entries else "(empty directory)"
159
+ return ToolResult(
160
+ success=True,
161
+ result=result,
162
+ ui_summary=f"[dim]({len(entries)} entries)[/dim]",
163
+ )
164
+
165
+ entries = await self._list_directory_entries(
166
+ fd_path,
167
+ dir_path,
168
+ max_depth=1,
169
+ max_results=MAX_DIRECTORY_ROWS + 1,
170
+ cancel_event=cancel_event,
171
+ )
172
+ except ToolCancelledError:
173
+ return ToolResult(
174
+ success=False, result="Read aborted", ui_summary="[yellow]Read aborted[/yellow]"
175
+ )
176
+ except RuntimeError as e:
177
+ msg = str(e)
178
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
179
+
180
+ truncated = len(entries) > MAX_DIRECTORY_ROWS
181
+ visible_entries = entries[:MAX_DIRECTORY_ROWS]
182
+ result = "\n".join(visible_entries) if visible_entries else "(empty directory)"
183
+ if truncated:
184
+ result += f"\n[output truncated after {MAX_DIRECTORY_ROWS} lines]"
185
+
186
+ shown = min(len(entries), MAX_DIRECTORY_ROWS)
187
+ return ToolResult(
188
+ success=True, result=result, ui_summary=f"[dim]({shown} entries shown)[/dim]"
189
+ )
190
+
191
+ async def execute(
192
+ self, params: ReadParams, cancel_event: asyncio.Event | None = None
193
+ ) -> ToolResult:
194
+ file_path = Path(params.path)
195
+
196
+ if not file_path.exists():
197
+ msg = "Path not found"
198
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
199
+
200
+ if file_path.is_dir():
201
+ return await self.read_directory(file_path, cancel_event)
202
+
203
+ if not file_path.is_file():
204
+ msg = "Path is not a file or directory"
205
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
206
+
207
+ if is_image_file(str(file_path)):
208
+ try:
209
+ base64_data, mime_type, resize_note = read_and_process_image(str(file_path))
210
+
211
+ text_note = f"Read image file [{mime_type}]"
212
+ if resize_note:
213
+ text_note += f" {resize_note}"
214
+
215
+ display_note = "[dim]Read image[/dim]"
216
+ if resize_note:
217
+ display_note = f"{display_note} {resize_note}"
218
+
219
+ return ToolResult(
220
+ success=True,
221
+ result=text_note,
222
+ images=[ImageContent(data=base64_data, mime_type=mime_type)],
223
+ ui_summary=display_note,
224
+ )
225
+ except Exception as e:
226
+ msg = f"Failed to read image: {e}"
227
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
228
+
229
+ try:
230
+ content = await self.read_file(file_path, params.offset, params.limit)
231
+ except OSError as e:
232
+ msg = f"Failed to read: {e}"
233
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
234
+
235
+ lines_read = len(content.splitlines()) if content else 0
236
+ return ToolResult(
237
+ success=True, result=content, ui_summary=f"[dim]({lines_read} lines)[/dim]"
238
+ )