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,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual import events
|
|
4
|
+
from textual.containers import Container as TxContainer
|
|
5
|
+
from textual.widget import Widget
|
|
6
|
+
|
|
7
|
+
from patchbai.events import LayoutResized
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Splitter(Widget):
|
|
11
|
+
"""Draggable 1-cell bar between sibling widgets in a Horizontal/Vertical box.
|
|
12
|
+
|
|
13
|
+
On mouse drag, mutates the previous and next siblings' inline
|
|
14
|
+
`styles.width`/`styles.height` to fixed cell counts (visual feedback).
|
|
15
|
+
On mouse-up, emits a LayoutResized event with the final sizes converted
|
|
16
|
+
to percentages of the parent container's inner extent so the app can
|
|
17
|
+
persist them to the workspace.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
Splitter {
|
|
22
|
+
background: $panel;
|
|
23
|
+
color: $foreground 30%;
|
|
24
|
+
}
|
|
25
|
+
Splitter.-vertical {
|
|
26
|
+
width: 1;
|
|
27
|
+
height: 1fr;
|
|
28
|
+
}
|
|
29
|
+
Splitter.-horizontal {
|
|
30
|
+
width: 1fr;
|
|
31
|
+
height: 1;
|
|
32
|
+
}
|
|
33
|
+
Splitter:hover, Splitter.-dragging {
|
|
34
|
+
background: $accent;
|
|
35
|
+
color: $accent;
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
can_focus = False
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
container_orientation: str,
|
|
44
|
+
*,
|
|
45
|
+
parent_path: tuple[int, ...] = (),
|
|
46
|
+
prev_index: int = 0,
|
|
47
|
+
next_index: int = 1,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
self._container_orientation = container_orientation
|
|
51
|
+
self._parent_path = parent_path
|
|
52
|
+
self._prev_index = prev_index
|
|
53
|
+
self._next_index = next_index
|
|
54
|
+
self.add_class("-vertical" if container_orientation == "horizontal" else "-horizontal")
|
|
55
|
+
self._drag_start: tuple[int, int] | None = None
|
|
56
|
+
self._initial_prev: int = 0
|
|
57
|
+
self._initial_next: int = 0
|
|
58
|
+
|
|
59
|
+
def render(self) -> str:
|
|
60
|
+
return "│" if self._container_orientation == "horizontal" else "─"
|
|
61
|
+
|
|
62
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
63
|
+
prev_sib, next_sib = self._neighbors()
|
|
64
|
+
if prev_sib is None or next_sib is None:
|
|
65
|
+
return
|
|
66
|
+
if self._container_orientation == "horizontal":
|
|
67
|
+
self._initial_prev = prev_sib.size.width
|
|
68
|
+
self._initial_next = next_sib.size.width
|
|
69
|
+
else:
|
|
70
|
+
self._initial_prev = prev_sib.size.height
|
|
71
|
+
self._initial_next = next_sib.size.height
|
|
72
|
+
self._drag_start = (event.screen_x, event.screen_y)
|
|
73
|
+
self.add_class("-dragging")
|
|
74
|
+
self.capture_mouse()
|
|
75
|
+
event.stop()
|
|
76
|
+
|
|
77
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
78
|
+
if self._drag_start is None:
|
|
79
|
+
return
|
|
80
|
+
prev_sib, next_sib = self._neighbors()
|
|
81
|
+
if prev_sib is None or next_sib is None:
|
|
82
|
+
return
|
|
83
|
+
sx, sy = self._drag_start
|
|
84
|
+
if self._container_orientation == "horizontal":
|
|
85
|
+
delta = event.screen_x - sx
|
|
86
|
+
new_prev = max(1, self._initial_prev + delta)
|
|
87
|
+
new_next = max(1, self._initial_next - delta)
|
|
88
|
+
prev_sib.styles.width = new_prev
|
|
89
|
+
next_sib.styles.width = new_next
|
|
90
|
+
else:
|
|
91
|
+
delta = event.screen_y - sy
|
|
92
|
+
new_prev = max(1, self._initial_prev + delta)
|
|
93
|
+
new_next = max(1, self._initial_next - delta)
|
|
94
|
+
prev_sib.styles.height = new_prev
|
|
95
|
+
next_sib.styles.height = new_next
|
|
96
|
+
event.stop()
|
|
97
|
+
|
|
98
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
99
|
+
if self._drag_start is None:
|
|
100
|
+
return
|
|
101
|
+
sx, sy = self._drag_start
|
|
102
|
+
moved = (event.screen_x, event.screen_y) != (sx, sy)
|
|
103
|
+
self._drag_start = None
|
|
104
|
+
self.remove_class("-dragging")
|
|
105
|
+
self.release_mouse()
|
|
106
|
+
event.stop()
|
|
107
|
+
# A plain click (mouse_down + mouse_up at same coords) shouldn't
|
|
108
|
+
# rewrite the spec — only publish on a real drag.
|
|
109
|
+
if moved:
|
|
110
|
+
self._publish_resize()
|
|
111
|
+
|
|
112
|
+
def _publish_resize(self) -> None:
|
|
113
|
+
"""Snapshot the parent's post-drag layout and emit LayoutResized.
|
|
114
|
+
|
|
115
|
+
Uses each non-splitter child's `outer_size` (full footprint including
|
|
116
|
+
any border) so the app handler can convert to percentages that sum to
|
|
117
|
+
exactly 100% — the previous version compared inner widths against a
|
|
118
|
+
denominator that included the splitter and child borders, so every
|
|
119
|
+
save lost ~3% of the layout to drift."""
|
|
120
|
+
parent = self.parent
|
|
121
|
+
if not isinstance(parent, Widget):
|
|
122
|
+
return
|
|
123
|
+
cells: list[int] = []
|
|
124
|
+
for child in parent.children:
|
|
125
|
+
if isinstance(child, Splitter):
|
|
126
|
+
continue
|
|
127
|
+
if self._container_orientation == "horizontal":
|
|
128
|
+
cells.append(child.outer_size.width)
|
|
129
|
+
else:
|
|
130
|
+
cells.append(child.outer_size.height)
|
|
131
|
+
if not cells or sum(cells) <= 0:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
tab_id = self._owning_tab_id()
|
|
135
|
+
if tab_id is None:
|
|
136
|
+
return
|
|
137
|
+
bus = getattr(self.app, "event_bus", None)
|
|
138
|
+
if bus is None:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
bus.publish(LayoutResized(
|
|
142
|
+
tab_id=tab_id,
|
|
143
|
+
parent_path=self._parent_path,
|
|
144
|
+
children_cells=tuple(cells),
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
def _neighbors(self) -> tuple[Widget | None, Widget | None]:
|
|
148
|
+
parent = self.parent
|
|
149
|
+
if parent is None:
|
|
150
|
+
return (None, None)
|
|
151
|
+
siblings = list(parent.children)
|
|
152
|
+
try:
|
|
153
|
+
idx = siblings.index(self)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return (None, None)
|
|
156
|
+
prev = siblings[idx - 1] if idx > 0 else None
|
|
157
|
+
nxt = siblings[idx + 1] if idx + 1 < len(siblings) else None
|
|
158
|
+
return (prev, nxt)
|
|
159
|
+
|
|
160
|
+
def _owning_tab_id(self) -> str | None:
|
|
161
|
+
"""Walk up ancestors until we find the per-tab `panel-area-{tab_id}`
|
|
162
|
+
container. Returns None if mounted outside an app tab (e.g., tests
|
|
163
|
+
that mount Splitter under a generic Container)."""
|
|
164
|
+
node = self.parent
|
|
165
|
+
while node is not None:
|
|
166
|
+
wid = getattr(node, "id", None)
|
|
167
|
+
if isinstance(node, TxContainer) and wid and wid.startswith("panel-area-"):
|
|
168
|
+
return wid[len("panel-area-"):]
|
|
169
|
+
node = node.parent
|
|
170
|
+
return None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from patchbai.layout.spec import Panel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_title(panel: "Panel | dict[str, Any]", widget_cls: type) -> str:
|
|
7
|
+
"""Resolve the effective border title for a panel.
|
|
8
|
+
|
|
9
|
+
Resolution order:
|
|
10
|
+
1. ``panel.title`` if explicitly set.
|
|
11
|
+
2. ``widget_cls.default_border_title(props)`` classmethod if defined.
|
|
12
|
+
3. ``widget_cls.DEFAULT_BORDER_TITLE`` class attribute if defined.
|
|
13
|
+
4. ``widget_cls.__name__`` as a last-resort fallback.
|
|
14
|
+
|
|
15
|
+
Any exception raised inside ``default_border_title`` is swallowed and the
|
|
16
|
+
resolution falls through to step 3 / step 4. A bad widget must never abort
|
|
17
|
+
layout apply. Non-string returns from ``default_border_title`` or
|
|
18
|
+
``DEFAULT_BORDER_TITLE`` are also ignored — the resolver only emits ``str``.
|
|
19
|
+
|
|
20
|
+
``panel`` may be either a ``Panel`` model or a plain dict from
|
|
21
|
+
``model_dump`` (so ``get_layout`` can call this on a dumped tree without
|
|
22
|
+
re-validating).
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(panel, Panel):
|
|
25
|
+
explicit = panel.title
|
|
26
|
+
props: dict[str, Any] = panel.props or {}
|
|
27
|
+
else:
|
|
28
|
+
explicit = panel.get("title")
|
|
29
|
+
props = panel.get("props") or {}
|
|
30
|
+
|
|
31
|
+
if isinstance(explicit, str) and explicit:
|
|
32
|
+
return explicit
|
|
33
|
+
|
|
34
|
+
fn = getattr(widget_cls, "default_border_title", None)
|
|
35
|
+
if callable(fn):
|
|
36
|
+
try:
|
|
37
|
+
value = fn(props)
|
|
38
|
+
except Exception:
|
|
39
|
+
value = None
|
|
40
|
+
if isinstance(value, str) and value:
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
static = getattr(widget_cls, "DEFAULT_BORDER_TITLE", None)
|
|
44
|
+
if isinstance(static, str) and static:
|
|
45
|
+
return static
|
|
46
|
+
|
|
47
|
+
return widget_cls.__name__
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def populate_effective_titles(node: Any, registry) -> None:
|
|
51
|
+
"""Walk a dumped LayoutSpec tree and fill in each panel's effective title.
|
|
52
|
+
|
|
53
|
+
Operates in-place on the dict returned by ``LayoutSpec.model_dump(mode='json')``.
|
|
54
|
+
Used by the ``get_layout`` MCP tool so the orchestrator sees the same
|
|
55
|
+
titles the user sees.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(node, dict):
|
|
58
|
+
return
|
|
59
|
+
if "widget" in node:
|
|
60
|
+
# Leaf panel.
|
|
61
|
+
if not node.get("title"):
|
|
62
|
+
try:
|
|
63
|
+
cls = registry.get(node["widget"])
|
|
64
|
+
except Exception:
|
|
65
|
+
node["title"] = node["widget"]
|
|
66
|
+
return
|
|
67
|
+
node["title"] = resolve_title(node, cls)
|
|
68
|
+
return
|
|
69
|
+
for child in node.get("children", []):
|
|
70
|
+
populate_effective_titles(child, registry)
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from claude_agent_sdk import AssistantMessage, TextBlock, ThinkingBlock, ToolUseBlock
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_assistant_message(msg: AssistantMessage) -> str:
|
|
5
|
+
parts: list[str] = []
|
|
6
|
+
for block in msg.content:
|
|
7
|
+
if isinstance(block, TextBlock):
|
|
8
|
+
parts.append(block.text)
|
|
9
|
+
elif isinstance(block, ToolUseBlock):
|
|
10
|
+
args = ", ".join(f"{k}={v!r}" for k, v in (block.input or {}).items())
|
|
11
|
+
parts.append(f"[tool: {block.name}]({args})")
|
|
12
|
+
elif isinstance(block, ThinkingBlock):
|
|
13
|
+
# Skipped in plan 2 — too noisy. Plan 5 may render as collapsible.
|
|
14
|
+
continue
|
|
15
|
+
return "".join(parts)
|