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/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ from ..core.types import ToolDefinition
2
+ from .base import BaseTool
3
+ from .bash import BashTool
4
+ from .edit import EditTool
5
+ from .find import FindTool
6
+ from .read import ReadTool
7
+ from .skill import SkillTool
8
+ from .web import WebFetchTool, WebSearchTool
9
+ from .write import WriteTool
10
+
11
+ __all__ = [
12
+ "DEFAULT_TOOLS",
13
+ "BaseTool",
14
+ "BashTool",
15
+ "EditTool",
16
+ "FindTool",
17
+ "ReadTool",
18
+ "SkillTool",
19
+ "WebFetchTool",
20
+ "WebSearchTool",
21
+ "WriteTool",
22
+ "get_tool",
23
+ "get_tool_definitions",
24
+ "get_tools",
25
+ "tools_by_name",
26
+ ]
27
+
28
+ all_tools: list[BaseTool] = [
29
+ ReadTool(),
30
+ EditTool(),
31
+ WriteTool(),
32
+ BashTool(),
33
+ FindTool(),
34
+ SkillTool(),
35
+ WebFetchTool(),
36
+ WebSearchTool(),
37
+ ]
38
+
39
+ tools_by_name: dict[str, BaseTool] = {tool.name: tool for tool in all_tools}
40
+ DEFAULT_TOOLS: list[str] = [
41
+ "read",
42
+ "edit",
43
+ "write",
44
+ "bash",
45
+ "find",
46
+ "skill",
47
+ "fetch_webpage",
48
+ "web_search",
49
+ ]
50
+
51
+
52
+ def get_tools(names: list[str]) -> list[BaseTool]:
53
+ return [tool for tool in all_tools if tool.name in names]
54
+
55
+
56
+ def get_tool(tool_name: str) -> BaseTool | None:
57
+ return tools_by_name.get(tool_name)
58
+
59
+
60
+ def get_tool_definitions(tools: list[BaseTool]) -> list[ToolDefinition]:
61
+ return [
62
+ ToolDefinition(
63
+ name=tool.name,
64
+ description=tool.description,
65
+ parameters=tool.params.model_json_schema(),
66
+ )
67
+ for tool in tools
68
+ ]
@@ -0,0 +1,106 @@
1
+ import base64
2
+ import io
3
+ import os
4
+
5
+ IMAGE_EXTENSIONS = {
6
+ ".jpg": "image/jpeg",
7
+ ".jpeg": "image/jpeg",
8
+ ".png": "image/png",
9
+ ".gif": "image/gif",
10
+ ".webp": "image/webp",
11
+ }
12
+
13
+ MAX_DIMENSION = 2000
14
+ MAX_BYTES = 4 * 1024 * 1024
15
+ JPEG_QUALITY_STEPS = [85, 70, 55, 40]
16
+
17
+
18
+ def get_mime_type(path: str) -> str | None:
19
+ ext = os.path.splitext(path)[1].lower()
20
+ return IMAGE_EXTENSIONS.get(ext)
21
+
22
+
23
+ def is_image_file(path: str) -> bool:
24
+ return get_mime_type(path) is not None
25
+
26
+
27
+ def resize_image(data: bytes, mime_type: str) -> tuple[bytes, str, str | None]:
28
+ """
29
+ Resize image if needed to stay within limits.
30
+
31
+ Returns (data, mime_type, resize_note).
32
+ If Pillow is not available, returns original image unchanged.
33
+ """
34
+ try:
35
+ from PIL import Image
36
+ except ImportError:
37
+ return data, mime_type, None
38
+
39
+ if len(data) <= MAX_BYTES:
40
+ img = Image.open(io.BytesIO(data))
41
+ width, height = img.size
42
+ if width <= MAX_DIMENSION and height <= MAX_DIMENSION:
43
+ return data, mime_type, f"[{width}x{height}]"
44
+
45
+ img = Image.open(io.BytesIO(data))
46
+ original_width, original_height = img.size
47
+
48
+ if img.mode in ("RGBA", "P"):
49
+ img = img.convert("RGB")
50
+
51
+ width, height = original_width, original_height
52
+ if width > MAX_DIMENSION:
53
+ height = int(height * MAX_DIMENSION / width)
54
+ width = MAX_DIMENSION
55
+ if height > MAX_DIMENSION:
56
+ width = int(width * MAX_DIMENSION / height)
57
+ height = MAX_DIMENSION
58
+
59
+ if (width, height) != (original_width, original_height):
60
+ img = img.resize((width, height), Image.Resampling.LANCZOS)
61
+
62
+ def encode_image(fmt: str, quality: int | None = None) -> tuple[bytes, str]:
63
+ buf = io.BytesIO()
64
+ if fmt == "JPEG" and quality:
65
+ img.save(buf, format=fmt, quality=quality, optimize=True)
66
+ elif fmt == "PNG":
67
+ img.save(buf, format=fmt, optimize=True)
68
+ else:
69
+ img.save(buf, format=fmt)
70
+ return buf.getvalue(), f"image/{fmt.lower()}"
71
+
72
+ png_data, png_mime = encode_image("PNG")
73
+ if len(png_data) <= MAX_BYTES:
74
+ if (width, height) != (original_width, original_height):
75
+ resize_note = f"[{width}x{height}, resized from {original_width}x{original_height}]"
76
+ else:
77
+ resize_note = f"[{width}x{height}]"
78
+ return png_data, png_mime, resize_note
79
+
80
+ jpeg_data, jpeg_mime = encode_image("JPEG")
81
+ for quality in JPEG_QUALITY_STEPS:
82
+ jpeg_data, jpeg_mime = encode_image("JPEG", quality)
83
+ if len(jpeg_data) <= MAX_BYTES:
84
+ resize_note = (
85
+ f"[{width}x{height}, resized from "
86
+ f"{original_width}x{original_height}, quality={quality}]"
87
+ )
88
+ return jpeg_data, jpeg_mime, resize_note
89
+
90
+ resize_note = (
91
+ f"[{width}x{height}, resized from "
92
+ f"{original_width}x{original_height}, may exceed size limit]"
93
+ )
94
+ return jpeg_data, jpeg_mime, resize_note
95
+
96
+
97
+ def read_and_process_image(path: str) -> tuple[str, str, str | None]:
98
+ mime_type = get_mime_type(path)
99
+ if not mime_type:
100
+ raise ValueError(
101
+ f"Unsupported image format. Supported: {', '.join(IMAGE_EXTENSIONS.keys())}"
102
+ )
103
+ with open(path, "rb") as f:
104
+ data = f.read()
105
+ data, mime_type, resize_note = resize_image(data, mime_type)
106
+ return base64.b64encode(data).decode("utf-8"), mime_type, resize_note
@@ -0,0 +1,90 @@
1
+ import asyncio
2
+ import os
3
+ from contextlib import suppress
4
+
5
+ from ..async_utils import OperationCancelledError, await_or_cancel
6
+
7
+ _SUBPROCESS_DRAIN_TIMEOUT_SECONDS = 1.0
8
+
9
+
10
+ class ToolCancelledError(Exception):
11
+ pass
12
+
13
+
14
+ async def await_task_or_cancel(work: asyncio.Task, cancel_event: asyncio.Event | None):
15
+ try:
16
+ return await await_or_cancel(work, cancel_event)
17
+ except OperationCancelledError as e:
18
+ raise ToolCancelledError from e
19
+
20
+
21
+ async def communicate_or_cancel(
22
+ proc: asyncio.subprocess.Process, cancel_event: asyncio.Event | None
23
+ ) -> tuple[bytes, bytes]:
24
+ comm_task = asyncio.create_task(proc.communicate())
25
+ if not cancel_event:
26
+ return await comm_task
27
+
28
+ cancel = asyncio.create_task(cancel_event.wait())
29
+ try:
30
+ done, pending = await asyncio.wait(
31
+ [comm_task, cancel], return_when=asyncio.FIRST_COMPLETED
32
+ )
33
+
34
+ if cancel in done and cancel_event.is_set():
35
+ if proc.returncode is None:
36
+ with suppress(ProcessLookupError):
37
+ proc.kill()
38
+ with suppress(ProcessLookupError):
39
+ await asyncio.wait_for(proc.wait(), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS)
40
+ if not comm_task.done():
41
+ with suppress(asyncio.CancelledError, TimeoutError):
42
+ await asyncio.wait_for(
43
+ asyncio.shield(comm_task), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS
44
+ )
45
+ if not comm_task.done():
46
+ comm_task.cancel()
47
+ with suppress(asyncio.CancelledError):
48
+ await comm_task
49
+ raise ToolCancelledError
50
+
51
+ for task in pending:
52
+ task.cancel()
53
+ with suppress(asyncio.CancelledError):
54
+ await task
55
+
56
+ return comm_task.result()
57
+ finally:
58
+ if not cancel.done():
59
+ cancel.cancel()
60
+ with suppress(asyncio.CancelledError):
61
+ await cancel
62
+
63
+
64
+ def shorten_path(path: str) -> str:
65
+ home = os.path.expanduser("~")
66
+ if path.startswith(home):
67
+ return "~" + path[len(home) :]
68
+ return path
69
+
70
+
71
+ def truncate_text(text: str, n: int = 80) -> str:
72
+ return text[:77] + "..." if len(text) > n else text
73
+
74
+
75
+ def truncate_lines_by_bytes(
76
+ lines: list[str], max_output_bytes: int, marker: str = "[output truncated]"
77
+ ) -> tuple[str, bool]:
78
+ total_bytes = 0
79
+ result_lines: list[str] = []
80
+
81
+ for line in lines:
82
+ line_bytes = len(line.encode("utf-8"))
83
+ if total_bytes + line_bytes <= max_output_bytes:
84
+ total_bytes += line_bytes
85
+ result_lines.append(line)
86
+ else:
87
+ result_lines.append(marker)
88
+ return "\n".join(result_lines), True
89
+
90
+ return "\n".join(result_lines), False
vtx/tools/base.py ADDED
@@ -0,0 +1,36 @@
1
+ import asyncio
2
+ from abc import ABC, abstractmethod
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from ..core.types import ToolResult
7
+
8
+
9
+ class BaseTool[T: BaseModel](ABC):
10
+ # UI model for tool blocks:
11
+ # - format_call(params): short call text shown on the tool header
12
+ # - ToolResult.ui_summary: one-line result summary appended to that header
13
+ # - ToolResult.ui_details: multiline result body shown below the header
14
+ # - format_preview(params): approval-time preview shown before execution
15
+ name: str
16
+ params: type[T]
17
+ description: str
18
+ mutating: bool = True
19
+ tool_icon: str = "→"
20
+ prompt_guidelines: tuple[str, ...] = ()
21
+
22
+ @abstractmethod
23
+ async def execute(
24
+ self, params: T, cancel_event: asyncio.Event | None = None
25
+ ) -> ToolResult: ...
26
+
27
+ def format_call(self, params: T) -> str:
28
+ data = params.model_dump(exclude_none=True)
29
+ if not data:
30
+ return ""
31
+ parts = [f"{k}={v}" for k, v in data.items()]
32
+ return " / ".join(parts)
33
+
34
+ def format_preview(self, params: T) -> str | None:
35
+ """Extended preview shown only during approval prompts. Returns None by default."""
36
+ return None
vtx/tools/bash.py ADDED
@@ -0,0 +1,371 @@
1
+ import asyncio
2
+ import contextlib
3
+ import os
4
+ import re
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from vtx import config
14
+ from vtx.ui.tool_output import truncate_tool_output_text
15
+
16
+ from ..core.types import ToolResult
17
+ from .base import BaseTool
18
+
19
+ DEFAULT_TIMEOUT = 180
20
+ MAX_OUTPUT_BYTES = 50 * 1024
21
+ MAX_OUTPUT_LINES = 2000
22
+ _SUBPROCESS_DRAIN_TIMEOUT_SECONDS = 1.0
23
+
24
+ _IS_WINDOWS: bool = sys.platform == "win32"
25
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]")
26
+
27
+
28
+ def _get_env() -> dict[str, str]:
29
+ return {
30
+ **os.environ,
31
+ "CI": "true",
32
+ "NO_COLOR": "1",
33
+ "TERM": "dumb",
34
+ "GIT_PAGER": "cat",
35
+ "PAGER": "cat",
36
+ }
37
+
38
+
39
+ def _get_shell() -> str | None:
40
+ if _IS_WINDOWS:
41
+ program_files = os.environ.get("ProgramFiles", "") # noqa: SIM112
42
+ program_files_x86 = os.environ.get("ProgramFiles(x86)", "") # noqa: SIM112
43
+ paths = [
44
+ os.path.join(program_files, "Git", "bin", "bash.exe"),
45
+ os.path.join(program_files_x86, "Git", "bin", "bash.exe"),
46
+ ]
47
+ for path in paths:
48
+ if path and os.path.exists(path):
49
+ return path
50
+ return None
51
+ return os.environ.get("SHELL") or "/bin/bash"
52
+
53
+
54
+ def _get_spawn_argv(command: str) -> list[str] | None:
55
+ """Return the argv to exec the command through a shell, or None to use the
56
+ platform's default shell mechanism.
57
+
58
+ The exec form is required on Windows: passing the shell as `executable=` to
59
+ create_subprocess_shell formats it unquoted into the command line, so a Git
60
+ bash path like "C:\\Program Files\\Git\\bin\\bash.exe" splits at the space
61
+ (and bash would receive cmd's /c instead of -c).
62
+ """
63
+ shell = _get_shell()
64
+ if shell is None:
65
+ return None
66
+ return [shell, "-c", command]
67
+
68
+
69
+ def _sanitize_output(text: str) -> str:
70
+ text = _ANSI_ESCAPE_RE.sub("", text)
71
+ text = text.replace("\r\n", "\n").replace("\r", "")
72
+ text = "".join(c for c in text if c >= " " or c in "\t\n")
73
+ return text
74
+
75
+
76
+ class TruncationResult:
77
+ def __init__(self, content: str, truncated: bool, lines_kept: int, total_lines: int):
78
+ self.content = content
79
+ self.truncated = truncated
80
+ self.lines_kept = lines_kept
81
+ self.total_lines = total_lines
82
+
83
+
84
+ def _truncate_tail(text: str) -> TruncationResult:
85
+ """
86
+ Truncate from the head, keeping the last N lines/bytes (tail truncation).
87
+ Better for bash output where errors/results are at the end.
88
+ """
89
+ lines = text.split("\n")
90
+ total_lines = len(lines)
91
+
92
+ if total_lines <= MAX_OUTPUT_LINES and len(text.encode("utf-8")) <= MAX_OUTPUT_BYTES:
93
+ return TruncationResult(text, False, total_lines, total_lines)
94
+
95
+ output_lines: list[str] = []
96
+ output_bytes = 0
97
+
98
+ for i in range(total_lines - 1, -1, -1):
99
+ line = lines[i]
100
+ encoded_line = line.encode("utf-8")
101
+ line_bytes = len(encoded_line) + (1 if output_lines else 0)
102
+
103
+ if output_bytes + line_bytes > MAX_OUTPUT_BYTES:
104
+ if not output_lines:
105
+ output_lines.append(encoded_line[-MAX_OUTPUT_BYTES:].decode("utf-8", "ignore"))
106
+ break
107
+ if len(output_lines) >= MAX_OUTPUT_LINES:
108
+ break
109
+
110
+ output_lines.insert(0, line)
111
+ output_bytes += line_bytes
112
+
113
+ return TruncationResult("\n".join(output_lines), True, len(output_lines), total_lines)
114
+
115
+
116
+ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
117
+ if proc.returncode is not None:
118
+ return
119
+
120
+ try:
121
+ if _IS_WINDOWS:
122
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
123
+ else:
124
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
125
+ await proc.wait()
126
+ except (ProcessLookupError, PermissionError, OSError):
127
+ pass
128
+
129
+
130
+ def _write_full_output_to_temp(output: str) -> str:
131
+ fd, path = tempfile.mkstemp(prefix="vtx-bash-", suffix=".log")
132
+ try:
133
+ os.write(fd, output.encode("utf-8"))
134
+ finally:
135
+ os.close(fd)
136
+ return path
137
+
138
+
139
+ class BashParams(BaseModel):
140
+ command: str = Field(description="The bash command to execute")
141
+ timeout: int = Field(
142
+ description=f"Timeout in seconds (default {DEFAULT_TIMEOUT})", default=DEFAULT_TIMEOUT
143
+ )
144
+
145
+
146
+ class BashTool(BaseTool):
147
+ name = "bash"
148
+ tool_icon = "$"
149
+ params = BashParams
150
+ prompt_guidelines = (
151
+ "Use bash for terminal operations (git, package managers, builds, tests, running scripts)",
152
+ )
153
+ description = (
154
+ "Execute a bash command in the current working directory. "
155
+ f"Output truncated to last {MAX_OUTPUT_LINES} lines or {MAX_OUTPUT_BYTES // 1024}KB. "
156
+ "If truncated, full output is saved to a temp file. "
157
+ "Optionally provide a timeout in seconds. "
158
+ "IMPORTANT: Do NOT use bash for file search (use the find tool instead), "
159
+ "reading files (use read), or editing files (use edit)."
160
+ )
161
+
162
+ # TODO: Add streaming support via an optional `on_chunk` callback parameter
163
+ # Implementation approach:
164
+ # 1. Add `on_chunk: Callable[[str], None] | None = None` parameter to execute()
165
+ # 2. Instead of proc.communicate(), read from proc.stdout/stderr in a loop
166
+ # 3. Keep a rolling buffer of chunks (max 2x MAX_OUTPUT_BYTES) for tail truncation
167
+ # 4. Call on_chunk(sanitized_text) for each chunk received
168
+ # 5. Start writing to temp file once total bytes exceed MAX_OUTPUT_BYTES
169
+ # 6. On completion, apply tail truncation to the rolling buffer
170
+
171
+ def format_call(self, params: BashParams) -> str:
172
+ return params.command
173
+
174
+ def _format_display(
175
+ self, output: str, max_lines: int = 5, max_line_chars: int = 500
176
+ ) -> tuple[str, str | None]:
177
+ truncation_color = config.ui.colors.dim
178
+
179
+ if not output:
180
+ return f"[{truncation_color}](no output)[/{truncation_color}]", None
181
+
182
+ lines = [line for line in output.split("\n") if line != ""]
183
+ if not lines:
184
+ return f"[{truncation_color}](no output)[/{truncation_color}]", None
185
+
186
+ full_formatted: list[str] = []
187
+ char_truncated = False
188
+ for line in lines:
189
+ if len(line) > max_line_chars:
190
+ visible = line[:max_line_chars].replace("[", "\\[")
191
+ hidden_chars = len(line) - max_line_chars
192
+ char_truncated = True
193
+ full_formatted.append(
194
+ f"[dim]{visible}[/dim]"
195
+ f"[{truncation_color}]... ({hidden_chars} more chars)[/{truncation_color}]"
196
+ )
197
+ else:
198
+ escaped = line.replace("[", "\\[")
199
+ full_formatted.append(f"[dim]{escaped}[/dim]")
200
+
201
+ full_display = "\n".join(full_formatted)
202
+ collapsed_display, line_truncated = truncate_tool_output_text(
203
+ full_display, max_lines=max_lines, escape_lines=False
204
+ )
205
+ expanded_display = full_display if line_truncated or char_truncated else None
206
+ return collapsed_display, expanded_display
207
+
208
+ async def execute(
209
+ self,
210
+ params: BashParams,
211
+ cancel_event: asyncio.Event | None = None,
212
+ inline_output: bool = False,
213
+ ) -> ToolResult:
214
+ if not params.command.strip():
215
+ msg = "Command cannot be empty"
216
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
217
+
218
+ command = params.command
219
+
220
+ cwd = Path.cwd()
221
+ if not cwd.exists():
222
+ return ToolResult(
223
+ success=False, ui_summary=f"[red]Working directory does not exist: {cwd}[/red]"
224
+ )
225
+
226
+ proc = None
227
+ try:
228
+ spawn_argv = _get_spawn_argv(command)
229
+ if spawn_argv is None:
230
+ proc = await asyncio.create_subprocess_shell(
231
+ command,
232
+ stdout=asyncio.subprocess.PIPE,
233
+ stderr=asyncio.subprocess.PIPE,
234
+ stdin=asyncio.subprocess.DEVNULL,
235
+ env=_get_env(),
236
+ start_new_session=not _IS_WINDOWS,
237
+ )
238
+ else:
239
+ proc = await asyncio.create_subprocess_exec(
240
+ *spawn_argv,
241
+ stdout=asyncio.subprocess.PIPE,
242
+ stderr=asyncio.subprocess.PIPE,
243
+ stdin=asyncio.subprocess.DEVNULL,
244
+ env=_get_env(),
245
+ start_new_session=not _IS_WINDOWS,
246
+ )
247
+
248
+ comm_task = asyncio.create_task(proc.communicate())
249
+
250
+ try:
251
+ if cancel_event:
252
+ cancel_wait = asyncio.create_task(cancel_event.wait())
253
+ done, pending = await asyncio.wait(
254
+ [comm_task, cancel_wait],
255
+ timeout=params.timeout,
256
+ return_when=asyncio.FIRST_COMPLETED,
257
+ )
258
+
259
+ if not done:
260
+ for task in pending:
261
+ task.cancel()
262
+ with contextlib.suppress(asyncio.CancelledError):
263
+ await task
264
+ await _kill_process_tree(proc)
265
+ return ToolResult(
266
+ success=False,
267
+ ui_summary=f"[red]Command timed out after {params.timeout}s[/red]",
268
+ )
269
+
270
+ if cancel_wait in done and cancel_event.is_set():
271
+ await _kill_process_tree(proc)
272
+ if not comm_task.done():
273
+ with contextlib.suppress(asyncio.CancelledError, TimeoutError):
274
+ await asyncio.wait_for(
275
+ asyncio.shield(comm_task), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS
276
+ )
277
+ if not comm_task.done():
278
+ comm_task.cancel()
279
+ with contextlib.suppress(asyncio.CancelledError):
280
+ await comm_task
281
+ return ToolResult(
282
+ success=False,
283
+ result="Command aborted",
284
+ ui_summary="[yellow]Command aborted by user[/yellow]",
285
+ )
286
+
287
+ for task in pending:
288
+ task.cancel()
289
+ with contextlib.suppress(asyncio.CancelledError):
290
+ await task
291
+
292
+ stdout_bytes, stderr_bytes = comm_task.result()
293
+ else:
294
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
295
+ comm_task, timeout=params.timeout
296
+ )
297
+
298
+ except TimeoutError:
299
+ await _kill_process_tree(proc)
300
+ if comm_task is not None and not comm_task.done():
301
+ with contextlib.suppress(asyncio.CancelledError, TimeoutError):
302
+ await asyncio.wait_for(
303
+ asyncio.shield(comm_task), _SUBPROCESS_DRAIN_TIMEOUT_SECONDS
304
+ )
305
+ if not comm_task.done():
306
+ comm_task.cancel()
307
+ with contextlib.suppress(asyncio.CancelledError):
308
+ await comm_task
309
+ return ToolResult(
310
+ success=False,
311
+ ui_summary=f"[red]Command timed out after {params.timeout}s[/red]",
312
+ )
313
+
314
+ stdout = _sanitize_output(stdout_bytes.decode("utf-8", errors="replace"))
315
+ stderr = _sanitize_output(stderr_bytes.decode("utf-8", errors="replace"))
316
+
317
+ full_output = ""
318
+ if stdout:
319
+ full_output += stdout
320
+ if stderr:
321
+ full_output += f"\n[stderr]\n{stderr}" if full_output else f"[stderr]\n{stderr}"
322
+ full_output = full_output.rstrip()
323
+
324
+ trunc = _truncate_tail(full_output)
325
+ if trunc.truncated:
326
+ marker = (
327
+ f"\n\n[output truncated to last {trunc.lines_kept} lines "
328
+ f"of {trunc.total_lines}"
329
+ )
330
+ if inline_output:
331
+ marker += "]"
332
+ else:
333
+ temp_file_path = _write_full_output_to_temp(full_output)
334
+ marker += f"; full output: {temp_file_path}]"
335
+ trunc.content += marker
336
+
337
+ result_text = trunc.content or "(no output)"
338
+
339
+ display_max = sys.maxsize if inline_output else 5
340
+ display_text, display_text_full = self._format_display(
341
+ trunc.content, max_lines=display_max
342
+ )
343
+
344
+ non_empty_lines = [line for line in (trunc.content or "").split("\n") if line.strip()]
345
+ is_single_line = len(non_empty_lines) <= 1
346
+
347
+ if proc.returncode == 0:
348
+ if is_single_line:
349
+ summary_line = display_text.replace("\n", " ").strip()
350
+ return ToolResult(success=True, result=result_text, ui_summary=summary_line)
351
+ return ToolResult(
352
+ success=True,
353
+ result=result_text,
354
+ ui_details=display_text,
355
+ ui_details_full=display_text_full,
356
+ )
357
+ else:
358
+ return ToolResult(
359
+ success=False,
360
+ result=result_text,
361
+ ui_summary=f"[red]Exit code {proc.returncode}[/red]",
362
+ ui_details=display_text,
363
+ ui_details_full=display_text_full,
364
+ )
365
+
366
+ except Exception as e:
367
+ msg = f"Error running command: {e}"
368
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
369
+ finally:
370
+ if proc is not None:
371
+ await _kill_process_tree(proc)