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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import weakref
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from patchbai.events import LayoutApplied, LayoutFailed
|
|
5
|
+
from patchbai.layout.spec import Container, LayoutSpec, Panel, Tabs
|
|
6
|
+
from patchbai.layout.titles import resolve_title
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# --- Operations -------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MountPanel:
|
|
13
|
+
panel: Panel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class UnmountPanel:
|
|
18
|
+
panel_id: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class UpdateProps:
|
|
23
|
+
panel_id: str
|
|
24
|
+
props: dict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Operation = MountPanel | UnmountPanel | UpdateProps
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Diff -------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def _collect_panels(node, out: dict[str, Panel]) -> None:
|
|
33
|
+
if isinstance(node, Panel):
|
|
34
|
+
out[node.id] = node
|
|
35
|
+
elif isinstance(node, Tabs):
|
|
36
|
+
for c in node.children:
|
|
37
|
+
out[c.id] = c
|
|
38
|
+
elif isinstance(node, Container):
|
|
39
|
+
for c in node.children:
|
|
40
|
+
_collect_panels(c, out)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def diff(old: LayoutSpec | None, new: LayoutSpec) -> list[Operation]:
|
|
44
|
+
"""Compute the minimal set of mount/unmount/update operations to take the
|
|
45
|
+
rendered widget tree from `old` to `new`.
|
|
46
|
+
|
|
47
|
+
Note: this plan reuses widgets only when the panel id AND widget type are
|
|
48
|
+
unchanged. Container restructuring is handled by Task 14's apply step,
|
|
49
|
+
which rebuilds the container scaffolding from `new.layout` each call.
|
|
50
|
+
Reusing identical panels means no scroll-jump or focus-loss for the cases
|
|
51
|
+
that matter most (props-only changes)."""
|
|
52
|
+
|
|
53
|
+
old_panels: dict[str, Panel] = {}
|
|
54
|
+
new_panels: dict[str, Panel] = {}
|
|
55
|
+
if old is not None:
|
|
56
|
+
_collect_panels(old.layout, old_panels)
|
|
57
|
+
_collect_panels(new.layout, new_panels)
|
|
58
|
+
|
|
59
|
+
ops: list[Operation] = []
|
|
60
|
+
|
|
61
|
+
for pid, op in old_panels.items():
|
|
62
|
+
if pid not in new_panels or new_panels[pid].widget != op.widget:
|
|
63
|
+
ops.append(UnmountPanel(panel_id=pid))
|
|
64
|
+
|
|
65
|
+
for pid, np in new_panels.items():
|
|
66
|
+
if pid not in old_panels or old_panels[pid].widget != np.widget:
|
|
67
|
+
ops.append(MountPanel(panel=np))
|
|
68
|
+
continue
|
|
69
|
+
if old_panels[pid].props != np.props:
|
|
70
|
+
ops.append(UpdateProps(panel_id=pid, props=np.props))
|
|
71
|
+
|
|
72
|
+
return ops
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Apply ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
from textual.containers import Container as TxContainer
|
|
78
|
+
from textual.containers import Horizontal, Vertical
|
|
79
|
+
from textual.widget import Widget
|
|
80
|
+
from textual.widgets import TabbedContent, TabPane
|
|
81
|
+
|
|
82
|
+
from patchbai.layout.splitter import Splitter
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _has_border_in_default_css(cls) -> bool:
|
|
86
|
+
"""Heuristic: does this class (or an ancestor) declare a border in
|
|
87
|
+
DEFAULT_CSS? Used by the safety net so we don't clobber a widget's own
|
|
88
|
+
border style. Walks the MRO so subclasses inherit the answer.
|
|
89
|
+
|
|
90
|
+
Naive substring match — will false-positive on `border:` appearing in
|
|
91
|
+
a CSS comment or in a selector name like `.border-panel`. The failure
|
|
92
|
+
mode (skipping the safety net when we shouldn't) is cosmetic, not
|
|
93
|
+
functional, and rare enough to accept here."""
|
|
94
|
+
for base in cls.__mro__:
|
|
95
|
+
css = getattr(base, "DEFAULT_CSS", "") or ""
|
|
96
|
+
if isinstance(css, str) and ("border:" in css or "border-" in css):
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build(node, registry, path: tuple[int, ...] = ()) -> Widget:
|
|
102
|
+
if isinstance(node, Panel):
|
|
103
|
+
cls = registry.get(node.widget)
|
|
104
|
+
widget = cls(**node.props) if node.props else cls()
|
|
105
|
+
widget.id = f"panel-{node.id}"
|
|
106
|
+
widget.can_focus = True # panels must be focusable so focus survives rebuilds
|
|
107
|
+
if node.size:
|
|
108
|
+
widget.styles.width = node.size if "%" in node.size or node.size.endswith("fr") else None
|
|
109
|
+
widget.styles.height = None
|
|
110
|
+
# Border safety net: widgets with no DEFAULT_CSS border get a default
|
|
111
|
+
# one so border_title renders. Widgets with their own border keep it.
|
|
112
|
+
# CSS variables (e.g. $surface-lighten-2) are not valid as inline style
|
|
113
|
+
# color values before mount — use a concrete grey that approximates the
|
|
114
|
+
# default dark-mode surface colour.
|
|
115
|
+
if not _has_border_in_default_css(cls):
|
|
116
|
+
widget.styles.border = ("round", "#3a3a3a")
|
|
117
|
+
# Title resolution (never aborts the apply on a buggy widget).
|
|
118
|
+
try:
|
|
119
|
+
widget.border_title = resolve_title(node, cls)
|
|
120
|
+
except Exception:
|
|
121
|
+
widget.border_title = cls.__name__
|
|
122
|
+
widget._patchbai_spec_path = path # type: ignore[attr-defined]
|
|
123
|
+
return widget
|
|
124
|
+
if isinstance(node, Tabs):
|
|
125
|
+
panes = []
|
|
126
|
+
for i, child in enumerate(node.children):
|
|
127
|
+
inner = _build(child, registry, path + (i,)) # reuses Panel branch
|
|
128
|
+
label = child.title or _default_pane_label(child, registry)
|
|
129
|
+
panes.append(TabPane(label, inner, id=f"tabpane-{child.id}"))
|
|
130
|
+
initial_id = f"tabpane-{node.active or node.children[0].id}"
|
|
131
|
+
# TabbedContent.__init__ takes *titles as strings; passing TabPane
|
|
132
|
+
# objects there triggers render_str and fails pre-mount. Instead,
|
|
133
|
+
# construct with no positional args and load panes via _tab_content
|
|
134
|
+
# (the same slot that compose_add_child fills when using the context
|
|
135
|
+
# manager syntax), then set _initial for the active tab.
|
|
136
|
+
tc = TabbedContent(initial=initial_id)
|
|
137
|
+
tc._tab_content = list(panes)
|
|
138
|
+
if node.size:
|
|
139
|
+
tc.styles.width = node.size
|
|
140
|
+
tc._patchbai_spec_path = path # type: ignore[attr-defined]
|
|
141
|
+
return tc
|
|
142
|
+
# Container
|
|
143
|
+
box_cls = Horizontal if node.type == "horizontal" else Vertical
|
|
144
|
+
built = [_build(c, registry, path + (i,)) for i, c in enumerate(node.children)]
|
|
145
|
+
# Interleave a draggable Splitter between each pair of siblings so the user
|
|
146
|
+
# can resize panels with the mouse. Single-child containers get no splitter.
|
|
147
|
+
interleaved: list[Widget] = []
|
|
148
|
+
for i, child in enumerate(built):
|
|
149
|
+
if i > 0:
|
|
150
|
+
# Splitter sits between spec children (i-1) and i of this Container.
|
|
151
|
+
interleaved.append(Splitter(
|
|
152
|
+
node.type, parent_path=path, prev_index=i - 1, next_index=i,
|
|
153
|
+
))
|
|
154
|
+
interleaved.append(child)
|
|
155
|
+
box = box_cls(*interleaved)
|
|
156
|
+
if node.size:
|
|
157
|
+
box.styles.width = node.size
|
|
158
|
+
box._patchbai_spec_path = path # type: ignore[attr-defined]
|
|
159
|
+
return box
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _default_pane_label(panel: Panel, registry) -> str:
|
|
163
|
+
"""Best-effort label for a TabPane when the panel has no explicit title.
|
|
164
|
+
Reuses resolve_title against the widget class so widgets that publish a
|
|
165
|
+
DEFAULT_BORDER_TITLE feed the tab label too."""
|
|
166
|
+
try:
|
|
167
|
+
cls = registry.get(panel.widget)
|
|
168
|
+
return resolve_title(panel, cls)
|
|
169
|
+
except Exception:
|
|
170
|
+
return panel.widget
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Track the most recent applied spec per container, keyed by the container
|
|
174
|
+
# instance itself. WeakKeyDictionary ensures stale entries gc out when the
|
|
175
|
+
# container is no longer referenced — important since `apply` is called many
|
|
176
|
+
# times per session in tests, and id-keyed caches are footguns once the
|
|
177
|
+
# garbage collector reuses ids.
|
|
178
|
+
_last_applied_spec: "weakref.WeakKeyDictionary[TxContainer, LayoutSpec]" = weakref.WeakKeyDictionary()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def apply(container: TxContainer, spec: LayoutSpec, registry,
|
|
182
|
+
*, layout_name: str | None = None,
|
|
183
|
+
apply_focus: bool = True) -> None:
|
|
184
|
+
"""Replace `container`'s children with widgets built from `spec.layout`.
|
|
185
|
+
|
|
186
|
+
Behavior:
|
|
187
|
+
- **Idempotent fast-path:** if `spec` equals the last-applied spec for this
|
|
188
|
+
container, skip the rebuild entirely. A `LayoutApplied` event is still
|
|
189
|
+
fired so subscribers can refresh anything spec-derived (e.g., the
|
|
190
|
+
StatusBar's layout name).
|
|
191
|
+
- **Atomic build:** the new widget tree is fully constructed before any
|
|
192
|
+
existing child is removed. If `_build` raises (e.g., UnknownWidgetError),
|
|
193
|
+
a `LayoutFailed(error=str(exc))` event is published and the previous
|
|
194
|
+
mounted layout stays untouched. The exception is re-raised.
|
|
195
|
+
- **Focus preservation:** if `spec.focus` is None and a panel is currently
|
|
196
|
+
focused, restore that panel's id after the rebuild (provided the panel
|
|
197
|
+
survives in the new spec).
|
|
198
|
+
- **`apply_focus=False`** skips the focus call entirely. Use when applying
|
|
199
|
+
a layout into a TabPane that isn't the workspace's active tab — focusing
|
|
200
|
+
a widget inside a hidden pane causes Textual to switch the displayed
|
|
201
|
+
pane to the one containing that widget, which silently overrides the
|
|
202
|
+
caller's intended active tab.
|
|
203
|
+
"""
|
|
204
|
+
bus = getattr(container.app, "event_bus", None)
|
|
205
|
+
|
|
206
|
+
# Fast-path: same spec → no rebuild.
|
|
207
|
+
if _last_applied_spec.get(container) == spec:
|
|
208
|
+
if bus is not None:
|
|
209
|
+
bus.publish(LayoutApplied(spec=spec, layout_name=layout_name))
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# Atomic build (raises before any mount changes).
|
|
213
|
+
try:
|
|
214
|
+
new_children = [_build(spec.layout, registry)]
|
|
215
|
+
except Exception as e:
|
|
216
|
+
if bus is not None:
|
|
217
|
+
bus.publish(LayoutFailed(error=str(e)))
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
# Snapshot focus.
|
|
221
|
+
snapshot_focus_id: str | None = None
|
|
222
|
+
try:
|
|
223
|
+
focused = container.app.focused
|
|
224
|
+
if focused is not None and focused.id and focused.id.startswith("panel-"):
|
|
225
|
+
snapshot_focus_id = focused.id[len("panel-"):]
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
await container.remove_children()
|
|
230
|
+
await container.mount_all(new_children)
|
|
231
|
+
|
|
232
|
+
target = spec.focus or snapshot_focus_id
|
|
233
|
+
if target and apply_focus:
|
|
234
|
+
try:
|
|
235
|
+
container.query_one(f"#panel-{target}").focus()
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
_last_applied_spec[container] = spec
|
|
240
|
+
if bus is not None:
|
|
241
|
+
bus.publish(LayoutApplied(spec=spec, layout_name=layout_name))
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import traceback
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from textual.widget import Widget
|
|
13
|
+
|
|
14
|
+
from patchbai.layout.registry import WidgetRegistry
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
LoadStatus = Literal[
|
|
20
|
+
"ok",
|
|
21
|
+
"import_error",
|
|
22
|
+
"no_widget_class",
|
|
23
|
+
"ambiguous_class",
|
|
24
|
+
"name_collision",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class LoadOutcome:
|
|
30
|
+
path: Path
|
|
31
|
+
name: str
|
|
32
|
+
status: LoadStatus
|
|
33
|
+
error: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocalWidgetLoader:
|
|
37
|
+
"""Walks a directory, imports every top-level .py file as an isolated
|
|
38
|
+
module, finds its Textual Widget subclass, and registers it.
|
|
39
|
+
|
|
40
|
+
Files starting with '_' or '.' are skipped.
|
|
41
|
+
Missing/empty directory yields an empty outcome list (not an error).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, dir_path: Path, registry: WidgetRegistry) -> None:
|
|
45
|
+
self._dir = Path(dir_path)
|
|
46
|
+
self._registry = registry
|
|
47
|
+
|
|
48
|
+
def load(self) -> list[LoadOutcome]:
|
|
49
|
+
if not self._dir.exists():
|
|
50
|
+
return []
|
|
51
|
+
outcomes: list[LoadOutcome] = []
|
|
52
|
+
for path in sorted(self._dir.iterdir()):
|
|
53
|
+
if not path.is_file() or path.suffix != ".py":
|
|
54
|
+
continue
|
|
55
|
+
if path.name.startswith(("_", ".")):
|
|
56
|
+
continue
|
|
57
|
+
outcomes.append(self._load_one(path))
|
|
58
|
+
return outcomes
|
|
59
|
+
|
|
60
|
+
def _load_one(self, path: Path) -> LoadOutcome:
|
|
61
|
+
stem = path.stem
|
|
62
|
+
# Use a unique module name to avoid collisions in sys.modules.
|
|
63
|
+
mod_name = f"_patchbai_local_widget_{stem}"
|
|
64
|
+
try:
|
|
65
|
+
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
66
|
+
assert spec is not None and spec.loader is not None
|
|
67
|
+
module = importlib.util.module_from_spec(spec)
|
|
68
|
+
sys.modules[mod_name] = module
|
|
69
|
+
spec.loader.exec_module(module)
|
|
70
|
+
except Exception:
|
|
71
|
+
tb = traceback.format_exc(limit=2)
|
|
72
|
+
return LoadOutcome(
|
|
73
|
+
path=path, name=stem, status="import_error", error=tb,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
meta = getattr(module, "__patchbai_widget__", {}) or {}
|
|
77
|
+
if not isinstance(meta, dict):
|
|
78
|
+
meta = {}
|
|
79
|
+
name = meta.get("name") or _pascal(stem)
|
|
80
|
+
|
|
81
|
+
# Name-collision with an already-registered builtin: skip.
|
|
82
|
+
existing = self._registry._infos.get(name)
|
|
83
|
+
if existing is not None and existing.source == "builtin":
|
|
84
|
+
return LoadOutcome(
|
|
85
|
+
path=path, name=name, status="name_collision",
|
|
86
|
+
error=f"name {name!r} is reserved by the built-in registry",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
cls, err = _find_widget_class_in_module(module, meta, name)
|
|
90
|
+
if cls is None:
|
|
91
|
+
return LoadOutcome(path=path, name=name, status=err or "no_widget_class")
|
|
92
|
+
|
|
93
|
+
self._registry.register(
|
|
94
|
+
name, cls,
|
|
95
|
+
description=meta.get("description", ""),
|
|
96
|
+
props_schema=dict(meta.get("props_schema") or {}),
|
|
97
|
+
source="local",
|
|
98
|
+
)
|
|
99
|
+
return LoadOutcome(path=path, name=name, status="ok")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pascal(stem: str) -> str:
|
|
103
|
+
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_") if part)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _find_widget_class_in_module(module, meta: dict, name: str):
|
|
107
|
+
"""Returns (cls_or_none, status_on_failure)."""
|
|
108
|
+
# 1. Explicit entry_point in metadata.
|
|
109
|
+
ep = meta.get("entry_point")
|
|
110
|
+
if isinstance(ep, type) and issubclass(ep, Widget):
|
|
111
|
+
return ep, None
|
|
112
|
+
|
|
113
|
+
# 2. WIDGET_CLASS sentinel.
|
|
114
|
+
sentinel = getattr(module, "WIDGET_CLASS", None)
|
|
115
|
+
if isinstance(sentinel, type) and issubclass(sentinel, Widget):
|
|
116
|
+
return sentinel, None
|
|
117
|
+
|
|
118
|
+
# 3. Class named exactly `name`, defined in this module
|
|
119
|
+
# (so an imported Widget subclass referenced by metadata `name`
|
|
120
|
+
# doesn't accidentally register as a local widget).
|
|
121
|
+
by_name = getattr(module, name, None)
|
|
122
|
+
if (
|
|
123
|
+
isinstance(by_name, type)
|
|
124
|
+
and issubclass(by_name, Widget)
|
|
125
|
+
and getattr(by_name, "__module__", None) == module.__name__
|
|
126
|
+
):
|
|
127
|
+
return by_name, None
|
|
128
|
+
|
|
129
|
+
# 4. Single Widget subclass DEFINED in this module.
|
|
130
|
+
candidates = []
|
|
131
|
+
for v in vars(module).values():
|
|
132
|
+
if (
|
|
133
|
+
isinstance(v, type)
|
|
134
|
+
and issubclass(v, Widget)
|
|
135
|
+
and v is not Widget
|
|
136
|
+
and getattr(v, "__module__", None) == module.__name__
|
|
137
|
+
):
|
|
138
|
+
candidates.append(v)
|
|
139
|
+
unique = list({id(c): c for c in candidates}.values())
|
|
140
|
+
if len(unique) == 1:
|
|
141
|
+
return unique[0], None
|
|
142
|
+
if len(unique) > 1:
|
|
143
|
+
return None, "ambiguous_class"
|
|
144
|
+
return None, "no_widget_class"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_widget_source(
|
|
148
|
+
name: str, source: str,
|
|
149
|
+
) -> tuple[type[Widget] | None, dict, str]:
|
|
150
|
+
"""Compile `source` and run the §2 class-detection precedence WITHOUT
|
|
151
|
+
registering anything. Returns (cls, meta, "") on success, or
|
|
152
|
+
(None, {}, error_text) on failure. `error_text` is one of: a one-line
|
|
153
|
+
syntax error excerpt, "no_widget_class", "ambiguous_class", or another
|
|
154
|
+
short description. `meta` is the imported module's `__patchbai_widget__`
|
|
155
|
+
dict (or `{}` if absent) — used by `save_widget` to register description
|
|
156
|
+
and props_schema in the live registry.
|
|
157
|
+
|
|
158
|
+
Used by the `save_widget` MCP tool to validate orchestrator-supplied
|
|
159
|
+
source before committing it to ~/.config/patchbai/widgets/.
|
|
160
|
+
"""
|
|
161
|
+
with tempfile.TemporaryDirectory() as td:
|
|
162
|
+
path = Path(td) / f"{name}.py"
|
|
163
|
+
try:
|
|
164
|
+
path.write_text(source, encoding="utf-8")
|
|
165
|
+
except OSError as e:
|
|
166
|
+
return None, {}, f"could not stage source for validation: {e}"
|
|
167
|
+
|
|
168
|
+
mod_name = f"_patchbai_validate_widget_{name}"
|
|
169
|
+
try:
|
|
170
|
+
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
171
|
+
assert spec is not None and spec.loader is not None
|
|
172
|
+
module = importlib.util.module_from_spec(spec)
|
|
173
|
+
sys.modules[mod_name] = module
|
|
174
|
+
try:
|
|
175
|
+
spec.loader.exec_module(module)
|
|
176
|
+
finally:
|
|
177
|
+
# Don't leak a module entry that points at a deleted tempdir.
|
|
178
|
+
sys.modules.pop(mod_name, None)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return None, {}, f"{type(e).__name__}: {e}"
|
|
181
|
+
|
|
182
|
+
meta = getattr(module, "__patchbai_widget__", {}) or {}
|
|
183
|
+
if not isinstance(meta, dict):
|
|
184
|
+
meta = {}
|
|
185
|
+
cls, err = _find_widget_class_in_module(module, meta, name)
|
|
186
|
+
if cls is None:
|
|
187
|
+
return None, {}, err or "no_widget_class"
|
|
188
|
+
return cls, meta, ""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UnknownWidgetError(KeyError):
|
|
8
|
+
"""Raised when a LayoutSpec references a widget name that is not registered."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
WidgetSource = Literal["builtin", "local", "inline"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class WidgetInfo:
|
|
16
|
+
name: str
|
|
17
|
+
cls: type[Widget]
|
|
18
|
+
description: str = ""
|
|
19
|
+
props_schema: dict = field(default_factory=dict)
|
|
20
|
+
source: WidgetSource = "builtin"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WidgetRegistry:
|
|
24
|
+
"""Maps widget-type strings (as used in LayoutSpec) to Textual classes,
|
|
25
|
+
plus optional metadata for the orchestrator's list_widgets tool.
|
|
26
|
+
|
|
27
|
+
Mode-C `register_custom_widget(name, source)` (which `exec`s code into an
|
|
28
|
+
isolated namespace) is intentionally NOT implemented in this plan — it
|
|
29
|
+
arrives in plan 6.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._infos: dict[str, WidgetInfo] = {}
|
|
34
|
+
|
|
35
|
+
def register(
|
|
36
|
+
self,
|
|
37
|
+
name: str,
|
|
38
|
+
cls: type[Widget],
|
|
39
|
+
*,
|
|
40
|
+
description: str = "",
|
|
41
|
+
props_schema: dict | None = None,
|
|
42
|
+
source: WidgetSource = "builtin",
|
|
43
|
+
) -> None:
|
|
44
|
+
self._infos[name] = WidgetInfo(
|
|
45
|
+
name=name, cls=cls,
|
|
46
|
+
description=description,
|
|
47
|
+
props_schema=dict(props_schema) if props_schema else {},
|
|
48
|
+
source=source,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def unregister(self, name: str) -> None:
|
|
52
|
+
"""Remove a widget registration. No-op if `name` was never registered."""
|
|
53
|
+
self._infos.pop(name, None)
|
|
54
|
+
|
|
55
|
+
def get(self, name: str) -> type[Widget]:
|
|
56
|
+
if name not in self._infos:
|
|
57
|
+
raise UnknownWidgetError(name)
|
|
58
|
+
return self._infos[name].cls
|
|
59
|
+
|
|
60
|
+
def known(self) -> list[str]:
|
|
61
|
+
return list(self._infos.keys())
|
|
62
|
+
|
|
63
|
+
def describe(self, name: str) -> WidgetInfo:
|
|
64
|
+
if name not in self._infos:
|
|
65
|
+
raise KeyError(name)
|
|
66
|
+
return self._infos[name]
|
|
67
|
+
|
|
68
|
+
def describe_all(self) -> list[WidgetInfo]:
|
|
69
|
+
return sorted(self._infos.values(), key=lambda i: i.name)
|
patchbai/layout/spec.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from typing import Annotated, Literal, Union
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Panel(BaseModel):
|
|
7
|
+
"""A leaf node — one widget instance."""
|
|
8
|
+
model_config = ConfigDict(extra="forbid")
|
|
9
|
+
|
|
10
|
+
id: str
|
|
11
|
+
widget: str
|
|
12
|
+
props: dict = Field(default_factory=dict)
|
|
13
|
+
size: str | None = None
|
|
14
|
+
title: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Container(BaseModel):
|
|
18
|
+
"""A non-leaf node — splits its area horizontally or vertically."""
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
type: Literal["horizontal", "vertical"]
|
|
22
|
+
size: str | None = None
|
|
23
|
+
children: list["Node"] = Field(min_length=1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Tabs(BaseModel):
|
|
27
|
+
"""A tabbed leaf-container — each child is one widget reachable via a
|
|
28
|
+
per-panel tab strip. Each tab holds exactly one Panel; splits inside
|
|
29
|
+
a single tab are not allowed.
|
|
30
|
+
|
|
31
|
+
`active` is the panel id of the initial tab; when None, the first
|
|
32
|
+
child is the initial tab."""
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
type: Literal["tabs"]
|
|
36
|
+
size: str | None = None
|
|
37
|
+
children: list[Panel] = Field(min_length=1)
|
|
38
|
+
active: str | None = None
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def _active_must_match_a_child(self) -> "Tabs":
|
|
42
|
+
if self.active is not None:
|
|
43
|
+
ids = {p.id for p in self.children}
|
|
44
|
+
if self.active not in ids:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Tabs.active={self.active!r} does not match any child panel id; "
|
|
47
|
+
f"expected one of {sorted(ids)}"
|
|
48
|
+
)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Discriminated union: Container and Tabs share the `children` shape but
|
|
53
|
+
# differ on `type`. Pydantic v2 dispatches on the literal `type` value.
|
|
54
|
+
# Panel has no `type` field and is matched by absence — placed last so
|
|
55
|
+
# union resolution prefers the typed branches first.
|
|
56
|
+
_TypedNode = Annotated[Union[Container, Tabs], Field(discriminator="type")]
|
|
57
|
+
Node = Union[_TypedNode, Panel]
|
|
58
|
+
Container.model_rebuild()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CustomWidget(BaseModel):
|
|
62
|
+
"""A user/orchestrator-supplied Textual widget class."""
|
|
63
|
+
model_config = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
source: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LayoutSpec(BaseModel):
|
|
70
|
+
"""Root of the layout description.
|
|
71
|
+
|
|
72
|
+
Validation invariant: at most one panel with widget='OrchestratorChat'
|
|
73
|
+
in `layout`. The "at least one chat across all tabs" half is enforced
|
|
74
|
+
by the Workspace model — a single LayoutSpec may have zero chats
|
|
75
|
+
(e.g., a logs-only tab).
|
|
76
|
+
|
|
77
|
+
The `focus` field names a panel id to receive keyboard focus on apply,
|
|
78
|
+
but is NOT validated against the tree at parse time — LayoutEngine.apply
|
|
79
|
+
silently no-ops if the id does not exist when the layout is mounted.
|
|
80
|
+
"""
|
|
81
|
+
model_config = ConfigDict(extra="forbid")
|
|
82
|
+
|
|
83
|
+
version: int = 1
|
|
84
|
+
layout: Node
|
|
85
|
+
focus: str | None = None
|
|
86
|
+
custom_widgets: list[CustomWidget] = Field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def _at_most_one_orchestrator_chat(self) -> "LayoutSpec":
|
|
90
|
+
count = _count_orchestrator(self.layout)
|
|
91
|
+
if count > 1:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"LayoutSpec must contain at most one OrchestratorChat panel"
|
|
94
|
+
)
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _count_orchestrator(node: Node) -> int:
|
|
99
|
+
if isinstance(node, Panel):
|
|
100
|
+
return 1 if node.widget == "OrchestratorChat" else 0
|
|
101
|
+
if isinstance(node, Tabs):
|
|
102
|
+
return sum(1 for c in node.children if c.widget == "OrchestratorChat")
|
|
103
|
+
# Container
|
|
104
|
+
return sum(_count_orchestrator(c) for c in node.children)
|