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.
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/gemini.py +140 -38
- gobby/agents/isolation.py +130 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn_executor.py +43 -13
- gobby/agents/spawners/macos.py +26 -1
- gobby/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/clones/git.py +177 -0
- gobby/config/skills.py +31 -0
- gobby/hooks/event_handlers.py +109 -10
- gobby/hooks/hook_manager.py +19 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/registries.py +21 -4
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
- gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
- gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
- gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
- gobby/mcp_proxy/tools/spawn_agent.py +44 -6
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/extractor.py +15 -1
- gobby/runner.py +13 -0
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +23 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +43 -3
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/blocking.py +13 -1
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/memory_actions.py +11 -0
- gobby/workflows/safe_evaluator.py +8 -0
- gobby/workflows/summary_actions.py +123 -50
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
- gobby/cli/tui.py +0 -34
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
gobby/tui/screens/workflows.py
DELETED
|
@@ -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())
|
gobby/tui/screens/worktrees.py
DELETED
|
@@ -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())
|
gobby/tui/widgets/__init__.py
DELETED
|
@@ -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()
|