gobby 0.2.7__py3-none-any.whl → 0.2.9__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 (125) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +99 -61
  3. gobby/adapters/gemini.py +140 -38
  4. gobby/agents/isolation.py +130 -0
  5. gobby/agents/registry.py +11 -0
  6. gobby/agents/session.py +1 -0
  7. gobby/agents/spawn_executor.py +43 -13
  8. gobby/agents/spawners/macos.py +26 -1
  9. gobby/app_context.py +59 -0
  10. gobby/cli/__init__.py +0 -2
  11. gobby/cli/memory.py +185 -0
  12. gobby/cli/utils.py +5 -17
  13. gobby/clones/git.py +177 -0
  14. gobby/config/features.py +0 -20
  15. gobby/config/skills.py +31 -0
  16. gobby/config/tasks.py +4 -0
  17. gobby/hooks/event_handlers/__init__.py +155 -0
  18. gobby/hooks/event_handlers/_agent.py +175 -0
  19. gobby/hooks/event_handlers/_base.py +87 -0
  20. gobby/hooks/event_handlers/_misc.py +66 -0
  21. gobby/hooks/event_handlers/_session.py +573 -0
  22. gobby/hooks/event_handlers/_tool.py +196 -0
  23. gobby/hooks/hook_manager.py +21 -1
  24. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  25. gobby/llm/claude.py +377 -42
  26. gobby/mcp_proxy/importer.py +4 -41
  27. gobby/mcp_proxy/instructions.py +2 -2
  28. gobby/mcp_proxy/manager.py +13 -3
  29. gobby/mcp_proxy/registries.py +35 -4
  30. gobby/mcp_proxy/services/recommendation.py +2 -28
  31. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  32. gobby/mcp_proxy/tools/agents.py +45 -9
  33. gobby/mcp_proxy/tools/artifacts.py +46 -12
  34. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  35. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  36. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  37. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  38. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  39. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  40. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  41. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  42. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  43. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  44. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  45. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  46. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  47. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  48. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  49. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  50. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  51. gobby/mcp_proxy/tools/worktrees.py +32 -7
  52. gobby/memory/components/__init__.py +0 -0
  53. gobby/memory/components/ingestion.py +98 -0
  54. gobby/memory/components/search.py +108 -0
  55. gobby/memory/extractor.py +15 -1
  56. gobby/memory/manager.py +16 -25
  57. gobby/paths.py +51 -0
  58. gobby/prompts/loader.py +1 -35
  59. gobby/runner.py +36 -10
  60. gobby/servers/http.py +186 -149
  61. gobby/servers/routes/admin.py +12 -0
  62. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  63. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  64. gobby/servers/routes/mcp/hooks.py +50 -3
  65. gobby/servers/websocket.py +57 -1
  66. gobby/sessions/analyzer.py +4 -4
  67. gobby/sessions/manager.py +9 -0
  68. gobby/sessions/transcripts/gemini.py +100 -34
  69. gobby/skills/parser.py +23 -0
  70. gobby/skills/sync.py +5 -4
  71. gobby/storage/artifacts.py +19 -0
  72. gobby/storage/database.py +9 -2
  73. gobby/storage/memories.py +32 -21
  74. gobby/storage/migrations.py +46 -4
  75. gobby/storage/sessions.py +4 -2
  76. gobby/storage/skills.py +87 -7
  77. gobby/tasks/external_validator.py +4 -17
  78. gobby/tasks/validation.py +13 -87
  79. gobby/tools/summarizer.py +18 -51
  80. gobby/utils/status.py +13 -0
  81. gobby/workflows/actions.py +5 -0
  82. gobby/workflows/context_actions.py +21 -24
  83. gobby/workflows/detection_helpers.py +38 -24
  84. gobby/workflows/enforcement/__init__.py +11 -1
  85. gobby/workflows/enforcement/blocking.py +109 -1
  86. gobby/workflows/enforcement/handlers.py +35 -1
  87. gobby/workflows/engine.py +96 -0
  88. gobby/workflows/evaluator.py +110 -0
  89. gobby/workflows/hooks.py +41 -0
  90. gobby/workflows/lifecycle_evaluator.py +2 -1
  91. gobby/workflows/memory_actions.py +11 -0
  92. gobby/workflows/safe_evaluator.py +8 -0
  93. gobby/workflows/summary_actions.py +123 -50
  94. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  95. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
  96. gobby/cli/tui.py +0 -34
  97. gobby/hooks/event_handlers.py +0 -909
  98. gobby/mcp_proxy/tools/workflows.py +0 -973
  99. gobby/tui/__init__.py +0 -5
  100. gobby/tui/api_client.py +0 -278
  101. gobby/tui/app.py +0 -329
  102. gobby/tui/screens/__init__.py +0 -25
  103. gobby/tui/screens/agents.py +0 -333
  104. gobby/tui/screens/chat.py +0 -450
  105. gobby/tui/screens/dashboard.py +0 -377
  106. gobby/tui/screens/memory.py +0 -305
  107. gobby/tui/screens/metrics.py +0 -231
  108. gobby/tui/screens/orchestrator.py +0 -903
  109. gobby/tui/screens/sessions.py +0 -412
  110. gobby/tui/screens/tasks.py +0 -440
  111. gobby/tui/screens/workflows.py +0 -289
  112. gobby/tui/screens/worktrees.py +0 -174
  113. gobby/tui/widgets/__init__.py +0 -21
  114. gobby/tui/widgets/chat.py +0 -210
  115. gobby/tui/widgets/conductor.py +0 -104
  116. gobby/tui/widgets/menu.py +0 -132
  117. gobby/tui/widgets/message_panel.py +0 -160
  118. gobby/tui/widgets/review_gate.py +0 -224
  119. gobby/tui/widgets/task_tree.py +0 -99
  120. gobby/tui/widgets/token_budget.py +0 -166
  121. gobby/tui/ws_client.py +0 -258
  122. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  123. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  124. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  125. {gobby-0.2.7.dist-info → gobby-0.2.9.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()