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,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)