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
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
+ })