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.
Files changed (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. 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()