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.
Files changed (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -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 = []
@@ -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