patchbai 0.1.0__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.
- patchbai/__init__.py +1 -0
- patchbai/__main__.py +10 -0
- patchbai/actions.py +34 -0
- patchbai/activity/__init__.py +0 -0
- patchbai/activity/log.py +237 -0
- patchbai/agents/__init__.py +0 -0
- patchbai/agents/child_tools.py +66 -0
- patchbai/agents/fake_sdk_adapter.py +45 -0
- patchbai/agents/manager.py +272 -0
- patchbai/agents/request_inbox.py +65 -0
- patchbai/agents/sdk_adapter.py +49 -0
- patchbai/agents/session.py +224 -0
- patchbai/agents/sort.py +66 -0
- patchbai/agents/state.py +80 -0
- patchbai/app.py +1288 -0
- patchbai/config.py +128 -0
- patchbai/events.py +236 -0
- patchbai/layout/__init__.py +0 -0
- patchbai/layout/custom_widgets.py +82 -0
- patchbai/layout/defaults.py +33 -0
- patchbai/layout/engine.py +241 -0
- patchbai/layout/local_widgets.py +188 -0
- patchbai/layout/registry.py +69 -0
- patchbai/layout/spec.py +104 -0
- patchbai/layout/splitter.py +170 -0
- patchbai/layout/titles.py +70 -0
- patchbai/orchestrator/__init__.py +0 -0
- patchbai/orchestrator/formatting.py +15 -0
- patchbai/orchestrator/session.py +644 -0
- patchbai/orchestrator/tabs_tools.py +149 -0
- patchbai/orchestrator/tools.py +976 -0
- patchbai/persistence/__init__.py +0 -0
- patchbai/persistence/agents_index.py +68 -0
- patchbai/persistence/atomic.py +47 -0
- patchbai/persistence/layout_store.py +25 -0
- patchbai/persistence/layouts_store.py +61 -0
- patchbai/persistence/orchestrator_sessions.py +127 -0
- patchbai/persistence/paths.py +48 -0
- patchbai/persistence/themes_store.py +44 -0
- patchbai/persistence/transcript_store.py +64 -0
- patchbai/persistence/workspace_store.py +25 -0
- patchbai/theme/__init__.py +0 -0
- patchbai/theme/engine.py +75 -0
- patchbai/theme/spec.py +31 -0
- patchbai/widgets/__init__.py +0 -0
- patchbai/widgets/_file_lang.py +36 -0
- patchbai/widgets/_terminal_keys.py +89 -0
- patchbai/widgets/_terminal_render.py +147 -0
- patchbai/widgets/activity_feed.py +365 -0
- patchbai/widgets/agent_table.py +235 -0
- patchbai/widgets/agent_transcript.py +58 -0
- patchbai/widgets/change_cwd_screen.py +39 -0
- patchbai/widgets/chrome.py +210 -0
- patchbai/widgets/diff_viewer.py +52 -0
- patchbai/widgets/file_editor.py +258 -0
- patchbai/widgets/file_tree.py +33 -0
- patchbai/widgets/file_viewer.py +77 -0
- patchbai/widgets/history_screen.py +58 -0
- patchbai/widgets/layout_switcher.py +126 -0
- patchbai/widgets/log_tail.py +113 -0
- patchbai/widgets/markdown.py +65 -0
- patchbai/widgets/new_tab_screen.py +31 -0
- patchbai/widgets/notebook.py +45 -0
- patchbai/widgets/orchestrator_chat.py +73 -0
- patchbai/widgets/resume_screen.py +179 -0
- patchbai/widgets/rich_transcript.py +606 -0
- patchbai/widgets/terminal.py +251 -0
- patchbai/widgets/theme_switcher.py +63 -0
- patchbai/widgets/transcript_screen.py +39 -0
- patchbai/workspace/__init__.py +3 -0
- patchbai/workspace/spec.py +72 -0
- patchbai-0.1.0.dist-info/METADATA +573 -0
- patchbai-0.1.0.dist-info/RECORD +76 -0
- patchbai-0.1.0.dist-info/WHEEL +4 -0
- patchbai-0.1.0.dist-info/entry_points.txt +3 -0
- patchbai-0.1.0.dist-info/licenses/LICENSE +21 -0
patchbai/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import tomli_w
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DEFAULT_BINDINGS = {
|
|
13
|
+
"/": ("focus_command_bar", {}),
|
|
14
|
+
"ctrl+q": ("quit", {}),
|
|
15
|
+
"ctrl+h": ("open_history", {}),
|
|
16
|
+
"ctrl+l": ("open_layout_switcher", {}),
|
|
17
|
+
"?": ("show_help", {}),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class KeyBinding:
|
|
23
|
+
action: str
|
|
24
|
+
args: dict = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class UISection:
|
|
29
|
+
active_theme: str = "default"
|
|
30
|
+
default_model: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class WidgetsSection:
|
|
35
|
+
local_dir_enabled: bool = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Config:
|
|
40
|
+
bindings: dict[str, KeyBinding] = field(default_factory=dict)
|
|
41
|
+
ui: UISection = field(default_factory=UISection)
|
|
42
|
+
widgets: WidgetsSection = field(default_factory=WidgetsSection)
|
|
43
|
+
|
|
44
|
+
def get_path(self, path: str) -> Any:
|
|
45
|
+
section, attr = self._split_path(path)
|
|
46
|
+
section_obj = getattr(self, section, None)
|
|
47
|
+
if section_obj is None or not hasattr(section_obj, attr):
|
|
48
|
+
raise KeyError(path)
|
|
49
|
+
return getattr(section_obj, attr)
|
|
50
|
+
|
|
51
|
+
def set_path(self, path: str, value: Any) -> None:
|
|
52
|
+
section, attr = self._split_path(path)
|
|
53
|
+
section_obj = getattr(self, section, None)
|
|
54
|
+
if section_obj is None or not hasattr(section_obj, attr):
|
|
55
|
+
raise KeyError(path)
|
|
56
|
+
setattr(section_obj, attr, value)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _split_path(path: str) -> tuple[str, str]:
|
|
60
|
+
parts = path.split(".")
|
|
61
|
+
if len(parts) != 2:
|
|
62
|
+
raise KeyError(f"only dotted two-segment paths supported, got {path!r}")
|
|
63
|
+
return parts[0], parts[1]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigStore:
|
|
67
|
+
"""Read/write ~/.config/patchbai/config.toml. Defaults applied on missing file."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, global_dir: Path) -> None:
|
|
70
|
+
self._dir = Path(global_dir)
|
|
71
|
+
self._path = self._dir / "config.toml"
|
|
72
|
+
|
|
73
|
+
def load(self) -> Config:
|
|
74
|
+
cfg = Config()
|
|
75
|
+
# Apply defaults first.
|
|
76
|
+
for key, (action, args) in _DEFAULT_BINDINGS.items():
|
|
77
|
+
cfg.bindings[key] = KeyBinding(action=action, args=dict(args))
|
|
78
|
+
|
|
79
|
+
if not self._path.exists():
|
|
80
|
+
return cfg
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
raw = tomllib.loads(self._path.read_text(encoding="utf-8"))
|
|
84
|
+
except Exception:
|
|
85
|
+
log.exception("Failed to parse config.toml; using defaults")
|
|
86
|
+
return cfg
|
|
87
|
+
|
|
88
|
+
# Merge bindings (overrides defaults).
|
|
89
|
+
bindings_raw = raw.get("bindings", {})
|
|
90
|
+
if isinstance(bindings_raw, dict):
|
|
91
|
+
for key, val in bindings_raw.items():
|
|
92
|
+
if isinstance(val, dict) and "action" in val:
|
|
93
|
+
cfg.bindings[key] = KeyBinding(
|
|
94
|
+
action=val["action"], args=dict(val.get("args", {}))
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
ui_raw = raw.get("ui", {})
|
|
98
|
+
if isinstance(ui_raw, dict):
|
|
99
|
+
if "active_theme" in ui_raw and isinstance(ui_raw["active_theme"], str):
|
|
100
|
+
cfg.ui.active_theme = ui_raw["active_theme"]
|
|
101
|
+
if "default_model" in ui_raw and isinstance(ui_raw["default_model"], str):
|
|
102
|
+
cfg.ui.default_model = ui_raw["default_model"]
|
|
103
|
+
# Legacy `ui.theme` key (now removed) is silently ignored.
|
|
104
|
+
|
|
105
|
+
widgets_raw = raw.get("widgets", {})
|
|
106
|
+
if isinstance(widgets_raw, dict):
|
|
107
|
+
if "local_dir_enabled" in widgets_raw and isinstance(
|
|
108
|
+
widgets_raw["local_dir_enabled"], bool
|
|
109
|
+
):
|
|
110
|
+
cfg.widgets.local_dir_enabled = widgets_raw["local_dir_enabled"]
|
|
111
|
+
return cfg
|
|
112
|
+
|
|
113
|
+
def save(self, cfg: Config) -> None:
|
|
114
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
out = {
|
|
116
|
+
"bindings": {
|
|
117
|
+
key: {"action": b.action, "args": b.args}
|
|
118
|
+
for key, b in cfg.bindings.items()
|
|
119
|
+
},
|
|
120
|
+
"ui": {
|
|
121
|
+
"active_theme": cfg.ui.active_theme,
|
|
122
|
+
"default_model": cfg.ui.default_model,
|
|
123
|
+
},
|
|
124
|
+
"widgets": {
|
|
125
|
+
"local_dir_enabled": cfg.widgets.local_dir_enabled,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
self._path.write_text(tomli_w.dumps(out), encoding="utf-8")
|
patchbai/events.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Callable, TypeVar
|
|
6
|
+
|
|
7
|
+
from patchbai.agents.state import AgentInfo, AgentState
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from patchbai.layout.spec import LayoutSpec
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
E = TypeVar("E")
|
|
15
|
+
Handler = Callable[[E], None]
|
|
16
|
+
Unsubscribe = Callable[[], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Built-in event types (more added by later plans) ----------------------
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class UserMessageToOrchestrator:
|
|
23
|
+
"""User typed something into the orchestrator chat or command bar."""
|
|
24
|
+
text: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class OrchestratorReply:
|
|
29
|
+
"""The orchestrator session emitted a reply."""
|
|
30
|
+
text: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class OrchestratorSessionSwitched:
|
|
35
|
+
"""The orchestrator session was swapped (via /reset or /resume)."""
|
|
36
|
+
session_id: str
|
|
37
|
+
transcript_path: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class OpenResumePicker:
|
|
42
|
+
"""Request from the orchestrator that the app open the resume modal."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class StatsUpdated:
|
|
48
|
+
"""StatusBar stats refresh."""
|
|
49
|
+
tokens_in: int = 0
|
|
50
|
+
tokens_out: int = 0
|
|
51
|
+
cost: float = 0.0
|
|
52
|
+
active_agents: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- Agent event types -----------------------------------------------------
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class AgentSpawned:
|
|
59
|
+
"""A new child agent has been created and registered with AgentManager."""
|
|
60
|
+
info: AgentInfo
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AgentStateChanged:
|
|
65
|
+
"""An agent transitioned between states (e.g., RUNNING → DONE)."""
|
|
66
|
+
info: AgentInfo
|
|
67
|
+
old_state: AgentState
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class AgentTokensTouched:
|
|
72
|
+
"""An AgentSession (orchestrator inner session or child agent) just
|
|
73
|
+
accumulated tokens / cost from a ResultMessage. Lightweight signal — no
|
|
74
|
+
deltas, no totals; subscribers re-aggregate from the canonical AgentInfo
|
|
75
|
+
objects."""
|
|
76
|
+
agent_id: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class AgentMessageAppended:
|
|
81
|
+
"""A new message landed in an agent's transcript."""
|
|
82
|
+
agent_id: str
|
|
83
|
+
role: str # "user" | "assistant" | "tool_use" | "tool_result" | "thinking" | "system"
|
|
84
|
+
text: str
|
|
85
|
+
tool_id: str | None = None # set for role in {"tool_use", "tool_result"}
|
|
86
|
+
tool_name: str | None = None # set for role == "tool_use"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Forward-declared for plan 3; the handler arrives later.
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class AgentRequestedUserInput:
|
|
92
|
+
"""A child agent called ask_orchestrator and is blocked waiting on a reply."""
|
|
93
|
+
agent_id: str
|
|
94
|
+
question: str
|
|
95
|
+
request_id: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class AgentNotifiedOrchestrator:
|
|
100
|
+
"""A child agent called notify_orchestrator (fire-and-forget)."""
|
|
101
|
+
agent_id: str
|
|
102
|
+
message: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class AgentArchiveChanged:
|
|
107
|
+
"""An agent's `archived` flag was toggled. Carries a frozen snapshot of
|
|
108
|
+
the AgentInfo so subscribers (AgentTable, persistence) can refresh."""
|
|
109
|
+
info: AgentInfo
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class DirectMessageToAgent:
|
|
114
|
+
"""User typed directly to a focused AgentTranscript's input."""
|
|
115
|
+
agent_id: str
|
|
116
|
+
text: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- Layout event types ----------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class LayoutApplied:
|
|
123
|
+
"""The LayoutEngine successfully applied a new spec."""
|
|
124
|
+
spec: "LayoutSpec"
|
|
125
|
+
layout_name: str | None = None # if loaded by name; else None
|
|
126
|
+
tab_id: str | None = None # set when published per-tab
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class LayoutFailed:
|
|
131
|
+
"""The LayoutEngine rejected a spec at build time."""
|
|
132
|
+
error: str
|
|
133
|
+
tab_id: str | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class LayoutResized:
|
|
138
|
+
"""User finished a mouse-drag resize on a Splitter between two siblings.
|
|
139
|
+
|
|
140
|
+
Carries the parent container's post-drag layout state as a tuple of
|
|
141
|
+
`outer_size` cell counts — one entry per *non-splitter* child, in spec
|
|
142
|
+
order. The app handler renormalizes those cells into percentages that
|
|
143
|
+
sum to 100%, which keeps the layout converged across re-saves instead of
|
|
144
|
+
drifting (the previous design rounded each pair independently using
|
|
145
|
+
inner widths and was off by border + splitter cells per drag)."""
|
|
146
|
+
tab_id: str
|
|
147
|
+
parent_path: tuple[int, ...]
|
|
148
|
+
children_cells: tuple[int, ...]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class TabAdded:
|
|
153
|
+
tab_id: str
|
|
154
|
+
title: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class TabClosed:
|
|
159
|
+
tab_id: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class TabSwitched:
|
|
164
|
+
tab_id: str
|
|
165
|
+
title: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class WorkspaceCwdChanged:
|
|
170
|
+
"""The app's working directory has been re-rooted at runtime. The
|
|
171
|
+
workspace state has already been reloaded from `cwd` and the active
|
|
172
|
+
layout re-applied; subscribers should re-render any cwd-dependent UI."""
|
|
173
|
+
cwd: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class FileSelected:
|
|
178
|
+
"""A FileTree (or similar) widget selected a file. Other widgets like
|
|
179
|
+
FileViewer can subscribe with `follow_selection=True` to react."""
|
|
180
|
+
path: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class ActivityLogged:
|
|
185
|
+
"""A new entry was appended to the app's ActivityLog. Subscribers (e.g.,
|
|
186
|
+
ActivityFeed widgets) consume this to render the new entry. The `entry`
|
|
187
|
+
field is an `ActivityEntry` from `patchbai.activity.log`; we leave it
|
|
188
|
+
typed as `object` here to avoid a circular import."""
|
|
189
|
+
entry: object
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class AgentFocusRequested:
|
|
194
|
+
"""An ActivityFeed row click (or other UI affordance) wants to focus a
|
|
195
|
+
specific agent. AgentTable subscribes and selects + scrolls to the
|
|
196
|
+
matching row; if no AgentTable is mounted the click handler falls back
|
|
197
|
+
to opening the agent's TranscriptScreen."""
|
|
198
|
+
agent_id: str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- The bus ---------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
class EventBus:
|
|
204
|
+
"""Synchronous in-process pub/sub keyed by event class.
|
|
205
|
+
|
|
206
|
+
Handlers are called in subscription order. Handler exceptions are logged
|
|
207
|
+
and swallowed so one bad handler can't take down the others.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self) -> None:
|
|
211
|
+
self._subs: dict[type, list[Callable]] = {}
|
|
212
|
+
|
|
213
|
+
def subscribe(self, event_type: type[E], handler: Callable[[E], None]) -> Unsubscribe:
|
|
214
|
+
self._subs.setdefault(event_type, []).append(handler)
|
|
215
|
+
|
|
216
|
+
def unsubscribe() -> None:
|
|
217
|
+
handlers = self._subs.get(event_type)
|
|
218
|
+
if handlers and handler in handlers:
|
|
219
|
+
handlers.remove(handler)
|
|
220
|
+
|
|
221
|
+
return unsubscribe
|
|
222
|
+
|
|
223
|
+
def publish(self, event: object) -> None:
|
|
224
|
+
"""Dispatch `event` to all current subscribers of `type(event)`.
|
|
225
|
+
|
|
226
|
+
Handlers are called on a snapshot of the subscriber list, so handlers
|
|
227
|
+
that unsubscribe during dispatch do not interfere with the current
|
|
228
|
+
publish — their removal takes effect on subsequent publishes.
|
|
229
|
+
|
|
230
|
+
Subscribers of subclasses are NOT matched (exact-type dispatch only).
|
|
231
|
+
"""
|
|
232
|
+
for handler in list(self._subs.get(type(event), [])):
|
|
233
|
+
try:
|
|
234
|
+
handler(event)
|
|
235
|
+
except Exception:
|
|
236
|
+
log.exception("EventBus handler raised")
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from textual.widget import Widget
|
|
2
|
+
|
|
3
|
+
from patchbai.layout.registry import WidgetRegistry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CustomWidgetError(Exception):
|
|
7
|
+
"""Raised when a custom-widget source can't be exec'd or doesn't yield
|
|
8
|
+
a usable Widget subclass."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_custom_widget(
|
|
12
|
+
registry: WidgetRegistry,
|
|
13
|
+
name: str,
|
|
14
|
+
source: str,
|
|
15
|
+
*,
|
|
16
|
+
description: str = "",
|
|
17
|
+
props_schema: dict | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Exec `source` in an isolated namespace and register the resulting
|
|
20
|
+
Widget subclass under `name`.
|
|
21
|
+
|
|
22
|
+
Class detection precedence:
|
|
23
|
+
1. `WIDGET_CLASS = SomeClass` sentinel in the namespace.
|
|
24
|
+
2. A class named exactly `name`.
|
|
25
|
+
3. A single Widget subclass defined in the source.
|
|
26
|
+
Otherwise CustomWidgetError.
|
|
27
|
+
|
|
28
|
+
The namespace is empty — the source is expected to import what it
|
|
29
|
+
needs from `textual.*` and stdlib.
|
|
30
|
+
"""
|
|
31
|
+
namespace: dict = {}
|
|
32
|
+
try:
|
|
33
|
+
exec(source, namespace) # noqa: S102 - intentional, in-process trust model
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise CustomWidgetError(f"failed to exec source for {name!r}: {e}") from e
|
|
36
|
+
|
|
37
|
+
cls = _find_widget_class(namespace, name)
|
|
38
|
+
if cls is None:
|
|
39
|
+
raise CustomWidgetError(
|
|
40
|
+
f"no Widget subclass found in source for {name!r}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Drop any prior registration so the new class is the live one.
|
|
44
|
+
registry.unregister(name)
|
|
45
|
+
registry.register(
|
|
46
|
+
name, cls,
|
|
47
|
+
description=description,
|
|
48
|
+
props_schema=props_schema or {},
|
|
49
|
+
source="inline",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _find_widget_class(namespace: dict, name: str) -> type[Widget] | None:
|
|
54
|
+
sentinel = namespace.get("WIDGET_CLASS")
|
|
55
|
+
if isinstance(sentinel, type) and issubclass(sentinel, Widget):
|
|
56
|
+
return sentinel
|
|
57
|
+
|
|
58
|
+
by_name = namespace.get(name)
|
|
59
|
+
if isinstance(by_name, type) and issubclass(by_name, Widget):
|
|
60
|
+
return by_name
|
|
61
|
+
|
|
62
|
+
# Find Widget subclasses DEFINED in this exec (not imported).
|
|
63
|
+
# exec'd classes get __module__ == "builtins" by default since the
|
|
64
|
+
# exec namespace has no __name__.
|
|
65
|
+
candidates = [
|
|
66
|
+
v for v in namespace.values()
|
|
67
|
+
if isinstance(v, type)
|
|
68
|
+
and issubclass(v, Widget)
|
|
69
|
+
and v is not Widget
|
|
70
|
+
and v.__module__ == "builtins"
|
|
71
|
+
]
|
|
72
|
+
# Deduplicate by id (same class can appear under multiple names).
|
|
73
|
+
unique = list({id(c): c for c in candidates}.values())
|
|
74
|
+
|
|
75
|
+
if len(unique) == 1:
|
|
76
|
+
return unique[0]
|
|
77
|
+
if len(unique) > 1:
|
|
78
|
+
raise CustomWidgetError(
|
|
79
|
+
f"ambiguous: source defined {len(unique)} Widget subclasses; "
|
|
80
|
+
f"set WIDGET_CLASS = ... or name one class {name!r} to disambiguate"
|
|
81
|
+
)
|
|
82
|
+
return None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from patchbai.layout.spec import LayoutSpec
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def dashboard_layout() -> LayoutSpec:
|
|
5
|
+
"""The built-in landing layout used when no <cwd>/.patchbai/layout.json exists.
|
|
6
|
+
|
|
7
|
+
Mirrors the saved 'focused-agents' layout: orchestrator at 60%, with the
|
|
8
|
+
agent table and activity feed sharing a tabs container at 40% (Agents
|
|
9
|
+
active by default) so the activity feed doesn't compete for vertical
|
|
10
|
+
space when the agent table is what the user usually wants to see.
|
|
11
|
+
"""
|
|
12
|
+
return LayoutSpec.model_validate({
|
|
13
|
+
"version": 1,
|
|
14
|
+
"layout": {
|
|
15
|
+
"type": "horizontal",
|
|
16
|
+
"children": [
|
|
17
|
+
{"id": "orch", "size": "60%", "widget": "OrchestratorChat",
|
|
18
|
+
"title": "Orchestrator"},
|
|
19
|
+
{
|
|
20
|
+
"type": "tabs",
|
|
21
|
+
"size": "40%",
|
|
22
|
+
"children": [
|
|
23
|
+
{"id": "agents", "widget": "AgentTable",
|
|
24
|
+
"title": "Agents"},
|
|
25
|
+
{"id": "feed", "widget": "ActivityFeed",
|
|
26
|
+
"title": "Activity"},
|
|
27
|
+
],
|
|
28
|
+
"active": "agents",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
"focus": "orch",
|
|
33
|
+
})
|