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.
Files changed (76) hide show
  1. patchbai/__init__.py +1 -0
  2. patchbai/__main__.py +10 -0
  3. patchbai/actions.py +34 -0
  4. patchbai/activity/__init__.py +0 -0
  5. patchbai/activity/log.py +237 -0
  6. patchbai/agents/__init__.py +0 -0
  7. patchbai/agents/child_tools.py +66 -0
  8. patchbai/agents/fake_sdk_adapter.py +45 -0
  9. patchbai/agents/manager.py +272 -0
  10. patchbai/agents/request_inbox.py +65 -0
  11. patchbai/agents/sdk_adapter.py +49 -0
  12. patchbai/agents/session.py +224 -0
  13. patchbai/agents/sort.py +66 -0
  14. patchbai/agents/state.py +80 -0
  15. patchbai/app.py +1288 -0
  16. patchbai/config.py +128 -0
  17. patchbai/events.py +236 -0
  18. patchbai/layout/__init__.py +0 -0
  19. patchbai/layout/custom_widgets.py +82 -0
  20. patchbai/layout/defaults.py +33 -0
  21. patchbai/layout/engine.py +241 -0
  22. patchbai/layout/local_widgets.py +188 -0
  23. patchbai/layout/registry.py +69 -0
  24. patchbai/layout/spec.py +104 -0
  25. patchbai/layout/splitter.py +170 -0
  26. patchbai/layout/titles.py +70 -0
  27. patchbai/orchestrator/__init__.py +0 -0
  28. patchbai/orchestrator/formatting.py +15 -0
  29. patchbai/orchestrator/session.py +644 -0
  30. patchbai/orchestrator/tabs_tools.py +149 -0
  31. patchbai/orchestrator/tools.py +976 -0
  32. patchbai/persistence/__init__.py +0 -0
  33. patchbai/persistence/agents_index.py +68 -0
  34. patchbai/persistence/atomic.py +47 -0
  35. patchbai/persistence/layout_store.py +25 -0
  36. patchbai/persistence/layouts_store.py +61 -0
  37. patchbai/persistence/orchestrator_sessions.py +127 -0
  38. patchbai/persistence/paths.py +48 -0
  39. patchbai/persistence/themes_store.py +44 -0
  40. patchbai/persistence/transcript_store.py +64 -0
  41. patchbai/persistence/workspace_store.py +25 -0
  42. patchbai/theme/__init__.py +0 -0
  43. patchbai/theme/engine.py +75 -0
  44. patchbai/theme/spec.py +31 -0
  45. patchbai/widgets/__init__.py +0 -0
  46. patchbai/widgets/_file_lang.py +36 -0
  47. patchbai/widgets/_terminal_keys.py +89 -0
  48. patchbai/widgets/_terminal_render.py +147 -0
  49. patchbai/widgets/activity_feed.py +365 -0
  50. patchbai/widgets/agent_table.py +235 -0
  51. patchbai/widgets/agent_transcript.py +58 -0
  52. patchbai/widgets/change_cwd_screen.py +39 -0
  53. patchbai/widgets/chrome.py +210 -0
  54. patchbai/widgets/diff_viewer.py +52 -0
  55. patchbai/widgets/file_editor.py +258 -0
  56. patchbai/widgets/file_tree.py +33 -0
  57. patchbai/widgets/file_viewer.py +77 -0
  58. patchbai/widgets/history_screen.py +58 -0
  59. patchbai/widgets/layout_switcher.py +126 -0
  60. patchbai/widgets/log_tail.py +113 -0
  61. patchbai/widgets/markdown.py +65 -0
  62. patchbai/widgets/new_tab_screen.py +31 -0
  63. patchbai/widgets/notebook.py +45 -0
  64. patchbai/widgets/orchestrator_chat.py +73 -0
  65. patchbai/widgets/resume_screen.py +179 -0
  66. patchbai/widgets/rich_transcript.py +606 -0
  67. patchbai/widgets/terminal.py +251 -0
  68. patchbai/widgets/theme_switcher.py +63 -0
  69. patchbai/widgets/transcript_screen.py +39 -0
  70. patchbai/workspace/__init__.py +3 -0
  71. patchbai/workspace/spec.py +72 -0
  72. patchbai-0.1.0.dist-info/METADATA +573 -0
  73. patchbai-0.1.0.dist-info/RECORD +76 -0
  74. patchbai-0.1.0.dist-info/WHEEL +4 -0
  75. patchbai-0.1.0.dist-info/entry_points.txt +3 -0
  76. 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)
@@ -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)