gobby 0.2.6__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/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- 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 +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +217 -51
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- 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/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
gobby/tui/widgets/conductor.py
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
"""Conductor widgets for haiku display and mode indicator."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from textual.app import ComposeResult
|
|
6
|
-
from textual.css.query import NoMatches
|
|
7
|
-
from textual.reactive import reactive
|
|
8
|
-
from textual.widget import Widget
|
|
9
|
-
from textual.widgets import Static
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class HaikuDisplay(Widget):
|
|
13
|
-
"""Widget displaying TARS-style status haiku."""
|
|
14
|
-
|
|
15
|
-
DEFAULT_CSS = """
|
|
16
|
-
HaikuDisplay {
|
|
17
|
-
height: auto;
|
|
18
|
-
padding: 1;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
HaikuDisplay .haiku-line {
|
|
22
|
-
text-align: center;
|
|
23
|
-
color: #cdd6f4;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
HaikuDisplay .haiku-line-first {
|
|
27
|
-
color: #a78bfa;
|
|
28
|
-
}
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
lines = reactive(("", "", ""))
|
|
32
|
-
|
|
33
|
-
def compose(self) -> ComposeResult:
|
|
34
|
-
for i, line in enumerate(self.lines):
|
|
35
|
-
classes = "haiku-line"
|
|
36
|
-
if i == 0:
|
|
37
|
-
classes += " haiku-line-first"
|
|
38
|
-
yield Static(line, classes=classes, id=f"haiku-{i}")
|
|
39
|
-
|
|
40
|
-
def update_haiku(self, lines: list[str]) -> None:
|
|
41
|
-
"""Update the haiku text."""
|
|
42
|
-
if len(lines) >= 3:
|
|
43
|
-
self.lines = tuple(lines[:3]) # type: ignore[assignment]
|
|
44
|
-
for i, line in enumerate(self.lines):
|
|
45
|
-
try:
|
|
46
|
-
widget = self.query_one(f"#haiku-{i}", Static)
|
|
47
|
-
widget.update(line)
|
|
48
|
-
except NoMatches:
|
|
49
|
-
pass # nosec B110 - Widget may not be mounted yet
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class ModeIndicator(Widget):
|
|
53
|
-
"""Widget showing current orchestrator mode."""
|
|
54
|
-
|
|
55
|
-
DEFAULT_CSS = """
|
|
56
|
-
ModeIndicator {
|
|
57
|
-
height: 3;
|
|
58
|
-
border: round #45475a;
|
|
59
|
-
padding: 0 1;
|
|
60
|
-
content-align: center middle;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
ModeIndicator.--interactive {
|
|
64
|
-
border: round #22c55e;
|
|
65
|
-
color: #22c55e;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
ModeIndicator.--autonomous {
|
|
69
|
-
border: round #f59e0b;
|
|
70
|
-
color: #f59e0b;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
ModeIndicator.--paused {
|
|
74
|
-
border: round #6c7086;
|
|
75
|
-
color: #6c7086;
|
|
76
|
-
}
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
mode = reactive("interactive")
|
|
80
|
-
|
|
81
|
-
MODE_LABELS = {
|
|
82
|
-
"interactive": "INTERACTIVE",
|
|
83
|
-
"autonomous": "AUTONOMOUS",
|
|
84
|
-
"paused": "PAUSED",
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
def compose(self) -> ComposeResult:
|
|
88
|
-
label = self.MODE_LABELS.get(self.mode, "UNKNOWN")
|
|
89
|
-
yield Static(label, id="mode-label")
|
|
90
|
-
|
|
91
|
-
def watch_mode(self, mode: str) -> None:
|
|
92
|
-
"""Update classes when mode changes."""
|
|
93
|
-
self.remove_class("--interactive", "--autonomous", "--paused")
|
|
94
|
-
self.add_class(f"--{mode}")
|
|
95
|
-
|
|
96
|
-
try:
|
|
97
|
-
label = self.query_one("#mode-label", Static)
|
|
98
|
-
label.update(self.MODE_LABELS.get(mode, "UNKNOWN"))
|
|
99
|
-
except NoMatches:
|
|
100
|
-
pass # nosec B110 - Widget may not be mounted yet
|
|
101
|
-
|
|
102
|
-
def set_mode(self, mode: str) -> None:
|
|
103
|
-
"""Set the current mode."""
|
|
104
|
-
self.mode = mode
|
gobby/tui/widgets/menu.py
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
"""Menu panel widget for screen navigation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from textual.app import ComposeResult
|
|
9
|
-
from textual.containers import Vertical
|
|
10
|
-
from textual.css.query import NoMatches
|
|
11
|
-
from textual.message import Message
|
|
12
|
-
from textual.reactive import reactive
|
|
13
|
-
from textual.widget import Widget
|
|
14
|
-
from textual.widgets import Static
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass
|
|
18
|
-
class MenuItem:
|
|
19
|
-
"""A menu item with key binding and label."""
|
|
20
|
-
|
|
21
|
-
key: str
|
|
22
|
-
label: str
|
|
23
|
-
screen_id: str
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class MenuItemWidget(Static):
|
|
27
|
-
"""Individual menu item widget."""
|
|
28
|
-
|
|
29
|
-
DEFAULT_CSS = """
|
|
30
|
-
MenuItemWidget {
|
|
31
|
-
height: 3;
|
|
32
|
-
padding: 0 1;
|
|
33
|
-
content-align: left middle;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
MenuItemWidget:hover {
|
|
37
|
-
background: #45475a;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
MenuItemWidget.--selected {
|
|
41
|
-
background: #7c3aed;
|
|
42
|
-
color: white;
|
|
43
|
-
text-style: bold;
|
|
44
|
-
}
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
selected = reactive(False)
|
|
48
|
-
|
|
49
|
-
def __init__(
|
|
50
|
-
self,
|
|
51
|
-
item: MenuItem,
|
|
52
|
-
selected: bool = False,
|
|
53
|
-
**kwargs: Any,
|
|
54
|
-
) -> None:
|
|
55
|
-
super().__init__(**kwargs)
|
|
56
|
-
self.item = item
|
|
57
|
-
self.selected = selected
|
|
58
|
-
|
|
59
|
-
def compose(self) -> ComposeResult:
|
|
60
|
-
yield Static(f"[{self.item.key.upper()}] {self.item.label}")
|
|
61
|
-
|
|
62
|
-
def watch_selected(self, selected: bool) -> None:
|
|
63
|
-
self.set_class(selected, "--selected")
|
|
64
|
-
|
|
65
|
-
def on_click(self) -> None:
|
|
66
|
-
self.post_message(MenuPanel.ItemSelected(self.item))
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class MenuPanel(Widget):
|
|
70
|
-
"""Left sidebar menu panel with keyboard navigation."""
|
|
71
|
-
|
|
72
|
-
DEFAULT_CSS = """
|
|
73
|
-
MenuPanel {
|
|
74
|
-
width: 14;
|
|
75
|
-
background: #313244;
|
|
76
|
-
border-right: solid #45475a;
|
|
77
|
-
}
|
|
78
|
-
"""
|
|
79
|
-
|
|
80
|
-
MENU_ITEMS = [
|
|
81
|
-
MenuItem("d", "Dashboard", "dashboard"),
|
|
82
|
-
MenuItem("t", "Tasks", "tasks"),
|
|
83
|
-
MenuItem("s", "Sessions", "sessions"),
|
|
84
|
-
MenuItem("c", "Chat", "chat"),
|
|
85
|
-
MenuItem("a", "Agents", "agents"),
|
|
86
|
-
MenuItem("w", "Worktrees", "worktrees"),
|
|
87
|
-
MenuItem("f", "Workflows", "workflows"),
|
|
88
|
-
MenuItem("m", "Memory", "memory"),
|
|
89
|
-
MenuItem("e", "Metrics", "metrics"),
|
|
90
|
-
MenuItem("o", "Orchestrator", "orchestrator"),
|
|
91
|
-
]
|
|
92
|
-
|
|
93
|
-
current_screen = reactive("dashboard")
|
|
94
|
-
|
|
95
|
-
@dataclass
|
|
96
|
-
class ItemSelected(Message):
|
|
97
|
-
"""Message sent when a menu item is selected."""
|
|
98
|
-
|
|
99
|
-
item: MenuItem
|
|
100
|
-
|
|
101
|
-
def compose(self) -> ComposeResult:
|
|
102
|
-
with Vertical():
|
|
103
|
-
for item in self.MENU_ITEMS:
|
|
104
|
-
yield MenuItemWidget(
|
|
105
|
-
item,
|
|
106
|
-
selected=(item.screen_id == self.current_screen),
|
|
107
|
-
id=f"menu-{item.screen_id}",
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
def watch_current_screen(self, screen_id: str) -> None:
|
|
111
|
-
"""Update selected state when current screen changes."""
|
|
112
|
-
# Skip if not yet composed
|
|
113
|
-
if not self.is_mounted:
|
|
114
|
-
return
|
|
115
|
-
for item in self.MENU_ITEMS:
|
|
116
|
-
try:
|
|
117
|
-
widget = self.query_one(f"#menu-{item.screen_id}", MenuItemWidget)
|
|
118
|
-
widget.selected = item.screen_id == screen_id
|
|
119
|
-
except NoMatches:
|
|
120
|
-
pass # Widget not yet mounted
|
|
121
|
-
|
|
122
|
-
def select_screen(self, screen_id: str) -> None:
|
|
123
|
-
"""Programmatically select a screen."""
|
|
124
|
-
self.current_screen = screen_id
|
|
125
|
-
for item in self.MENU_ITEMS:
|
|
126
|
-
if item.screen_id == screen_id:
|
|
127
|
-
self.post_message(self.ItemSelected(item))
|
|
128
|
-
break
|
|
129
|
-
|
|
130
|
-
def get_key_bindings(self) -> dict[str, str]:
|
|
131
|
-
"""Return key -> screen_id mapping for binding setup."""
|
|
132
|
-
return {item.key: item.screen_id for item in self.MENU_ITEMS}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
"""Inter-agent message panel widget."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from textual.app import ComposeResult
|
|
9
|
-
from textual.containers import Horizontal, VerticalScroll
|
|
10
|
-
from textual.reactive import reactive
|
|
11
|
-
from textual.widget import Widget
|
|
12
|
-
from textual.widgets import Static
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class AgentMessage(Static):
|
|
16
|
-
"""A single agent message display."""
|
|
17
|
-
|
|
18
|
-
DEFAULT_CSS = """
|
|
19
|
-
AgentMessage {
|
|
20
|
-
height: 1;
|
|
21
|
-
padding: 0 1;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
AgentMessage.--outgoing {
|
|
25
|
-
color: #06b6d4;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
AgentMessage.--incoming {
|
|
29
|
-
color: #cdd6f4;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
AgentMessage .message-arrow {
|
|
33
|
-
width: 2;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
AgentMessage .message-sender {
|
|
37
|
-
color: #a6adc8;
|
|
38
|
-
width: 12;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
AgentMessage .message-content {
|
|
42
|
-
width: 1fr;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
AgentMessage .message-time {
|
|
46
|
-
color: #6c7086;
|
|
47
|
-
width: 8;
|
|
48
|
-
}
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
def __init__(
|
|
52
|
-
self,
|
|
53
|
-
sender: str,
|
|
54
|
-
content: str,
|
|
55
|
-
direction: str = "incoming",
|
|
56
|
-
timestamp: str | None = None,
|
|
57
|
-
**kwargs: Any,
|
|
58
|
-
) -> None:
|
|
59
|
-
super().__init__(**kwargs)
|
|
60
|
-
self.sender = sender
|
|
61
|
-
self.content = content
|
|
62
|
-
self.direction = direction
|
|
63
|
-
self.timestamp = timestamp or datetime.now().strftime("%H:%M:%S")
|
|
64
|
-
self.add_class(f"--{direction}")
|
|
65
|
-
|
|
66
|
-
def compose(self) -> ComposeResult:
|
|
67
|
-
arrow = "→" if self.direction == "outgoing" else "←"
|
|
68
|
-
with Horizontal():
|
|
69
|
-
yield Static(arrow, classes="message-arrow")
|
|
70
|
-
yield Static(f"[{self.sender}]", classes="message-sender")
|
|
71
|
-
yield Static(str(self.content)[:60], classes="message-content")
|
|
72
|
-
yield Static(self.timestamp[-8:], classes="message-time")
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class InterAgentMessagePanel(Widget):
|
|
76
|
-
"""Panel showing real-time inter-agent messages."""
|
|
77
|
-
|
|
78
|
-
DEFAULT_CSS = """
|
|
79
|
-
InterAgentMessagePanel {
|
|
80
|
-
height: 1fr;
|
|
81
|
-
border: round #45475a;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
InterAgentMessagePanel .panel-header {
|
|
85
|
-
height: 1;
|
|
86
|
-
padding: 0 1;
|
|
87
|
-
background: #313244;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
InterAgentMessagePanel .panel-title {
|
|
91
|
-
text-style: bold;
|
|
92
|
-
color: #a78bfa;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
InterAgentMessagePanel .messages-scroll {
|
|
96
|
-
height: 1fr;
|
|
97
|
-
padding: 1;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
InterAgentMessagePanel .empty-state {
|
|
101
|
-
content-align: center middle;
|
|
102
|
-
height: 1fr;
|
|
103
|
-
color: #6c7086;
|
|
104
|
-
}
|
|
105
|
-
"""
|
|
106
|
-
|
|
107
|
-
messages: reactive[list[dict[str, Any]]] = reactive(list)
|
|
108
|
-
max_messages = 100
|
|
109
|
-
|
|
110
|
-
def compose(self) -> ComposeResult:
|
|
111
|
-
yield Static("💬 Inter-Agent Messages", classes="panel-header panel-title")
|
|
112
|
-
|
|
113
|
-
if not self.messages:
|
|
114
|
-
yield Static("No messages yet", classes="empty-state")
|
|
115
|
-
else:
|
|
116
|
-
with VerticalScroll(classes="messages-scroll", id="messages-scroll"):
|
|
117
|
-
for msg in self.messages[-20:]: # Show last 20
|
|
118
|
-
yield AgentMessage(
|
|
119
|
-
sender=msg.get("sender", "unknown"),
|
|
120
|
-
content=msg.get("content", ""),
|
|
121
|
-
direction=msg.get("direction", "incoming"),
|
|
122
|
-
timestamp=msg.get("timestamp"),
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
def watch_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
126
|
-
"""Scroll to bottom when messages change."""
|
|
127
|
-
# Schedule scroll after recompose completes
|
|
128
|
-
self.call_after_refresh(self._scroll_to_end)
|
|
129
|
-
|
|
130
|
-
def _scroll_to_end(self) -> None:
|
|
131
|
-
"""Scroll the message panel to the end."""
|
|
132
|
-
try:
|
|
133
|
-
scroll = self.query_one("#messages-scroll", VerticalScroll)
|
|
134
|
-
scroll.scroll_end(animate=False)
|
|
135
|
-
except Exception:
|
|
136
|
-
pass # nosec B110 - widget may not be mounted yet
|
|
137
|
-
|
|
138
|
-
def add_message(
|
|
139
|
-
self,
|
|
140
|
-
sender: str,
|
|
141
|
-
content: str,
|
|
142
|
-
direction: str = "incoming",
|
|
143
|
-
) -> None:
|
|
144
|
-
"""Add a new message to the panel."""
|
|
145
|
-
new_messages = list(self.messages)
|
|
146
|
-
new_messages.append(
|
|
147
|
-
{
|
|
148
|
-
"sender": sender,
|
|
149
|
-
"content": content,
|
|
150
|
-
"direction": direction,
|
|
151
|
-
"timestamp": datetime.now().isoformat(),
|
|
152
|
-
}
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
# Keep only the last max_messages - reactive will trigger recompose
|
|
156
|
-
self.messages = new_messages[-self.max_messages :]
|
|
157
|
-
|
|
158
|
-
def clear_messages(self) -> None:
|
|
159
|
-
"""Clear all messages."""
|
|
160
|
-
self.messages = []
|
gobby/tui/widgets/review_gate.py
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
"""Review gate panel widget."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from datetime import UTC, datetime
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from textual.app import ComposeResult
|
|
11
|
-
from textual.containers import Horizontal, VerticalScroll
|
|
12
|
-
from textual.message import Message
|
|
13
|
-
from textual.reactive import reactive
|
|
14
|
-
from textual.widget import Widget
|
|
15
|
-
from textual.widgets import Button, Static
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class ReviewItem(Static):
|
|
19
|
-
"""A single review queue item."""
|
|
20
|
-
|
|
21
|
-
DEFAULT_CSS = """
|
|
22
|
-
ReviewItem {
|
|
23
|
-
height: 2;
|
|
24
|
-
padding: 0 1;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
ReviewItem:hover {
|
|
28
|
-
background: #45475a;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
ReviewItem.--selected {
|
|
32
|
-
background: #6d28d9;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
ReviewItem .review-content {
|
|
36
|
-
layout: horizontal;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
ReviewItem .review-ref {
|
|
40
|
-
color: #a855f7;
|
|
41
|
-
width: 12;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
ReviewItem .review-title {
|
|
45
|
-
width: 1fr;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
ReviewItem .review-wait {
|
|
49
|
-
color: #6c7086;
|
|
50
|
-
width: 8;
|
|
51
|
-
}
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
selected = reactive(False)
|
|
55
|
-
|
|
56
|
-
def __init__(self, task_data: dict[str, Any], **kwargs: Any) -> None:
|
|
57
|
-
super().__init__(**kwargs)
|
|
58
|
-
self.task_data = task_data
|
|
59
|
-
|
|
60
|
-
def compose(self) -> ComposeResult:
|
|
61
|
-
ref = self.task_data.get("ref", "")
|
|
62
|
-
title = self.task_data.get("title", "Untitled")[:30]
|
|
63
|
-
if len(self.task_data.get("title", "")) > 30:
|
|
64
|
-
title += "..."
|
|
65
|
-
|
|
66
|
-
# Calculate wait time
|
|
67
|
-
updated = self.task_data.get("updated_at", "")
|
|
68
|
-
if updated:
|
|
69
|
-
try:
|
|
70
|
-
# Parse ISO format, normalize to UTC
|
|
71
|
-
updated_dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
72
|
-
# Ensure timezone-aware comparison
|
|
73
|
-
if updated_dt.tzinfo is None:
|
|
74
|
-
updated_dt = updated_dt.replace(tzinfo=UTC)
|
|
75
|
-
now = datetime.now(UTC)
|
|
76
|
-
wait = now - updated_dt
|
|
77
|
-
minutes = int(wait.total_seconds() // 60)
|
|
78
|
-
wait_str = f"⏳{minutes}m"
|
|
79
|
-
except ValueError as e:
|
|
80
|
-
# Log parsing errors for debugging
|
|
81
|
-
logging.getLogger(__name__).debug(f"Failed to parse updated_at: {updated!r}: {e}")
|
|
82
|
-
wait_str = "⏳?"
|
|
83
|
-
else:
|
|
84
|
-
wait_str = "⏳?"
|
|
85
|
-
|
|
86
|
-
with Horizontal(classes="review-content"):
|
|
87
|
-
yield Static(ref, classes="review-ref")
|
|
88
|
-
yield Static(title, classes="review-title")
|
|
89
|
-
yield Static(wait_str, classes="review-wait")
|
|
90
|
-
|
|
91
|
-
def watch_selected(self, selected: bool) -> None:
|
|
92
|
-
"""Update selected class."""
|
|
93
|
-
self.set_class(selected, "--selected")
|
|
94
|
-
|
|
95
|
-
def on_click(self) -> None:
|
|
96
|
-
"""Handle click to select."""
|
|
97
|
-
self.post_message(ReviewGatePanel.ItemSelected(self.task_data))
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
class ReviewGatePanel(Widget):
|
|
101
|
-
"""Panel showing tasks awaiting review with approve/reject actions."""
|
|
102
|
-
|
|
103
|
-
DEFAULT_CSS = """
|
|
104
|
-
ReviewGatePanel {
|
|
105
|
-
height: 1fr;
|
|
106
|
-
border: round #45475a;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
ReviewGatePanel .panel-header {
|
|
110
|
-
layout: horizontal;
|
|
111
|
-
height: 1;
|
|
112
|
-
padding: 0 1;
|
|
113
|
-
background: #313244;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
ReviewGatePanel .panel-title {
|
|
117
|
-
text-style: bold;
|
|
118
|
-
color: #a78bfa;
|
|
119
|
-
width: 1fr;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
ReviewGatePanel .queue-count {
|
|
123
|
-
color: #a855f7;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
ReviewGatePanel .review-list {
|
|
127
|
-
height: 1fr;
|
|
128
|
-
padding: 1;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
ReviewGatePanel .action-row {
|
|
132
|
-
layout: horizontal;
|
|
133
|
-
height: 3;
|
|
134
|
-
padding: 1;
|
|
135
|
-
border-top: solid #45475a;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
ReviewGatePanel .action-row Button {
|
|
139
|
-
margin-right: 1;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
ReviewGatePanel .empty-state {
|
|
143
|
-
content-align: center middle;
|
|
144
|
-
height: 1fr;
|
|
145
|
-
color: #6c7086;
|
|
146
|
-
}
|
|
147
|
-
"""
|
|
148
|
-
|
|
149
|
-
tasks: reactive[list[dict[str, Any]]] = reactive(list)
|
|
150
|
-
selected_task: reactive[dict[str, Any] | None] = reactive(None)
|
|
151
|
-
|
|
152
|
-
@dataclass
|
|
153
|
-
class ItemSelected(Message):
|
|
154
|
-
"""Message sent when a review item is selected."""
|
|
155
|
-
|
|
156
|
-
task: dict[str, Any]
|
|
157
|
-
|
|
158
|
-
@dataclass
|
|
159
|
-
class TaskApproved(Message):
|
|
160
|
-
"""Message sent when a task is approved."""
|
|
161
|
-
|
|
162
|
-
task: dict[str, Any]
|
|
163
|
-
|
|
164
|
-
@dataclass
|
|
165
|
-
class TaskRejected(Message):
|
|
166
|
-
"""Message sent when a task is rejected."""
|
|
167
|
-
|
|
168
|
-
task: dict[str, Any]
|
|
169
|
-
|
|
170
|
-
def compose(self) -> ComposeResult:
|
|
171
|
-
review_tasks = [t for t in self.tasks if t.get("status") == "review"]
|
|
172
|
-
|
|
173
|
-
with Horizontal(classes="panel-header"):
|
|
174
|
-
yield Static("📋 Review Queue", classes="panel-title")
|
|
175
|
-
yield Static(f"({len(review_tasks)})", classes="queue-count")
|
|
176
|
-
|
|
177
|
-
if not review_tasks:
|
|
178
|
-
yield Static("No tasks awaiting review", classes="empty-state")
|
|
179
|
-
else:
|
|
180
|
-
with VerticalScroll(classes="review-list"):
|
|
181
|
-
for task in review_tasks:
|
|
182
|
-
item = ReviewItem(task_data=task, id=f"review-{task.get('id', '')}")
|
|
183
|
-
if self.selected_task and task.get("id") == self.selected_task.get("id"):
|
|
184
|
-
item.selected = True
|
|
185
|
-
yield item
|
|
186
|
-
|
|
187
|
-
with Horizontal(classes="action-row"):
|
|
188
|
-
yield Button("Approve", variant="success", id="btn-approve")
|
|
189
|
-
yield Button("Reject", variant="error", id="btn-reject")
|
|
190
|
-
|
|
191
|
-
def on_review_gate_panel_item_selected(self, event: ItemSelected) -> None:
|
|
192
|
-
"""Handle item selection."""
|
|
193
|
-
self.selected_task = event.task
|
|
194
|
-
self._update_selection()
|
|
195
|
-
|
|
196
|
-
def _update_selection(self) -> None:
|
|
197
|
-
"""Update selected state of all items."""
|
|
198
|
-
for task in self.tasks:
|
|
199
|
-
try:
|
|
200
|
-
item = self.query_one(f"#review-{task.get('id', '')}", ReviewItem)
|
|
201
|
-
item.selected = self.selected_task is not None and task.get(
|
|
202
|
-
"id"
|
|
203
|
-
) == self.selected_task.get("id")
|
|
204
|
-
except Exception:
|
|
205
|
-
pass # nosec B110 - widget may not be mounted yet
|
|
206
|
-
|
|
207
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
208
|
-
"""Handle action button presses."""
|
|
209
|
-
if not self.selected_task:
|
|
210
|
-
return
|
|
211
|
-
|
|
212
|
-
if event.button.id == "btn-approve":
|
|
213
|
-
self.post_message(self.TaskApproved(self.selected_task))
|
|
214
|
-
elif event.button.id == "btn-reject":
|
|
215
|
-
self.post_message(self.TaskRejected(self.selected_task))
|
|
216
|
-
|
|
217
|
-
def update_tasks(self, tasks: list[dict[str, Any]]) -> None:
|
|
218
|
-
"""Update the tasks list."""
|
|
219
|
-
self.tasks = tasks
|
|
220
|
-
self.refresh(recompose=True)
|
|
221
|
-
|
|
222
|
-
def get_selected_task(self) -> dict[str, Any] | None:
|
|
223
|
-
"""Get the currently selected task."""
|
|
224
|
-
return self.selected_task
|