gobby 0.2.7__py3-none-any.whl → 0.2.8__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 (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -1,289 +0,0 @@
1
- """Workflows screen with active workflow state visualization."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import logging
7
- from typing import Any
8
-
9
- from textual.app import ComposeResult
10
- from textual.containers import Container, Horizontal, Vertical
11
- from textual.reactive import reactive
12
- from textual.widget import Widget
13
- from textual.widgets import (
14
- Button,
15
- DataTable,
16
- LoadingIndicator,
17
- Select,
18
- Static,
19
- )
20
-
21
- from gobby.tui.api_client import GobbyAPIClient
22
- from gobby.tui.ws_client import GobbyWebSocketClient
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
-
27
- class WorkflowStatePanel(Widget):
28
- """Panel showing current workflow state."""
29
-
30
- DEFAULT_CSS = """
31
- WorkflowStatePanel {
32
- height: auto;
33
- padding: 1;
34
- border: round #7c3aed;
35
- margin: 1;
36
- }
37
-
38
- WorkflowStatePanel .state-title {
39
- text-style: bold;
40
- color: #a78bfa;
41
- padding-bottom: 1;
42
- }
43
-
44
- WorkflowStatePanel .state-row {
45
- layout: horizontal;
46
- height: 1;
47
- }
48
-
49
- WorkflowStatePanel .state-label {
50
- color: #a6adc8;
51
- width: 16;
52
- }
53
-
54
- WorkflowStatePanel .state-value {
55
- width: 1fr;
56
- }
57
-
58
- WorkflowStatePanel .state-active {
59
- color: #22c55e;
60
- }
61
-
62
- WorkflowStatePanel .state-inactive {
63
- color: #6c7086;
64
- }
65
- """
66
-
67
- workflow_status: reactive[dict[str, Any] | None] = reactive(None)
68
-
69
- def compose(self) -> ComposeResult:
70
- yield Static("⚙️ Active Workflow", classes="state-title")
71
-
72
- if self.workflow_status is None:
73
- yield Static("No workflow active", classes="state-inactive")
74
- else:
75
- workflow = self.workflow_status.get("workflow")
76
- if workflow:
77
- with Horizontal(classes="state-row"):
78
- yield Static("Workflow:", classes="state-label")
79
- yield Static(
80
- workflow.get("name", "Unknown"), classes="state-value state-active"
81
- )
82
- with Horizontal(classes="state-row"):
83
- yield Static("Current Step:", classes="state-label")
84
- yield Static(workflow.get("current_step", "N/A"), classes="state-value")
85
- with Horizontal(classes="state-row"):
86
- yield Static("Type:", classes="state-label")
87
- yield Static(workflow.get("type", "unknown"), classes="state-value")
88
- else:
89
- yield Static("No workflow active", classes="state-inactive")
90
-
91
- def watch_workflow_status(self, status: dict[str, Any] | None) -> None:
92
- """Recompose when status changes."""
93
- asyncio.create_task(self.recompose())
94
-
95
-
96
- class WorkflowsScreen(Widget):
97
- """Workflows screen showing workflow state and controls."""
98
-
99
- DEFAULT_CSS = """
100
- WorkflowsScreen {
101
- width: 1fr;
102
- height: 1fr;
103
- }
104
-
105
- WorkflowsScreen .screen-header {
106
- height: auto;
107
- padding: 1;
108
- background: #313244;
109
- }
110
-
111
- WorkflowsScreen .header-row {
112
- layout: horizontal;
113
- }
114
-
115
- WorkflowsScreen .panel-title {
116
- text-style: bold;
117
- color: #a78bfa;
118
- width: 1fr;
119
- }
120
-
121
- WorkflowsScreen #workflow-selector {
122
- width: 30;
123
- margin-right: 1;
124
- }
125
-
126
- WorkflowsScreen .content-area {
127
- height: 1fr;
128
- padding: 1;
129
- }
130
-
131
- WorkflowsScreen #available-workflows {
132
- height: 1fr;
133
- }
134
-
135
- WorkflowsScreen .loading-container {
136
- width: 1fr;
137
- height: 1fr;
138
- content-align: center middle;
139
- }
140
- """
141
-
142
- loading = reactive(True)
143
- workflow_status: reactive[dict[str, Any] | None] = reactive(None)
144
- available_workflows: reactive[list[str]] = reactive(list)
145
-
146
- def __init__(
147
- self,
148
- api_client: GobbyAPIClient,
149
- ws_client: GobbyWebSocketClient,
150
- **kwargs: Any,
151
- ) -> None:
152
- super().__init__(**kwargs)
153
- self.api_client = api_client
154
- self.ws_client = ws_client
155
-
156
- def compose(self) -> ComposeResult:
157
- with Vertical(classes="screen-header"):
158
- with Horizontal(classes="header-row"):
159
- yield Static("🔄 Workflows", classes="panel-title")
160
- yield Select(
161
- [(name, name) for name in self.available_workflows] or [("None", "")],
162
- value="",
163
- id="workflow-selector",
164
- )
165
- yield Button("Activate", variant="primary", id="btn-activate")
166
- yield Button("Clear", id="btn-clear")
167
- yield Button("Refresh", id="btn-refresh")
168
-
169
- if self.loading:
170
- with Container(classes="loading-container"):
171
- yield LoadingIndicator()
172
- else:
173
- with Vertical(classes="content-area"):
174
- yield WorkflowStatePanel(id="state-panel")
175
- yield Static("Available Workflows", classes="panel-title")
176
- yield DataTable(id="available-workflows")
177
-
178
- async def on_mount(self) -> None:
179
- """Load data when mounted."""
180
- await self.refresh_data()
181
-
182
- async def refresh_data(self) -> None:
183
- """Refresh workflow status."""
184
- workflows: list[dict[str, Any]] = []
185
- try:
186
- async with GobbyAPIClient(self.api_client.base_url) as client:
187
- status = await client.get_workflow_status()
188
- self.workflow_status = status
189
-
190
- # Get available workflows list
191
- result = await client.call_tool(
192
- "gobby-workflows",
193
- "list_workflows",
194
- {},
195
- )
196
- workflows = result.get("workflows", [])
197
- self.available_workflows = [w.get("name", "") for w in workflows if w.get("name")]
198
-
199
- except Exception as e:
200
- self.notify(f"Failed to load workflow status: {e}", severity="error")
201
- finally:
202
- self.loading = False
203
- await self.recompose()
204
- await self._update_state_panel()
205
- await self._setup_table(workflows)
206
-
207
- async def _update_state_panel(self) -> None:
208
- """Update the workflow state panel."""
209
- try:
210
- panel = self.query_one("#state-panel", WorkflowStatePanel)
211
- panel.workflow_status = self.workflow_status
212
- except Exception:
213
- logger.debug("Failed to query state panel during async update", exc_info=True) # nosec B110
214
-
215
- async def _setup_table(self, workflows: list[dict[str, Any]] | None = None) -> None:
216
- """Set up the available workflows table."""
217
- try:
218
- table = self.query_one("#available-workflows", DataTable)
219
- table.clear(columns=True)
220
- table.add_columns("Name", "Type", "Description")
221
- table.cursor_type = "row"
222
-
223
- # Use provided workflows instead of fetching again
224
- if workflows is None:
225
- workflows = []
226
-
227
- for wf in workflows:
228
- name = wf.get("name", "Unknown")
229
- wf_type = wf.get("type", "unknown")
230
- desc = wf.get("description", "")[:50]
231
- table.add_row(name, wf_type, desc, key=name)
232
-
233
- except Exception:
234
- pass # nosec B110 - TUI update failure is non-critical
235
-
236
- async def on_button_pressed(self, event: Button.Pressed) -> None:
237
- """Handle button presses."""
238
- button_id = event.button.id
239
-
240
- if button_id == "btn-activate":
241
- await self._activate_workflow()
242
-
243
- elif button_id == "btn-clear":
244
- await self._clear_workflow()
245
-
246
- elif button_id == "btn-refresh":
247
- self.loading = True
248
- await self.refresh_data()
249
-
250
- async def _activate_workflow(self) -> None:
251
- """Activate the selected workflow."""
252
- try:
253
- selector = self.query_one("#workflow-selector", Select)
254
- workflow_name = str(selector.value)
255
-
256
- if not workflow_name:
257
- self.notify("Select a workflow first", severity="warning")
258
- return
259
-
260
- async with GobbyAPIClient(self.api_client.base_url) as client:
261
- await client.activate_workflow(workflow_name)
262
- self.notify(f"Activated workflow: {workflow_name}")
263
-
264
- await self.refresh_data()
265
-
266
- except Exception as e:
267
- self.notify(f"Failed to activate workflow: {e}", severity="error")
268
-
269
- async def _clear_workflow(self) -> None:
270
- """Clear the active workflow."""
271
- try:
272
- async with GobbyAPIClient(self.api_client.base_url) as client:
273
- await client.call_tool(
274
- "gobby-workflows",
275
- "deactivate_workflow",
276
- {},
277
- )
278
- self.notify("Workflow cleared")
279
-
280
- await self.refresh_data()
281
-
282
- except Exception as e:
283
- self.notify(f"Failed to clear workflow: {e}", severity="error")
284
-
285
- def on_ws_event(self, event_type: str, data: dict[str, Any]) -> None:
286
- """Handle WebSocket events."""
287
- # Refresh on workflow-related events
288
- if event_type == "hook_event":
289
- asyncio.create_task(self.refresh_data())
@@ -1,174 +0,0 @@
1
- """Worktrees screen with git worktree management."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- from typing import Any
7
-
8
- from textual.app import ComposeResult
9
- from textual.containers import Container, Horizontal, Vertical
10
- from textual.reactive import reactive
11
- from textual.widget import Widget
12
- from textual.widgets import (
13
- Button,
14
- DataTable,
15
- LoadingIndicator,
16
- Static,
17
- )
18
-
19
- from gobby.tui.api_client import GobbyAPIClient
20
- from gobby.tui.ws_client import GobbyWebSocketClient
21
-
22
-
23
- class WorktreesScreen(Widget):
24
- """Worktrees screen showing git worktree status."""
25
-
26
- DEFAULT_CSS = """
27
- WorktreesScreen {
28
- width: 1fr;
29
- height: 1fr;
30
- }
31
-
32
- WorktreesScreen .screen-header {
33
- height: auto;
34
- padding: 1;
35
- background: #313244;
36
- }
37
-
38
- WorktreesScreen .header-row {
39
- layout: horizontal;
40
- }
41
-
42
- WorktreesScreen .panel-title {
43
- text-style: bold;
44
- color: #a78bfa;
45
- width: 1fr;
46
- }
47
-
48
- WorktreesScreen #worktrees-table {
49
- height: 1fr;
50
- }
51
-
52
- WorktreesScreen .loading-container {
53
- width: 1fr;
54
- height: 1fr;
55
- content-align: center middle;
56
- }
57
-
58
- WorktreesScreen .empty-state {
59
- content-align: center middle;
60
- height: 1fr;
61
- color: #a6adc8;
62
- }
63
- """
64
-
65
- loading = reactive(True)
66
- worktrees: reactive[list[dict[str, Any]]] = reactive(list)
67
- selected_worktree_id: reactive[str | None] = reactive(None)
68
-
69
- def __init__(
70
- self,
71
- api_client: GobbyAPIClient,
72
- ws_client: GobbyWebSocketClient,
73
- **kwargs: Any,
74
- ) -> None:
75
- super().__init__(**kwargs)
76
- self.api_client = api_client
77
- self.ws_client = ws_client
78
-
79
- def compose(self) -> ComposeResult:
80
- with Vertical(classes="screen-header"):
81
- with Horizontal(classes="header-row"):
82
- yield Static("🌳 Worktrees", classes="panel-title")
83
- yield Button("+ Create", variant="primary", id="btn-create")
84
- yield Button("Cleanup", id="btn-cleanup")
85
- yield Button("Refresh", id="btn-refresh")
86
-
87
- if self.loading:
88
- with Container(classes="loading-container"):
89
- yield LoadingIndicator()
90
- elif not self.worktrees:
91
- yield Static(
92
- "No worktrees found. Create one to enable parallel development.",
93
- classes="empty-state",
94
- )
95
- else:
96
- yield DataTable(id="worktrees-table")
97
-
98
- async def on_mount(self) -> None:
99
- """Load data when mounted."""
100
- await self.refresh_data()
101
-
102
- async def refresh_data(self) -> None:
103
- """Refresh worktree list."""
104
- try:
105
- worktrees = await self.api_client.list_worktrees()
106
- self.worktrees = worktrees
107
- except Exception as e:
108
- self.notify(f"Failed to load worktrees: {e}", severity="error")
109
- finally:
110
- self.loading = False
111
- await self.recompose()
112
- await self._setup_table()
113
-
114
- async def _setup_table(self) -> None:
115
- """Set up and populate the worktrees table."""
116
- if not self.worktrees:
117
- return
118
-
119
- try:
120
- table = self.query_one("#worktrees-table", DataTable)
121
- table.clear(columns=True)
122
- table.add_columns("ID", "Branch", "Status", "Task", "Path")
123
- table.cursor_type = "row"
124
-
125
- for wt in self.worktrees:
126
- wt_id = wt.get("id", "")[:12]
127
- branch = wt.get("branch_name", "N/A")
128
- status = wt.get("status", "unknown")
129
- task_id = wt.get("task_id", "-")[:12] if wt.get("task_id") else "-"
130
- wt_path = wt.get("path", "")
131
- path = wt_path[-30:] if len(wt_path) > 30 else wt_path
132
- table.add_row(wt_id, branch, status, task_id, path, key=wt.get("id"))
133
-
134
- except Exception:
135
- pass # nosec B110 - TUI update failure is non-critical
136
-
137
- def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
138
- """Handle worktree selection."""
139
- self.selected_worktree_id = str(event.row_key.value) if event.row_key else None
140
-
141
- async def on_button_pressed(self, event: Button.Pressed) -> None:
142
- """Handle button presses."""
143
- button_id = event.button.id
144
-
145
- if button_id == "btn-create":
146
- self.notify("Worktree creation dialog coming soon", severity="information")
147
-
148
- elif button_id == "btn-cleanup":
149
- await self._cleanup_worktrees()
150
-
151
- elif button_id == "btn-refresh":
152
- self.loading = True
153
- await self.refresh_data()
154
-
155
- async def _cleanup_worktrees(self) -> None:
156
- """Clean up stale worktrees."""
157
- try:
158
- result = await self.api_client.call_tool(
159
- "gobby-worktrees",
160
- "cleanup_worktrees",
161
- {},
162
- )
163
- cleaned = result.get("cleaned", 0)
164
- self.notify(f"Cleaned up {cleaned} worktrees")
165
-
166
- await self.refresh_data()
167
-
168
- except Exception as e:
169
- self.notify(f"Cleanup failed: {e}", severity="error")
170
-
171
- def on_ws_event(self, event_type: str, data: dict[str, Any]) -> None:
172
- """Handle WebSocket events."""
173
- if event_type == "worktree_event":
174
- asyncio.create_task(self.refresh_data())
@@ -1,21 +0,0 @@
1
- """TUI widgets for reusable components."""
2
-
3
- from gobby.tui.widgets.chat import ChatHistory, ChatInput
4
- from gobby.tui.widgets.conductor import HaikuDisplay, ModeIndicator
5
- from gobby.tui.widgets.menu import MenuPanel
6
- from gobby.tui.widgets.message_panel import InterAgentMessagePanel
7
- from gobby.tui.widgets.review_gate import ReviewGatePanel
8
- from gobby.tui.widgets.task_tree import TaskTree
9
- from gobby.tui.widgets.token_budget import TokenBudgetMeter
10
-
11
- __all__ = [
12
- "MenuPanel",
13
- "HaikuDisplay",
14
- "ModeIndicator",
15
- "TaskTree",
16
- "TokenBudgetMeter",
17
- "ReviewGatePanel",
18
- "InterAgentMessagePanel",
19
- "ChatHistory",
20
- "ChatInput",
21
- ]
gobby/tui/widgets/chat.py DELETED
@@ -1,210 +0,0 @@
1
- """Chat widgets for LLM interface."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from datetime import datetime
7
- from typing import Any
8
-
9
- from textual.app import ComposeResult
10
- from textual.containers import Horizontal, VerticalScroll
11
- from textual.message import Message
12
- from textual.reactive import reactive
13
- from textual.widget import Widget
14
- from textual.widgets import Button, Static, TextArea
15
-
16
-
17
- class ChatMessage(Static):
18
- """A single chat message display."""
19
-
20
- DEFAULT_CSS = """
21
- ChatMessage {
22
- padding: 1;
23
- margin-bottom: 1;
24
- height: auto;
25
- }
26
-
27
- ChatMessage.--user {
28
- margin-left: 8;
29
- background: #313244;
30
- border: round #06b6d4;
31
- }
32
-
33
- ChatMessage.--assistant {
34
- margin-right: 8;
35
- background: #313244;
36
- border: round #7c3aed;
37
- }
38
-
39
- ChatMessage.--system {
40
- margin-left: 4;
41
- margin-right: 4;
42
- background: #45475a;
43
- border: round #6c7086;
44
- }
45
-
46
- ChatMessage .message-header {
47
- layout: horizontal;
48
- height: 1;
49
- margin-bottom: 1;
50
- }
51
-
52
- ChatMessage .message-sender {
53
- text-style: bold;
54
- width: 1fr;
55
- }
56
-
57
- ChatMessage.--user .message-sender {
58
- color: #06b6d4;
59
- }
60
-
61
- ChatMessage.--assistant .message-sender {
62
- color: #a78bfa;
63
- }
64
-
65
- ChatMessage.--system .message-sender {
66
- color: #6c7086;
67
- }
68
-
69
- ChatMessage .message-time {
70
- color: #6c7086;
71
- width: auto;
72
- }
73
-
74
- ChatMessage .message-content {
75
- color: #cdd6f4;
76
- }
77
- """
78
-
79
- def __init__(
80
- self,
81
- sender: str,
82
- content: str,
83
- role: str = "user",
84
- timestamp: str | None = None,
85
- **kwargs: Any,
86
- ) -> None:
87
- super().__init__(**kwargs)
88
- self.sender = sender
89
- self.content = content
90
- self.role = role
91
- self.timestamp = timestamp or datetime.now().strftime("%H:%M")
92
- self.add_class(f"--{role}")
93
-
94
- def compose(self) -> ComposeResult:
95
- with Horizontal(classes="message-header"):
96
- yield Static(self.sender, classes="message-sender")
97
- yield Static(self.timestamp, classes="message-time")
98
- yield Static(self.content, classes="message-content")
99
-
100
-
101
- class ChatHistory(VerticalScroll):
102
- """Scrollable chat history container."""
103
-
104
- DEFAULT_CSS = """
105
- ChatHistory {
106
- height: 1fr;
107
- padding: 1;
108
- background: #1e1e2e;
109
- }
110
- """
111
-
112
- messages: reactive[list[dict[str, Any]]] = reactive(list)
113
-
114
- def add_message(
115
- self,
116
- sender: str,
117
- content: str,
118
- role: str = "user",
119
- timestamp: str | None = None,
120
- ) -> None:
121
- """Add a new message to the chat history."""
122
- # Store message data
123
- new_messages = list(self.messages)
124
- new_messages.append(
125
- {
126
- "sender": sender,
127
- "content": content,
128
- "role": role,
129
- "timestamp": timestamp or datetime.now().strftime("%H:%M"),
130
- }
131
- )
132
- self.messages = new_messages
133
-
134
- # Mount the widget
135
- message = ChatMessage(sender, content, role, timestamp)
136
- self.mount(message)
137
- self.scroll_end(animate=False)
138
-
139
- def clear_history(self) -> None:
140
- """Clear all messages."""
141
- self.messages = []
142
- self.remove_children()
143
-
144
-
145
- class ChatInput(Widget):
146
- """Chat input widget with send button."""
147
-
148
- DEFAULT_CSS = """
149
- ChatInput {
150
- height: auto;
151
- min-height: 4;
152
- max-height: 10;
153
- padding: 1;
154
- border-top: solid #45475a;
155
- background: #313244;
156
- }
157
-
158
- ChatInput .input-row {
159
- layout: horizontal;
160
- height: auto;
161
- }
162
-
163
- ChatInput #message-input {
164
- width: 1fr;
165
- height: auto;
166
- min-height: 3;
167
- margin-right: 1;
168
- }
169
-
170
- ChatInput #send-button {
171
- width: 10;
172
- height: 3;
173
- }
174
- """
175
-
176
- @dataclass
177
- class Submitted(Message):
178
- """Message sent when user submits input."""
179
-
180
- text: str
181
-
182
- def compose(self) -> ComposeResult:
183
- with Horizontal(classes="input-row"):
184
- yield TextArea(id="message-input")
185
- yield Button("Send", variant="primary", id="send-button")
186
-
187
- def on_button_pressed(self, event: Button.Pressed) -> None:
188
- """Handle send button press."""
189
- if event.button.id == "send-button":
190
- self._submit()
191
-
192
- def _submit(self) -> None:
193
- """Submit the current input."""
194
- text_area = self.query_one("#message-input", TextArea)
195
- text = text_area.text.strip()
196
- if text:
197
- self.post_message(self.Submitted(text))
198
- text_area.clear()
199
-
200
- def get_text(self) -> str:
201
- """Get the current input text."""
202
- return self.query_one("#message-input", TextArea).text
203
-
204
- def clear(self) -> None:
205
- """Clear the input."""
206
- self.query_one("#message-input", TextArea).clear()
207
-
208
- def focus_input(self) -> None:
209
- """Focus the input field."""
210
- self.query_one("#message-input", TextArea).focus()