codemaster-cli 2.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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- vibe/whats_new.md +5 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
|
|
10
|
+
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|
11
|
+
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StatusMessage(SpinnerMixin, NoMarkupStatic):
|
|
15
|
+
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.PULSE
|
|
16
|
+
|
|
17
|
+
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
|
|
18
|
+
self._initial_text = initial_text
|
|
19
|
+
self._indicator_widget: Static | None = None
|
|
20
|
+
self._text_widget: NoMarkupStatic | None = None
|
|
21
|
+
self.success = True
|
|
22
|
+
self.init_spinner()
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
with Horizontal():
|
|
27
|
+
self._indicator_widget = NonSelectableStatic(
|
|
28
|
+
self._spinner.current_frame(), classes="status-indicator-icon"
|
|
29
|
+
)
|
|
30
|
+
yield self._indicator_widget
|
|
31
|
+
self._text_widget = NoMarkupStatic("", classes="status-indicator-text")
|
|
32
|
+
yield self._text_widget
|
|
33
|
+
|
|
34
|
+
def on_mount(self) -> None:
|
|
35
|
+
self.update_display()
|
|
36
|
+
self.start_spinner_timer()
|
|
37
|
+
|
|
38
|
+
def on_resize(self) -> None:
|
|
39
|
+
self.refresh_spinner()
|
|
40
|
+
|
|
41
|
+
def _update_spinner_frame(self) -> None:
|
|
42
|
+
if not self._is_spinning:
|
|
43
|
+
return
|
|
44
|
+
self.update_display()
|
|
45
|
+
|
|
46
|
+
def update_display(self) -> None:
|
|
47
|
+
if not self._indicator_widget or not self._text_widget:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
content = self.get_content()
|
|
51
|
+
|
|
52
|
+
if self._is_spinning:
|
|
53
|
+
self._indicator_widget.update(self._spinner.next_frame())
|
|
54
|
+
self._indicator_widget.remove_class("success")
|
|
55
|
+
self._indicator_widget.remove_class("error")
|
|
56
|
+
elif self.success:
|
|
57
|
+
self._indicator_widget.update("✓")
|
|
58
|
+
self._indicator_widget.add_class("success")
|
|
59
|
+
self._indicator_widget.remove_class("error")
|
|
60
|
+
else:
|
|
61
|
+
self._indicator_widget.update("✕")
|
|
62
|
+
self._indicator_widget.add_class("error")
|
|
63
|
+
self._indicator_widget.remove_class("success")
|
|
64
|
+
|
|
65
|
+
self._text_widget.update(content)
|
|
66
|
+
|
|
67
|
+
def get_content(self) -> str:
|
|
68
|
+
return self._initial_text
|
|
69
|
+
|
|
70
|
+
def stop_spinning(self, success: bool = True) -> None:
|
|
71
|
+
self._is_spinning = False
|
|
72
|
+
self.success = success
|
|
73
|
+
if self._spinner_timer:
|
|
74
|
+
self._spinner_timer.stop()
|
|
75
|
+
self._spinner_timer = None
|
|
76
|
+
self.update_display()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TeleportMessage(StatusMessage):
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.add_class("teleport-message")
|
|
10
|
+
self._status: str = "Teleporting..."
|
|
11
|
+
self._final_url: str | None = None
|
|
12
|
+
self._error: str | None = None
|
|
13
|
+
|
|
14
|
+
def get_content(self) -> str:
|
|
15
|
+
if self._error:
|
|
16
|
+
return f"Teleport failed: {self._error}"
|
|
17
|
+
if self._final_url:
|
|
18
|
+
return f"Teleported to Nuage: {self._final_url}"
|
|
19
|
+
return self._status
|
|
20
|
+
|
|
21
|
+
def set_status(self, status: str) -> None:
|
|
22
|
+
self._status = status
|
|
23
|
+
self.update_display()
|
|
24
|
+
|
|
25
|
+
def set_complete(self, url: str) -> None:
|
|
26
|
+
self._final_url = url
|
|
27
|
+
self.stop_spinning(success=True)
|
|
28
|
+
|
|
29
|
+
def set_error(self, error: str) -> None:
|
|
30
|
+
self._error = error
|
|
31
|
+
self.stop_spinning(success=False)
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
|
|
12
|
+
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|
13
|
+
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
|
|
14
|
+
from vibe.core.tools.builtins.bash import BashArgs, BashResult
|
|
15
|
+
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
|
|
16
|
+
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
|
|
17
|
+
from vibe.core.tools.builtins.search_replace import (
|
|
18
|
+
SEARCH_REPLACE_BLOCK_RE,
|
|
19
|
+
SearchReplaceArgs,
|
|
20
|
+
SearchReplaceResult,
|
|
21
|
+
)
|
|
22
|
+
from vibe.core.tools.builtins.todo import TodoArgs, TodoResult
|
|
23
|
+
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
|
|
27
|
+
"""Truncate content to max_lines, returning (content, truncation_info)."""
|
|
28
|
+
lines = content.split("\n")
|
|
29
|
+
if len(lines) <= max_lines:
|
|
30
|
+
return content, None
|
|
31
|
+
remaining = len(lines) - max_lines
|
|
32
|
+
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_search_replace_to_diff(content: str) -> list[str]:
|
|
36
|
+
"""Parse SEARCH/REPLACE blocks and generate unified diff lines."""
|
|
37
|
+
all_diff_lines: list[str] = []
|
|
38
|
+
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
|
|
39
|
+
if not matches:
|
|
40
|
+
return [content[:500]] if content else []
|
|
41
|
+
|
|
42
|
+
for i, (search_text, replace_text) in enumerate(matches):
|
|
43
|
+
if i > 0:
|
|
44
|
+
all_diff_lines.append("") # Separator between blocks
|
|
45
|
+
search_lines = search_text.strip().split("\n")
|
|
46
|
+
replace_lines = replace_text.strip().split("\n")
|
|
47
|
+
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
|
48
|
+
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
|
|
49
|
+
|
|
50
|
+
return all_diff_lines
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_diff_line(line: str) -> Static:
|
|
54
|
+
"""Render a single diff line with appropriate styling."""
|
|
55
|
+
if line.startswith("---") or line.startswith("+++"):
|
|
56
|
+
return NoMarkupStatic(line, classes="diff-header")
|
|
57
|
+
elif line.startswith("-"):
|
|
58
|
+
return NoMarkupStatic(line, classes="diff-removed")
|
|
59
|
+
elif line.startswith("+"):
|
|
60
|
+
return NoMarkupStatic(line, classes="diff-added")
|
|
61
|
+
elif line.startswith("@@"):
|
|
62
|
+
return NoMarkupStatic(line, classes="diff-range")
|
|
63
|
+
else:
|
|
64
|
+
return NoMarkupStatic(line, classes="diff-context")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
|
68
|
+
"""Base class for approval widgets with typed args."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, args: TArgs) -> None:
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.args = args
|
|
73
|
+
self.add_class("tool-approval-widget")
|
|
74
|
+
|
|
75
|
+
def compose(self) -> ComposeResult:
|
|
76
|
+
MAX_MSG_SIZE = 150
|
|
77
|
+
for field_name in type(self.args).model_fields:
|
|
78
|
+
value = getattr(self.args, field_name)
|
|
79
|
+
if value is None or value in ("", []):
|
|
80
|
+
continue
|
|
81
|
+
value_str = str(value)
|
|
82
|
+
if len(value_str) > MAX_MSG_SIZE:
|
|
83
|
+
hidden = len(value_str) - MAX_MSG_SIZE
|
|
84
|
+
value_str = value_str[:MAX_MSG_SIZE] + f"… ({hidden} more characters)"
|
|
85
|
+
yield NoMarkupStatic(
|
|
86
|
+
f"{field_name}: {value_str}", classes="approval-description"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ToolResultWidget[TResult: BaseModel](Static):
|
|
91
|
+
"""Base class for result widgets with typed result."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
result: TResult | None,
|
|
96
|
+
success: bool,
|
|
97
|
+
message: str,
|
|
98
|
+
collapsed: bool = True,
|
|
99
|
+
warnings: list[str] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__()
|
|
102
|
+
self.result = result
|
|
103
|
+
self.success = success
|
|
104
|
+
self.message = message
|
|
105
|
+
self.collapsed = collapsed
|
|
106
|
+
self.warnings = warnings or []
|
|
107
|
+
self.add_class("tool-result-widget")
|
|
108
|
+
|
|
109
|
+
def _footer(self, extra: str | None = None) -> ComposeResult:
|
|
110
|
+
"""Yield the footer with optional extra info."""
|
|
111
|
+
if extra:
|
|
112
|
+
yield NoMarkupStatic(extra, classes="tool-result-hint")
|
|
113
|
+
|
|
114
|
+
def compose(self) -> ComposeResult:
|
|
115
|
+
"""Default: show result fields."""
|
|
116
|
+
if not self.collapsed and self.result:
|
|
117
|
+
for field_name in type(self.result).model_fields:
|
|
118
|
+
value = getattr(self.result, field_name)
|
|
119
|
+
if value is not None and value not in ("", []):
|
|
120
|
+
yield NoMarkupStatic(
|
|
121
|
+
f"{field_name}: {value}", classes="tool-result-detail"
|
|
122
|
+
)
|
|
123
|
+
yield from self._footer()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
|
|
127
|
+
def compose(self) -> ComposeResult:
|
|
128
|
+
yield Markdown(f"```bash\n{self.args.command}\n```")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BashResultWidget(ToolResultWidget[BashResult]):
|
|
132
|
+
def compose(self) -> ComposeResult:
|
|
133
|
+
if not self.result:
|
|
134
|
+
yield from self._footer()
|
|
135
|
+
return
|
|
136
|
+
if self.collapsed:
|
|
137
|
+
truncation_info = None
|
|
138
|
+
if self.result.stdout:
|
|
139
|
+
content, truncation_info = _truncate_lines(self.result.stdout, 10)
|
|
140
|
+
yield NoMarkupStatic(content, classes="tool-result-detail")
|
|
141
|
+
else:
|
|
142
|
+
yield NoMarkupStatic("(no content)", classes="tool-result-detail")
|
|
143
|
+
yield from self._footer(truncation_info)
|
|
144
|
+
return
|
|
145
|
+
yield NoMarkupStatic(
|
|
146
|
+
f"returncode: {self.result.returncode}", classes="tool-result-detail"
|
|
147
|
+
)
|
|
148
|
+
if self.result.stdout:
|
|
149
|
+
sep = "\n" if "\n" in self.result.stdout else " "
|
|
150
|
+
yield NoMarkupStatic(
|
|
151
|
+
f"stdout:{sep}{self.result.stdout}", classes="tool-result-detail"
|
|
152
|
+
)
|
|
153
|
+
if self.result.stderr:
|
|
154
|
+
sep = "\n" if "\n" in self.result.stderr else " "
|
|
155
|
+
yield NoMarkupStatic(
|
|
156
|
+
f"stderr:{sep}{self.result.stderr}", classes="tool-result-detail"
|
|
157
|
+
)
|
|
158
|
+
yield from self._footer()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
|
|
162
|
+
def compose(self) -> ComposeResult:
|
|
163
|
+
path = Path(self.args.path)
|
|
164
|
+
file_extension = path.suffix.lstrip(".") or "text"
|
|
165
|
+
|
|
166
|
+
yield NoMarkupStatic(f"File: {self.args.path}", classes="approval-description")
|
|
167
|
+
yield NoMarkupStatic("")
|
|
168
|
+
yield Markdown(f"```{file_extension}\n{self.args.content}\n```")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
|
|
172
|
+
def compose(self) -> ComposeResult:
|
|
173
|
+
if not self.result:
|
|
174
|
+
yield from self._footer()
|
|
175
|
+
return
|
|
176
|
+
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
|
177
|
+
if self.collapsed:
|
|
178
|
+
truncation_info = None
|
|
179
|
+
if self.result.content:
|
|
180
|
+
content, truncation_info = _truncate_lines(self.result.content, 10)
|
|
181
|
+
yield Markdown(f"```{ext}\n{content}\n```")
|
|
182
|
+
yield from self._footer(truncation_info)
|
|
183
|
+
return
|
|
184
|
+
yield NoMarkupStatic(f"Path: {self.result.path}", classes="tool-result-detail")
|
|
185
|
+
yield NoMarkupStatic(
|
|
186
|
+
f"Bytes: {self.result.bytes_written}", classes="tool-result-detail"
|
|
187
|
+
)
|
|
188
|
+
if self.result.content:
|
|
189
|
+
yield NoMarkupStatic("")
|
|
190
|
+
content, _ = _truncate_lines(self.result.content, 10)
|
|
191
|
+
yield Markdown(f"```{ext}\n{content}\n```")
|
|
192
|
+
yield from self._footer()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
|
|
196
|
+
def compose(self) -> ComposeResult:
|
|
197
|
+
yield NoMarkupStatic(
|
|
198
|
+
f"File: {self.args.file_path}", classes="approval-description"
|
|
199
|
+
)
|
|
200
|
+
yield NoMarkupStatic("")
|
|
201
|
+
|
|
202
|
+
diff_lines = parse_search_replace_to_diff(self.args.content)
|
|
203
|
+
for line in diff_lines:
|
|
204
|
+
yield render_diff_line(line)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
|
|
208
|
+
def compose(self) -> ComposeResult:
|
|
209
|
+
if not self.result:
|
|
210
|
+
yield from self._footer()
|
|
211
|
+
return
|
|
212
|
+
for warning in self.warnings:
|
|
213
|
+
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
|
214
|
+
if self.result.content:
|
|
215
|
+
for line in parse_search_replace_to_diff(self.result.content):
|
|
216
|
+
yield render_diff_line(line)
|
|
217
|
+
yield from self._footer()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
|
|
221
|
+
def compose(self) -> ComposeResult:
|
|
222
|
+
yield NoMarkupStatic(
|
|
223
|
+
f"Action: {self.args.action}", classes="approval-description"
|
|
224
|
+
)
|
|
225
|
+
if self.args.todos:
|
|
226
|
+
yield NoMarkupStatic(
|
|
227
|
+
f"Todos: {len(self.args.todos)} items", classes="approval-description"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TodoResultWidget(ToolResultWidget[TodoResult]):
|
|
232
|
+
def compose(self) -> ComposeResult:
|
|
233
|
+
if not self.result or not self.result.todos:
|
|
234
|
+
yield NoMarkupStatic("No todos", classes="todo-empty")
|
|
235
|
+
yield from self._footer()
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
by_status: dict[str, list] = {
|
|
239
|
+
"in_progress": [],
|
|
240
|
+
"pending": [],
|
|
241
|
+
"completed": [],
|
|
242
|
+
"cancelled": [],
|
|
243
|
+
}
|
|
244
|
+
for todo in self.result.todos:
|
|
245
|
+
status = (
|
|
246
|
+
todo.status.value if hasattr(todo.status, "value") else str(todo.status)
|
|
247
|
+
)
|
|
248
|
+
if status in by_status:
|
|
249
|
+
by_status[status].append(todo)
|
|
250
|
+
|
|
251
|
+
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
|
252
|
+
for todo in by_status[status]:
|
|
253
|
+
icon = self._get_status_icon(status)
|
|
254
|
+
yield NoMarkupStatic(f"{icon} {todo.content}", classes=f"todo-{status}")
|
|
255
|
+
yield from self._footer()
|
|
256
|
+
|
|
257
|
+
def _get_status_icon(self, status: str) -> str:
|
|
258
|
+
icons = {"pending": "☐", "in_progress": "☐", "completed": "☑", "cancelled": "☒"}
|
|
259
|
+
return icons.get(status, "☐")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
|
|
263
|
+
def compose(self) -> ComposeResult:
|
|
264
|
+
yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description")
|
|
265
|
+
if self.args.offset > 0:
|
|
266
|
+
yield NoMarkupStatic(
|
|
267
|
+
f"offset: {self.args.offset}", classes="approval-description"
|
|
268
|
+
)
|
|
269
|
+
if self.args.limit is not None:
|
|
270
|
+
yield NoMarkupStatic(
|
|
271
|
+
f"limit: {self.args.limit}", classes="approval-description"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
|
|
276
|
+
def compose(self) -> ComposeResult:
|
|
277
|
+
if self.collapsed:
|
|
278
|
+
yield from self._footer()
|
|
279
|
+
return
|
|
280
|
+
if self.result:
|
|
281
|
+
yield NoMarkupStatic(
|
|
282
|
+
f"Path: {self.result.path}", classes="tool-result-detail"
|
|
283
|
+
)
|
|
284
|
+
for warning in self.warnings:
|
|
285
|
+
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
|
286
|
+
truncation_info = None
|
|
287
|
+
if self.result and self.result.content:
|
|
288
|
+
yield NoMarkupStatic("")
|
|
289
|
+
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
|
290
|
+
content, truncation_info = _truncate_lines(self.result.content, 10)
|
|
291
|
+
yield Markdown(f"```{ext}\n{content}\n```")
|
|
292
|
+
yield from self._footer(truncation_info)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
|
|
296
|
+
def compose(self) -> ComposeResult:
|
|
297
|
+
yield NoMarkupStatic(
|
|
298
|
+
f"pattern: {self.args.pattern}", classes="approval-description"
|
|
299
|
+
)
|
|
300
|
+
yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description")
|
|
301
|
+
if self.args.max_matches is not None:
|
|
302
|
+
yield NoMarkupStatic(
|
|
303
|
+
f"max_matches: {self.args.max_matches}", classes="approval-description"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class GrepResultWidget(ToolResultWidget[GrepResult]):
|
|
308
|
+
def compose(self) -> ComposeResult:
|
|
309
|
+
for warning in self.warnings:
|
|
310
|
+
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
|
311
|
+
if not self.result or not self.result.matches:
|
|
312
|
+
yield from self._footer()
|
|
313
|
+
return
|
|
314
|
+
max_lines = 10 if self.collapsed else None
|
|
315
|
+
if max_lines:
|
|
316
|
+
content, truncation_info = _truncate_lines(self.result.matches, max_lines)
|
|
317
|
+
else:
|
|
318
|
+
content, truncation_info = self.result.matches, None
|
|
319
|
+
yield NoMarkupStatic(content, classes="tool-result-detail")
|
|
320
|
+
yield from self._footer(truncation_info)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
|
|
324
|
+
def compose(self) -> ComposeResult:
|
|
325
|
+
if self.collapsed or not self.result:
|
|
326
|
+
yield from self._footer()
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
for answer in self.result.answers:
|
|
330
|
+
if len(self.result.answers) > 1:
|
|
331
|
+
yield NoMarkupStatic(answer.question, classes="tool-result-detail")
|
|
332
|
+
prefix = "(Other) " if answer.is_other else ""
|
|
333
|
+
yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer")
|
|
334
|
+
yield from self._footer()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
|
|
338
|
+
"bash": BashApprovalWidget,
|
|
339
|
+
"read_file": ReadFileApprovalWidget,
|
|
340
|
+
"write_file": WriteFileApprovalWidget,
|
|
341
|
+
"search_replace": SearchReplaceApprovalWidget,
|
|
342
|
+
"grep": GrepApprovalWidget,
|
|
343
|
+
"todo": TodoApprovalWidget,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
|
|
347
|
+
"bash": BashResultWidget,
|
|
348
|
+
"read_file": ReadFileResultWidget,
|
|
349
|
+
"write_file": WriteFileResultWidget,
|
|
350
|
+
"search_replace": SearchReplaceResultWidget,
|
|
351
|
+
"grep": GrepResultWidget,
|
|
352
|
+
"todo": TodoResultWidget,
|
|
353
|
+
"ask_user_question": AskUserQuestionResultWidget,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_approval_widget(tool_name: str, args: BaseModel) -> ToolApprovalWidget:
|
|
358
|
+
widget_class = APPROVAL_WIDGETS.get(tool_name, ToolApprovalWidget)
|
|
359
|
+
return widget_class(args)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_result_widget(
|
|
363
|
+
tool_name: str,
|
|
364
|
+
result: BaseModel | None,
|
|
365
|
+
success: bool,
|
|
366
|
+
message: str,
|
|
367
|
+
collapsed: bool = True,
|
|
368
|
+
warnings: list[str] | None = None,
|
|
369
|
+
) -> ToolResultWidget:
|
|
370
|
+
widget_class = RESULT_WIDGETS.get(tool_name, ToolResultWidget)
|
|
371
|
+
return widget_class(result, success, message, collapsed, warnings)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableStatic
|
|
8
|
+
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|
9
|
+
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
|
10
|
+
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
|
|
11
|
+
from vibe.core.tools.ui import ToolUIDataAdapter
|
|
12
|
+
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolCallMessage(StatusMessage):
|
|
16
|
+
def __init__(
|
|
17
|
+
self, event: ToolCallEvent | None = None, *, tool_name: str | None = None
|
|
18
|
+
) -> None:
|
|
19
|
+
if event is None and tool_name is None:
|
|
20
|
+
raise ValueError("Either event or tool_name must be provided")
|
|
21
|
+
|
|
22
|
+
self._event = event
|
|
23
|
+
self._tool_name = tool_name or (event.tool_name if event else "unknown")
|
|
24
|
+
self._is_history = event is None
|
|
25
|
+
self._stream_widget: NoMarkupStatic | None = None
|
|
26
|
+
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.add_class("tool-call")
|
|
29
|
+
|
|
30
|
+
if self._is_history:
|
|
31
|
+
self._is_spinning = False
|
|
32
|
+
|
|
33
|
+
def compose(self) -> ComposeResult:
|
|
34
|
+
with Vertical(classes="tool-call-container"):
|
|
35
|
+
with Horizontal():
|
|
36
|
+
self._indicator_widget = NonSelectableStatic(
|
|
37
|
+
self._spinner.current_frame(), classes="status-indicator-icon"
|
|
38
|
+
)
|
|
39
|
+
yield self._indicator_widget
|
|
40
|
+
self._text_widget = NoMarkupStatic("", classes="status-indicator-text")
|
|
41
|
+
yield self._text_widget
|
|
42
|
+
self._stream_widget = NoMarkupStatic("", classes="tool-stream-message")
|
|
43
|
+
self._stream_widget.display = False
|
|
44
|
+
yield self._stream_widget
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
siblings = list(self.parent.children) if self.parent else []
|
|
48
|
+
idx = siblings.index(self) if self in siblings else -1
|
|
49
|
+
if idx > 0 and isinstance(
|
|
50
|
+
siblings[idx - 1], (ToolCallMessage, ToolResultMessage)
|
|
51
|
+
):
|
|
52
|
+
self.add_class("no-gap")
|
|
53
|
+
|
|
54
|
+
def get_content(self) -> str:
|
|
55
|
+
if self._event and self._event.tool_class:
|
|
56
|
+
adapter = ToolUIDataAdapter(self._event.tool_class)
|
|
57
|
+
display = adapter.get_call_display(self._event)
|
|
58
|
+
return display.summary
|
|
59
|
+
return self._tool_name
|
|
60
|
+
|
|
61
|
+
def set_stream_message(self, message: str) -> None:
|
|
62
|
+
"""Update the stream message displayed below the tool call indicator."""
|
|
63
|
+
if self._stream_widget:
|
|
64
|
+
self._stream_widget.update(f"→ {message}")
|
|
65
|
+
self._stream_widget.display = True
|
|
66
|
+
|
|
67
|
+
def stop_spinning(self, success: bool = True) -> None:
|
|
68
|
+
"""Stop the spinner and hide the stream widget."""
|
|
69
|
+
if self._stream_widget:
|
|
70
|
+
self._stream_widget.display = False
|
|
71
|
+
super().stop_spinning(success)
|
|
72
|
+
|
|
73
|
+
def set_result_text(self, text: str) -> None:
|
|
74
|
+
if self._text_widget:
|
|
75
|
+
self._text_widget.update(text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ToolResultMessage(Static):
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
event: ToolResultEvent | None = None,
|
|
82
|
+
call_widget: ToolCallMessage | None = None,
|
|
83
|
+
collapsed: bool = True,
|
|
84
|
+
*,
|
|
85
|
+
tool_name: str | None = None,
|
|
86
|
+
content: str | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
if event is None and tool_name is None:
|
|
89
|
+
raise ValueError("Either event or tool_name must be provided")
|
|
90
|
+
|
|
91
|
+
self._event = event
|
|
92
|
+
self._call_widget = call_widget
|
|
93
|
+
self._tool_name = tool_name or (event.tool_name if event else "unknown")
|
|
94
|
+
self._content = content
|
|
95
|
+
self.collapsed = collapsed
|
|
96
|
+
self._content_container: Vertical | None = None
|
|
97
|
+
|
|
98
|
+
super().__init__()
|
|
99
|
+
self.add_class("tool-result")
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def tool_name(self) -> str:
|
|
103
|
+
return self._tool_name
|
|
104
|
+
|
|
105
|
+
def compose(self) -> ComposeResult:
|
|
106
|
+
with Horizontal(classes="tool-result-container"):
|
|
107
|
+
yield ExpandingBorder(classes="tool-result-border")
|
|
108
|
+
self._content_container = Vertical(classes="tool-result-content")
|
|
109
|
+
yield self._content_container
|
|
110
|
+
|
|
111
|
+
async def on_mount(self) -> None:
|
|
112
|
+
if self._call_widget:
|
|
113
|
+
success = self._determine_success()
|
|
114
|
+
self._call_widget.stop_spinning(success=success)
|
|
115
|
+
result_text = self._get_result_text()
|
|
116
|
+
self._call_widget.set_result_text(result_text)
|
|
117
|
+
await self._render_result()
|
|
118
|
+
|
|
119
|
+
def _determine_success(self) -> bool:
|
|
120
|
+
if self._event is None:
|
|
121
|
+
return True
|
|
122
|
+
if self._event.error or self._event.skipped:
|
|
123
|
+
return False
|
|
124
|
+
if self._event.tool_class:
|
|
125
|
+
adapter = ToolUIDataAdapter(self._event.tool_class)
|
|
126
|
+
display = adapter.get_result_display(self._event)
|
|
127
|
+
return display.success
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def _get_result_text(self) -> str:
|
|
131
|
+
if self._event is None:
|
|
132
|
+
return f"{self._tool_name} completed"
|
|
133
|
+
|
|
134
|
+
if self._event.error:
|
|
135
|
+
return f"{self._tool_name}: error"
|
|
136
|
+
|
|
137
|
+
if self._event.skipped:
|
|
138
|
+
return f"{self._tool_name}: skipped"
|
|
139
|
+
|
|
140
|
+
if self._event.tool_class:
|
|
141
|
+
adapter = ToolUIDataAdapter(self._event.tool_class)
|
|
142
|
+
display = adapter.get_result_display(self._event)
|
|
143
|
+
return display.message
|
|
144
|
+
|
|
145
|
+
return f"{self._tool_name} completed"
|
|
146
|
+
|
|
147
|
+
async def _render_result(self) -> None:
|
|
148
|
+
if self._content_container is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
await self._content_container.remove_children()
|
|
152
|
+
|
|
153
|
+
if self._event is None:
|
|
154
|
+
self.display = False
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if self._event.error:
|
|
158
|
+
self.add_class("error-text")
|
|
159
|
+
await self._content_container.mount(
|
|
160
|
+
NoMarkupStatic(f"Error: {self._event.error}")
|
|
161
|
+
)
|
|
162
|
+
self.display = True
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if self._event.skipped:
|
|
166
|
+
self.add_class("warning-text")
|
|
167
|
+
reason = self._event.skip_reason or "User skipped"
|
|
168
|
+
await self._content_container.mount(NoMarkupStatic(f"Skipped: {reason}"))
|
|
169
|
+
self.display = True
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
self.remove_class("error-text")
|
|
173
|
+
self.remove_class("warning-text")
|
|
174
|
+
|
|
175
|
+
if self._event.tool_class is None:
|
|
176
|
+
self.display = False
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
adapter = ToolUIDataAdapter(self._event.tool_class)
|
|
180
|
+
display = adapter.get_result_display(self._event)
|
|
181
|
+
|
|
182
|
+
widget = get_result_widget(
|
|
183
|
+
self._event.tool_name,
|
|
184
|
+
self._event.result,
|
|
185
|
+
success=display.success,
|
|
186
|
+
message=display.message,
|
|
187
|
+
collapsed=self.collapsed,
|
|
188
|
+
warnings=display.warnings,
|
|
189
|
+
)
|
|
190
|
+
await self._content_container.mount(widget)
|
|
191
|
+
self.display = bool(widget.children)
|
|
192
|
+
|
|
193
|
+
async def set_collapsed(self, collapsed: bool) -> None:
|
|
194
|
+
if self.collapsed == collapsed:
|
|
195
|
+
return
|
|
196
|
+
self.collapsed = collapsed
|
|
197
|
+
await self._render_result()
|
|
198
|
+
|
|
199
|
+
async def toggle_collapsed(self) -> None:
|
|
200
|
+
self.collapsed = not self.collapsed
|
|
201
|
+
await self._render_result()
|