vibecore 0.3.0__py3-none-any.whl → 0.6.2__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 (37) hide show
  1. vibecore/agents/default.py +3 -3
  2. vibecore/agents/task.py +3 -3
  3. vibecore/cli.py +67 -43
  4. vibecore/context.py +74 -11
  5. vibecore/flow.py +335 -73
  6. vibecore/handlers/stream_handler.py +35 -56
  7. vibecore/main.py +70 -272
  8. vibecore/session/jsonl_session.py +3 -1
  9. vibecore/session/loader.py +2 -2
  10. vibecore/settings.py +48 -1
  11. vibecore/tools/file/executor.py +59 -13
  12. vibecore/tools/file/tools.py +9 -9
  13. vibecore/tools/path_validator.py +251 -0
  14. vibecore/tools/python/helpers.py +2 -2
  15. vibecore/tools/python/tools.py +2 -2
  16. vibecore/tools/shell/executor.py +63 -7
  17. vibecore/tools/shell/tools.py +9 -9
  18. vibecore/tools/task/executor.py +2 -2
  19. vibecore/tools/task/tools.py +2 -2
  20. vibecore/tools/todo/manager.py +2 -10
  21. vibecore/tools/todo/models.py +5 -14
  22. vibecore/tools/todo/tools.py +5 -5
  23. vibecore/tools/webfetch/tools.py +1 -4
  24. vibecore/tools/websearch/ddgs/backend.py +1 -1
  25. vibecore/tools/websearch/tools.py +1 -4
  26. vibecore/widgets/core.py +3 -17
  27. vibecore/widgets/feedback.py +164 -0
  28. vibecore/widgets/feedback.tcss +121 -0
  29. vibecore/widgets/messages.py +22 -2
  30. vibecore/widgets/messages.tcss +28 -0
  31. vibecore/widgets/tool_messages.py +19 -4
  32. vibecore/widgets/tool_messages.tcss +23 -0
  33. {vibecore-0.3.0.dist-info → vibecore-0.6.2.dist-info}/METADATA +122 -29
  34. {vibecore-0.3.0.dist-info → vibecore-0.6.2.dist-info}/RECORD +37 -34
  35. {vibecore-0.3.0.dist-info → vibecore-0.6.2.dist-info}/WHEEL +0 -0
  36. {vibecore-0.3.0.dist-info → vibecore-0.6.2.dist-info}/entry_points.txt +0 -0
  37. {vibecore-0.3.0.dist-info → vibecore-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -2,14 +2,14 @@
2
2
 
3
3
  from agents import RunContextWrapper, function_tool
4
4
 
5
- from vibecore.context import VibecoreContext
5
+ from vibecore.context import PathValidatorContext
6
6
 
7
7
  from .executor import bash_executor, glob_files, grep_files, list_directory
8
8
 
9
9
 
10
10
  @function_tool
11
11
  async def bash(
12
- ctx: RunContextWrapper[VibecoreContext],
12
+ ctx: RunContextWrapper[PathValidatorContext],
13
13
  command: str,
14
14
  timeout: int | None = None,
15
15
  description: str | None = None,
@@ -58,7 +58,7 @@ async def bash(
58
58
  Returns:
59
59
  The command output or error message
60
60
  """
61
- output, exit_code = await bash_executor(command, timeout)
61
+ output, exit_code = await bash_executor(ctx, command, timeout)
62
62
  if exit_code != 0:
63
63
  return f"{output}\nExit code: {exit_code}"
64
64
  return output
@@ -66,7 +66,7 @@ async def bash(
66
66
 
67
67
  @function_tool
68
68
  async def glob(
69
- ctx: RunContextWrapper[VibecoreContext],
69
+ ctx: RunContextWrapper[PathValidatorContext],
70
70
  pattern: str,
71
71
  path: str | None = None,
72
72
  ) -> str:
@@ -90,7 +90,7 @@ async def glob(
90
90
  Returns:
91
91
  List of matching file paths, one per line
92
92
  """
93
- files = await glob_files(pattern, path)
93
+ files = await glob_files(ctx, pattern, path)
94
94
  if files and files[0].startswith("Error:"):
95
95
  return files[0]
96
96
  return "\n".join(files) if files else "No files found matching pattern"
@@ -98,7 +98,7 @@ async def glob(
98
98
 
99
99
  @function_tool
100
100
  async def grep(
101
- ctx: RunContextWrapper[VibecoreContext],
101
+ ctx: RunContextWrapper[PathValidatorContext],
102
102
  pattern: str,
103
103
  path: str | None = None,
104
104
  include: str | None = None,
@@ -124,7 +124,7 @@ async def grep(
124
124
  Returns:
125
125
  List of file paths containing matches, one per line
126
126
  """
127
- files = await grep_files(pattern, path, include)
127
+ files = await grep_files(ctx, pattern, path, include)
128
128
  if files and files[0].startswith("Error:"):
129
129
  return files[0]
130
130
  return "\n".join(files) if files else "No files found containing pattern"
@@ -132,7 +132,7 @@ async def grep(
132
132
 
133
133
  @function_tool
134
134
  async def ls(
135
- ctx: RunContextWrapper[VibecoreContext],
135
+ ctx: RunContextWrapper[PathValidatorContext],
136
136
  path: str,
137
137
  ignore: list[str] | None = None,
138
138
  ) -> str:
@@ -150,7 +150,7 @@ async def ls(
150
150
  Returns:
151
151
  List of entries in the directory, one per line
152
152
  """
153
- entries = await list_directory(path, ignore)
153
+ entries = await list_directory(ctx, path, ignore)
154
154
  if entries and entries[0].startswith("Error:"):
155
155
  return entries[0]
156
156
  return "\n".join(entries) if entries else "Empty directory"
@@ -8,12 +8,12 @@ from agents import (
8
8
  from textual import log
9
9
 
10
10
  from vibecore.agents.task import create_task_agent
11
- from vibecore.context import VibecoreContext
11
+ from vibecore.context import FullVibecoreContext
12
12
  from vibecore.settings import settings
13
13
 
14
14
 
15
15
  async def execute_task(
16
- context: VibecoreContext,
16
+ context: FullVibecoreContext,
17
17
  description: str,
18
18
  prompt: str,
19
19
  tool_name: str,
@@ -3,14 +3,14 @@
3
3
  from agents import function_tool
4
4
  from agents.tool_context import ToolContext
5
5
 
6
- from vibecore.context import VibecoreContext
6
+ from vibecore.context import FullVibecoreContext
7
7
 
8
8
  from .executor import execute_task
9
9
 
10
10
 
11
11
  @function_tool
12
12
  async def task(
13
- ctx: ToolContext[VibecoreContext],
13
+ ctx: ToolContext[FullVibecoreContext],
14
14
  description: str,
15
15
  prompt: str,
16
16
  ) -> str:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- from .models import TodoItem, TodoPriority, TodoStatus
5
+ from .models import TodoItem
6
6
 
7
7
 
8
8
  class TodoManager:
@@ -20,12 +20,4 @@ class TodoManager:
20
20
 
21
21
  def write(self, todos: list[dict[str, Any]]) -> None:
22
22
  """Replace the entire todo list."""
23
- self.todos = [
24
- TodoItem(
25
- id=todo["id"],
26
- content=todo["content"],
27
- status=TodoStatus(todo["status"]),
28
- priority=TodoPriority(todo["priority"]),
29
- )
30
- for todo in todos
31
- ]
23
+ self.todos = [TodoItem(**todo) for todo in todos]
@@ -1,10 +1,9 @@
1
1
  """Todo data models."""
2
2
 
3
3
  import uuid
4
- from dataclasses import dataclass, field
5
4
  from enum import Enum
6
5
 
7
- from pydantic import BaseModel
6
+ from pydantic import BaseModel, Field
8
7
 
9
8
 
10
9
  class TodoStatus(str, Enum):
@@ -19,18 +18,10 @@ class TodoPriority(str, Enum):
19
18
  LOW = "low"
20
19
 
21
20
 
22
- @dataclass
23
- class TodoItem:
24
- content: str
25
- status: TodoStatus
26
- priority: TodoPriority
27
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
28
-
29
-
30
- class TodoItemModel(BaseModel):
21
+ class TodoItem(BaseModel):
31
22
  """Pydantic model for todo items."""
32
23
 
33
- id: str
24
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
34
25
  content: str
35
- status: str
36
- priority: str
26
+ status: TodoStatus
27
+ priority: TodoPriority
@@ -4,13 +4,13 @@ from typing import Any
4
4
 
5
5
  from agents import RunContextWrapper, function_tool
6
6
 
7
- from vibecore.context import VibecoreContext
7
+ from vibecore.context import TodoToolContext
8
8
 
9
- from .models import TodoItemModel
9
+ from .models import TodoItem
10
10
 
11
11
 
12
12
  @function_tool
13
- async def todo_read(ctx: RunContextWrapper[VibecoreContext]) -> list[dict[str, Any]]:
13
+ async def todo_read(ctx: RunContextWrapper[TodoToolContext]) -> list[dict[str, Any]]:
14
14
  """Use this tool to read the current to-do list for the session. This tool should be used proactively and
15
15
  frequently to ensure that you are aware of the status of the current task list. You should make use of this
16
16
  tool as often as possible, especially in the following situations:
@@ -38,7 +38,7 @@ async def todo_read(ctx: RunContextWrapper[VibecoreContext]) -> list[dict[str, A
38
38
 
39
39
 
40
40
  @function_tool
41
- async def todo_write(ctx: RunContextWrapper[VibecoreContext], todos: list[TodoItemModel]) -> str:
41
+ async def todo_write(ctx: RunContextWrapper[TodoToolContext], todos: list[TodoItem]) -> str:
42
42
  """Use this tool to create and manage a structured task list for your current coding session. This helps you
43
43
  track progress, organize complex tasks, and demonstrate thoroughness to the user. It also helps the user
44
44
  understand the progress of the task and overall progress of their requests.
@@ -106,6 +106,6 @@ async def todo_write(ctx: RunContextWrapper[VibecoreContext], todos: list[TodoIt
106
106
  Success message.
107
107
  """
108
108
  # Convert Pydantic models to dicts for the implementation
109
- todos_dict = [todo.model_dump() for todo in todos]
109
+ todos_dict = [todo.model_dump() if isinstance(todo, TodoItem) else TodoItem(**todo).model_dump() for todo in todos]
110
110
  ctx.context.todo_manager.write(todos_dict)
111
111
  return "Todo list updated successfully."
@@ -1,8 +1,6 @@
1
1
  """Webfetch tool for Vibecore agents."""
2
2
 
3
- from agents import RunContextWrapper, function_tool
4
-
5
- from vibecore.context import VibecoreContext
3
+ from agents import function_tool
6
4
 
7
5
  from .executor import fetch_url
8
6
  from .models import WebFetchParams
@@ -10,7 +8,6 @@ from .models import WebFetchParams
10
8
 
11
9
  @function_tool
12
10
  async def webfetch(
13
- ctx: RunContextWrapper[VibecoreContext],
14
11
  url: str,
15
12
  timeout: int = 30,
16
13
  follow_redirects: bool = True,
@@ -31,7 +31,7 @@ class DDGSBackend(WebSearchBackend):
31
31
 
32
32
  # Perform search (synchronous call)
33
33
  # ddgs.text() expects 'query' as first positional argument
34
- raw_results = ddgs.text(
34
+ raw_results = ddgs.text( # type: ignore
35
35
  query=params.query,
36
36
  region=params.region,
37
37
  safesearch=params.safesearch,
@@ -1,8 +1,6 @@
1
1
  """Websearch tool for Vibecore agents."""
2
2
 
3
- from agents import RunContextWrapper, function_tool
4
-
5
- from vibecore.context import VibecoreContext
3
+ from agents import function_tool
6
4
 
7
5
  from .executor import perform_websearch
8
6
  from .models import SearchParams
@@ -10,7 +8,6 @@ from .models import SearchParams
10
8
 
11
9
  @function_tool
12
10
  async def websearch(
13
- ctx: RunContextWrapper[VibecoreContext],
14
11
  query: str,
15
12
  max_results: int = 5,
16
13
  region: str | None = None,
vibecore/widgets/core.py CHANGED
@@ -5,7 +5,6 @@ from typing import ClassVar
5
5
  from textual import events
6
6
  from textual.app import ComposeResult
7
7
  from textual.containers import Horizontal, ScrollableContainer, Vertical
8
- from textual.geometry import Size
9
8
  from textual.message import Message
10
9
  from textual.widget import Widget
11
10
  from textual.widgets import Footer, ProgressBar, Static, TextArea
@@ -94,10 +93,10 @@ class MyTextArea(TextArea):
94
93
  history = []
95
94
 
96
95
  # First, load history from the session
97
- if app.session:
96
+ if app.runner.session:
98
97
  try:
99
98
  # Get all items from the session
100
- session_items = await app.session.get_items()
99
+ session_items = await app.runner.session.get_items()
101
100
  for item in session_items:
102
101
  # Filter for user messages
103
102
  if isinstance(item, dict):
@@ -113,13 +112,6 @@ class MyTextArea(TextArea):
113
112
 
114
113
  log(f"Error loading session history: {e}")
115
114
 
116
- # Then add current session's messages (in memory, not yet persisted)
117
- for item in app.input_items:
118
- if isinstance(item, dict) and item.get("role") == "user":
119
- content = item.get("content")
120
- if isinstance(content, str) and content not in history:
121
- history.append(content)
122
-
123
115
  return history
124
116
  return []
125
117
 
@@ -218,13 +210,7 @@ class MyTextArea(TextArea):
218
210
  class MainScroll(ScrollableContainer):
219
211
  """A container with vertical layout and an automatic scrollbar on the Y axis."""
220
212
 
221
- def watch_virtual_size(self, size: Size) -> None:
222
- """Scroll to the bottom when resized = when new content is added."""
223
- # If the scroll is near the end, keep the scroll sticky to the end
224
- epsilon = 30
225
- in_the_end = (size.height - (self.scroll_target_y + self.scrollable_size.height)) < epsilon
226
- if size.height > self.scrollable_size.height and in_the_end:
227
- self.scroll_end(animate=False)
213
+ ...
228
214
 
229
215
 
230
216
  class LoadingWidget(Widget):
@@ -0,0 +1,164 @@
1
+ """Feedback widget for collecting user feedback."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.content import Content
6
+ from textual.message import Message
7
+ from textual.reactive import reactive
8
+ from textual.widgets import Button, Checkbox, Static, TextArea
9
+
10
+ from vibecore.widgets.messages import BaseMessage, MessageHeader, MessageStatus
11
+
12
+
13
+ class FeedbackWidget(BaseMessage):
14
+ """A widget to collect user feedback with Good/Bad rating and optional text comment."""
15
+
16
+ class FeedbackSubmitted(Message):
17
+ """Event emitted when feedback is submitted."""
18
+
19
+ def __init__(self, rating: str, comment: str, criteria: dict[str, bool]) -> None:
20
+ """
21
+ Construct a FeedbackSubmitted message.
22
+
23
+ Args:
24
+ rating: The rating ("good" or "bad").
25
+ comment: Optional text comment from the user.
26
+ criteria: Dict of criterion name to boolean value.
27
+ """
28
+ super().__init__()
29
+ self.rating = rating
30
+ self.comment = comment
31
+ self.criteria = criteria
32
+
33
+ rating: reactive[str | None] = reactive(None)
34
+ comment: reactive[str] = reactive("")
35
+ submitted: reactive[bool] = reactive(False)
36
+ show_comment_input: reactive[bool] = reactive(False)
37
+ show_criteria: reactive[bool] = reactive(False)
38
+
39
+ def __init__(self, prompt: str = "How was this response?", **kwargs) -> None:
40
+ """
41
+ Construct a FeedbackWidget.
42
+
43
+ Args:
44
+ prompt: The prompt text to display.
45
+ **kwargs: Additional keyword arguments for Widget.
46
+ """
47
+ super().__init__(status=MessageStatus.IDLE, **kwargs)
48
+ self.prompt = prompt
49
+ self.add_class("feedback-widget")
50
+
51
+ def get_header_params(self) -> tuple[str, str, bool]:
52
+ """Get parameters for MessageHeader."""
53
+ return ("⏺", self.prompt, False)
54
+
55
+ def compose(self) -> ComposeResult:
56
+ """Create child widgets for the feedback widget."""
57
+ yield MessageHeader("⏺", self.prompt, status=self.status)
58
+
59
+ with Horizontal(classes="feedback-controls"):
60
+ yield Button("👍 Good", id="feedback-good", classes="feedback-button good-button", variant="success")
61
+ yield Button("👎 Bad", id="feedback-bad", classes="feedback-button bad-button", variant="error")
62
+
63
+ # Feedback form containing criteria, comment area, and submit button (shown after rating selection)
64
+ with Vertical(id="feedback-form", classes="feedback-form"):
65
+ # Structured feedback criteria checkboxes
66
+ with Vertical(classes="feedback-criteria"):
67
+ yield Static("Please check any criteria that apply:", classes="criteria-label")
68
+ yield Checkbox("Factual accuracy - Was the information correct?", id="criteria-accuracy")
69
+ yield Checkbox("Task completion - Did it fully address the request?", id="criteria-completion")
70
+ yield Checkbox(
71
+ "Instruction following - Did it follow specific constraints or requirements?",
72
+ id="criteria-instructions",
73
+ )
74
+ yield Checkbox(
75
+ "Good format/structure - Are the format and structure appropriate?", id="criteria-format"
76
+ )
77
+
78
+ # Comment text area
79
+ with Vertical(classes="feedback-comment-area"):
80
+ yield Static("Optional comment:", classes="comment-label")
81
+ yield TextArea(id="feedback-textarea", classes="feedback-textarea", show_line_numbers=False)
82
+
83
+ # Submit button
84
+ yield Button("Submit", id="feedback-submit", classes="feedback-submit-button", variant="primary")
85
+
86
+ with Vertical(id="feedback-result", classes="feedback-result"):
87
+ yield Static("", id="feedback-result-text")
88
+
89
+ def on_mount(self) -> None:
90
+ """Initialize widget state on mount."""
91
+ # Hide feedback form and result initially
92
+ self.query_one("#feedback-form").display = False
93
+ self.query_one("#feedback-result").display = False
94
+
95
+ def on_button_pressed(self, event: Button.Pressed) -> None:
96
+ """Handle button press events."""
97
+ button_id = event.button.id
98
+
99
+ if self.submitted:
100
+ return # Ignore clicks after submission
101
+
102
+ if button_id == "feedback-good":
103
+ self.rating = "good"
104
+ self._show_feedback_form()
105
+ event.button.add_class("selected")
106
+ self.query_one("#feedback-bad").remove_class("selected")
107
+
108
+ elif button_id == "feedback-bad":
109
+ self.rating = "bad"
110
+ self._show_feedback_form()
111
+ event.button.add_class("selected")
112
+ self.query_one("#feedback-good").remove_class("selected")
113
+
114
+ elif button_id == "feedback-submit":
115
+ self._submit_feedback()
116
+
117
+ def _show_feedback_form(self) -> None:
118
+ """Show the feedback form (criteria, comment area, and submit button)."""
119
+ self.show_criteria = True
120
+ self.show_comment_input = True
121
+ feedback_form = self.query_one("#feedback-form")
122
+ feedback_form.display = True
123
+ feedback_form.refresh()
124
+
125
+ def _get_criteria_values(self) -> dict[str, bool]:
126
+ """Get the current state of all criteria checkboxes."""
127
+ return {
128
+ "accuracy": self.query_one("#criteria-accuracy", Checkbox).value,
129
+ "completion": self.query_one("#criteria-completion", Checkbox).value,
130
+ "instructions": self.query_one("#criteria-instructions", Checkbox).value,
131
+ "format": self.query_one("#criteria-format", Checkbox).value,
132
+ }
133
+
134
+ def _submit_feedback(self) -> None:
135
+ """Submit the feedback."""
136
+ if not self.rating:
137
+ # Require a rating before submission
138
+ return
139
+
140
+ self.submitted = True
141
+ self.comment = self.query_one("#feedback-textarea", TextArea).text
142
+ criteria = self._get_criteria_values()
143
+
144
+ # Hide controls and form, show result
145
+ self.query_one(".feedback-controls").display = False
146
+ self.query_one("#feedback-form").display = False
147
+
148
+ # Show result
149
+ result_container = self.query_one("#feedback-result")
150
+ result_text = self.query_one("#feedback-result-text", Static)
151
+
152
+ rating_emoji = "👍" if self.rating == "good" else "👎"
153
+ result_msg = f"{rating_emoji} Thank you for your feedback!"
154
+ if self.comment:
155
+ result_msg += f"\nComment: {self.comment}"
156
+
157
+ result_text.update(Content(result_msg))
158
+ result_container.display = True
159
+
160
+ # Update status to success
161
+ self.status = MessageStatus.SUCCESS
162
+
163
+ # Emit feedback event
164
+ self.post_message(self.FeedbackSubmitted(self.rating, self.comment, criteria))
@@ -0,0 +1,121 @@
1
+ /* Feedback widget styles */
2
+
3
+ FeedbackWidget {
4
+ color: $text;
5
+
6
+ .feedback-controls {
7
+ height: auto;
8
+ width: 1fr;
9
+ padding: 0 0 0 4;
10
+ margin-top: 1;
11
+
12
+ .feedback-button {
13
+ height: 3;
14
+ min-width: 12;
15
+ margin-right: 1;
16
+
17
+ &.selected {
18
+ text-style: bold;
19
+ border: heavy;
20
+ }
21
+
22
+ &.good-button {
23
+ background: $success;
24
+ color: $text;
25
+
26
+ &:hover {
27
+ background: $success-lighten-1;
28
+ }
29
+
30
+ &.selected {
31
+ background: $success-darken-1;
32
+ }
33
+ }
34
+
35
+ &.bad-button {
36
+ background: $error;
37
+ color: $text;
38
+
39
+ &:hover {
40
+ background: $error-lighten-1;
41
+ }
42
+
43
+ &.selected {
44
+ background: $error-darken-1;
45
+ }
46
+ }
47
+ }
48
+
49
+ }
50
+
51
+ .feedback-form {
52
+ height: auto;
53
+ width: 1fr;
54
+ padding: 0 0 0 4;
55
+ margin-top: 1;
56
+
57
+ .feedback-criteria {
58
+ height: auto;
59
+ width: 1fr;
60
+
61
+ .criteria-label {
62
+ color: $text-muted;
63
+ margin-bottom: 0;
64
+ height: 1;
65
+ }
66
+
67
+ Checkbox {
68
+ border: none;
69
+ margin: 0;
70
+ padding: 0;
71
+ color: $text;
72
+
73
+ &:focus {
74
+ background: $panel;
75
+ }
76
+ }
77
+ }
78
+
79
+ .feedback-comment-area {
80
+ height: auto;
81
+ width: 1fr;
82
+ margin-top: 1;
83
+
84
+ .comment-label {
85
+ color: $text-muted;
86
+ margin-bottom: 0;
87
+ height: 1;
88
+ }
89
+
90
+ .feedback-textarea {
91
+ height: 6;
92
+ width: 1fr;
93
+ border: tall $primary;
94
+ }
95
+ }
96
+
97
+ .feedback-submit-button {
98
+ height: 3;
99
+ min-width: 10;
100
+ margin-top: 1;
101
+ background: $primary;
102
+ color: $text;
103
+
104
+ &:hover {
105
+ background: $primary-lighten-1;
106
+ }
107
+ }
108
+ }
109
+
110
+ .feedback-result {
111
+ height: auto;
112
+ width: 1fr;
113
+ padding: 1 0 0 4;
114
+ margin-top: 1;
115
+ color: $success;
116
+
117
+ #feedback-result-text {
118
+ color: $success;
119
+ }
120
+ }
121
+ }
@@ -1,10 +1,12 @@
1
+ import os
1
2
  from enum import StrEnum
2
3
 
3
4
  from textual.app import ComposeResult
5
+ from textual.containers import Horizontal
4
6
  from textual.content import Content
5
7
  from textual.reactive import reactive
6
8
  from textual.widget import Widget
7
- from textual.widgets import Markdown, Static
9
+ from textual.widgets import Button, Markdown, Static
8
10
 
9
11
 
10
12
  class MessageStatus(StrEnum):
@@ -83,11 +85,16 @@ class MessageHeader(Widget):
83
85
  # self.query_one(".prefix").visible = self._prefix_visible
84
86
 
85
87
  def _on_mount(self, event) -> None:
88
+ disable_blink = bool(os.environ.get("TEXTUAL_SNAPSHOT_TEMPDIR"))
86
89
  self.blink_timer = self.set_interval(
87
90
  0.5,
88
91
  self._toggle_cursor_blink_visible,
89
- pause=(self.status != MessageStatus.EXECUTING),
92
+ pause=(self.status != MessageStatus.EXECUTING) or disable_blink,
90
93
  )
94
+ # Ensure the prefix starts visible for executing statuses so snapshot tests
95
+ # and initial renders see the indicator before the first timer tick hides it.
96
+ if self.status == MessageStatus.EXECUTING:
97
+ self._prefix_visible = True
91
98
 
92
99
 
93
100
  class BaseMessage(Widget):
@@ -167,6 +174,19 @@ class AgentMessage(BaseMessage):
167
174
  """Get parameters for MessageHeader."""
168
175
  return ("⏺", self.text, True)
169
176
 
177
+ def compose(self) -> ComposeResult:
178
+ """Create child widgets for the agent message."""
179
+ prefix, text, use_markdown = self.get_header_params()
180
+ with Horizontal(classes="agent-message-header"):
181
+ yield MessageHeader(prefix, text, status=self.status, use_markdown=use_markdown)
182
+ yield Button("Copy", classes="copy-button", variant="primary")
183
+
184
+ def on_button_pressed(self, event: Button.Pressed) -> None:
185
+ """Handle button press events."""
186
+ if event.button.has_class("copy-button"):
187
+ # Copy the markdown text to clipboard
188
+ self.app.copy_to_clipboard(self.text)
189
+
170
190
  def update(self, text: str, status: MessageStatus | None = None) -> None:
171
191
  """Update the text of the agent message."""
172
192
  self.text = text
@@ -62,6 +62,34 @@ UserMessage {
62
62
 
63
63
  AgentMessage {
64
64
  color: $text;
65
+
66
+ Horizontal.agent-message-header {
67
+ height: auto;
68
+ width: 1fr;
69
+ layers: main button;
70
+
71
+ .copy-button {
72
+ layer: button;
73
+ dock: right;
74
+ height: 1;
75
+ width: 8;
76
+ min-width: 8;
77
+ margin: 0;
78
+ padding: 0;
79
+ background: $secondary;
80
+ color: $text;
81
+ border: none;
82
+
83
+ &:hover {
84
+ background: $secondary-lighten-1;
85
+ }
86
+
87
+ &:focus {
88
+ background: $secondary-lighten-1;
89
+ text-style: bold;
90
+ }
91
+ }
92
+ }
65
93
  }
66
94
 
67
95
  SystemMessage {