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,251 @@
1
+ import asyncio
2
+ import os
3
+ import select
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container
7
+ from textual.widgets import Static
8
+
9
+ import ptyprocess
10
+ import pyte
11
+
12
+ from patchbai.widgets._terminal_keys import encode_key
13
+ from patchbai.widgets._terminal_render import render_screen
14
+
15
+
16
+ def _default_command() -> list[str]:
17
+ return [os.environ.get("SHELL", "/bin/sh")]
18
+
19
+
20
+ class Terminal(Container):
21
+ """Real PTY hosted in a Textual panel.
22
+
23
+ Spawns a subprocess via ptyprocess.PtyProcessUnicode, feeds output
24
+ through pyte for ANSI emulation, and re-renders whenever the PTY fd
25
+ becomes readable via asyncio.add_reader. Anything typed here is
26
+ OPAQUE to the orchestrator (intentional escape-hatch behavior — use
27
+ this for an interactive `claude` CLI session inside Patchbai).
28
+
29
+ Props:
30
+ command: argv list (default: [$SHELL])
31
+ cwd: working directory (default: process cwd)
32
+ env: extra env vars merged into os.environ
33
+
34
+ Limitations: no mouse forwarding; no bracketed paste; no Kitty
35
+ keyboard protocol. POSIX-only (ptyprocess).
36
+ """
37
+
38
+ DEFAULT_CSS = """
39
+ Terminal {
40
+ border: round $surface-lighten-2;
41
+ padding: 0 1;
42
+ background: black;
43
+ color: white;
44
+ }
45
+ Terminal Static {
46
+ background: black;
47
+ }
48
+ """
49
+
50
+ can_focus = True
51
+
52
+ DEFAULT_COLS = 80
53
+ DEFAULT_ROWS = 24
54
+ HISTORY_LINES = 2000 # scrollback rows; memory grows linearly with terminal width
55
+ READ_BUDGET_BYTES = 64 * 1024 # per-tick drain cap; hitting it is fine — add_reader fires again next tick
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ command: list[str] | None = None,
61
+ cwd: str | None = None,
62
+ env: dict | None = None,
63
+ ) -> None:
64
+ super().__init__()
65
+ self._command = command or _default_command()
66
+ self._cwd = cwd
67
+ environ = dict(os.environ)
68
+ if env:
69
+ environ.update(env)
70
+ self._env = environ
71
+ self._pty = None
72
+ self._screen = pyte.HistoryScreen(self.DEFAULT_COLS, self.DEFAULT_ROWS, history=self.HISTORY_LINES)
73
+ self._stream = pyte.Stream(self._screen)
74
+ self._timer = None
75
+ self._reader_registered: bool = False
76
+ self._last_write: bytes | None = None
77
+
78
+ def compose(self) -> ComposeResult:
79
+ yield Static("", id="terminal-screen")
80
+
81
+ def on_mount(self) -> None:
82
+ try:
83
+ self._pty = ptyprocess.PtyProcessUnicode.spawn(
84
+ self._command,
85
+ cwd=self._cwd,
86
+ env=self._env,
87
+ dimensions=(self.DEFAULT_ROWS, self.DEFAULT_COLS),
88
+ )
89
+ except Exception as e:
90
+ self._show_error(f"PTY spawn failed: {e}")
91
+ return
92
+ loop = asyncio.get_running_loop()
93
+ loop.add_reader(self._pty.fd, self._tick)
94
+ self._reader_registered = True
95
+
96
+ def on_resize(self, event) -> None:
97
+ """Propagate Textual size changes to the PTY and the pyte screen.
98
+
99
+ Uses `self.content_size` (region shrunk by the styles gutter, i.e.
100
+ border + padding) as the authoritative inner dimensions. This is
101
+ populated by the time the Resize event fires, so we don't need to
102
+ consult the inner Static (whose auto-height can collapse to 1) or
103
+ subtract CSS chrome by hand.
104
+ """
105
+ if self._pty is None:
106
+ return
107
+ inner = self.content_size
108
+ cols = max(1, inner.width)
109
+ rows = max(1, inner.height)
110
+ if cols == self._screen.columns and rows == self._screen.lines:
111
+ return
112
+ # setwinsize and screen.resize form a logical pair: if one fails the
113
+ # other leaves the system in an inconsistent state, so they share a
114
+ # single guard. No logging in Phase 1 — failures are silent for now.
115
+ try:
116
+ self._pty.setwinsize(rows, cols)
117
+ self._screen.resize(rows, cols)
118
+ except Exception:
119
+ return
120
+ self._refresh()
121
+
122
+ def on_unmount(self) -> None:
123
+ self._teardown()
124
+
125
+ def _teardown(self) -> None:
126
+ if self._reader_registered and self._pty is not None:
127
+ try:
128
+ asyncio.get_running_loop().remove_reader(self._pty.fd)
129
+ except Exception:
130
+ pass
131
+ self._reader_registered = False
132
+ if self._timer is not None:
133
+ try:
134
+ self._timer.stop()
135
+ except Exception:
136
+ pass
137
+ self._timer = None
138
+ if self._pty is not None:
139
+ try:
140
+ self._pty.close(force=True)
141
+ except Exception:
142
+ pass
143
+ self._pty = None
144
+
145
+ def _announce_exit(self) -> None:
146
+ # Capture status if available before teardown clears _pty.
147
+ # Banner status: int (real exit code), or "?" sentinel when we can't determine it.
148
+ status: int | str = "?"
149
+ if self._pty is not None:
150
+ try:
151
+ ex = self._pty.exitstatus
152
+ if ex is None:
153
+ # isalive() polls waitpid(WNOHANG) and populates exitstatus
154
+ # as a side effect. Non-blocking; safe even if a grandchild
155
+ # inherited the slave fd and is still running.
156
+ if not self._pty.isalive():
157
+ ex = self._pty.exitstatus
158
+ if ex is not None:
159
+ status = ex
160
+ except Exception:
161
+ pass
162
+ banner = f"\r\n[process exited {status}]\r\n"
163
+ try:
164
+ self._stream.feed(banner)
165
+ except Exception:
166
+ pass
167
+ self._teardown()
168
+ self._refresh()
169
+
170
+ def action_restart(self) -> None:
171
+ """Respawn the subprocess in-place. Safe to call after exit."""
172
+ if self._pty is not None:
173
+ # Already running — nothing to do.
174
+ return
175
+ # Reset the screen so the old session's tail doesn't accumulate forever.
176
+ self._screen = pyte.HistoryScreen(
177
+ self._screen.columns, self._screen.lines, history=self.HISTORY_LINES
178
+ )
179
+ self._stream = pyte.Stream(self._screen)
180
+ self.on_mount()
181
+
182
+ def _tick(self) -> None:
183
+ if self._pty is None:
184
+ return
185
+ # Drain everything available without blocking, but bounded so a flooding
186
+ # child can't starve the asyncio loop. add_reader will fire again next tick.
187
+ any_data = False
188
+ bytes_read = 0
189
+ eof = False
190
+ while bytes_read < self.READ_BUDGET_BYTES:
191
+ try:
192
+ ready, _, _ = select.select([self._pty.fd], [], [], 0)
193
+ if not ready:
194
+ break
195
+ chunk = self._pty.read(4096)
196
+ except EOFError:
197
+ eof = True
198
+ break
199
+ except Exception:
200
+ # TODO(phase 2): surface PTY read errors via _show_error
201
+ break
202
+ if not chunk:
203
+ eof = True
204
+ break
205
+ self._stream.feed(chunk)
206
+ bytes_read += len(chunk)
207
+ any_data = True
208
+ if eof:
209
+ self._announce_exit()
210
+ elif any_data:
211
+ self._refresh()
212
+
213
+ def _refresh(self) -> None:
214
+ try:
215
+ screen = self.query_one("#terminal-screen", Static)
216
+ except Exception:
217
+ return
218
+ text = render_screen(self._screen, show_cursor=True)
219
+ screen.update(text)
220
+
221
+ def _show_error(self, msg: str) -> None:
222
+ from rich.text import Text
223
+ try:
224
+ self.query_one("#terminal-screen", Static).update(Text(msg))
225
+ except Exception:
226
+ pass
227
+
228
+ def on_key(self, event) -> None:
229
+ if self._pty is None:
230
+ return
231
+ data = encode_key(event.key, event.character)
232
+ if data is None:
233
+ return
234
+ try:
235
+ # encode_key returns bytes; PtyProcessUnicode.write wraps a utf-8 text
236
+ # stream, so decode->re-encode is lossless for any output of encode_key
237
+ # (all paths produce well-formed utf-8).
238
+ self._pty.write(data.decode("utf-8", errors="replace"))
239
+ except Exception:
240
+ # TODO(phase 2): surface PTY write errors via _show_error
241
+ return
242
+ self._last_write = data
243
+ event.stop()
244
+
245
+ @classmethod
246
+ def default_border_title(cls, props: dict) -> str:
247
+ from pathlib import Path as _P
248
+ command = props.get("command")
249
+ if command and isinstance(command, list) and len(command) > 0:
250
+ return f"Terminal: {_P(command[0]).name}"
251
+ return "Terminal"
@@ -0,0 +1,63 @@
1
+ from textual.binding import Binding
2
+ from textual.containers import Container
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Footer, Label, ListItem, ListView
5
+
6
+ from patchbai.persistence.themes_store import NamedThemesStore
7
+
8
+
9
+ class ThemeSwitcherScreen(ModalScreen[str | None]):
10
+ """Pick a theme. Esc dismisses with None; selecting dismisses with the name."""
11
+
12
+ DEFAULT_CSS = """
13
+ ThemeSwitcherScreen {
14
+ align: center middle;
15
+ }
16
+ ThemeSwitcherScreen > Container {
17
+ width: 50%;
18
+ height: 60%;
19
+ border: thick $primary;
20
+ background: $surface;
21
+ padding: 1 2;
22
+ }
23
+ ThemeSwitcherScreen ListView {
24
+ height: 1fr;
25
+ }
26
+ """
27
+
28
+ BINDINGS = [Binding("escape", "dismiss_none", "cancel")]
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ store: NamedThemesStore,
34
+ available_builtins: list[str],
35
+ active: str,
36
+ ) -> None:
37
+ super().__init__()
38
+ self._store = store
39
+ self._builtins = list(available_builtins)
40
+ self._active = active
41
+
42
+ def compose(self):
43
+ items: list[ListItem] = []
44
+ for name in self._store.list():
45
+ label = f"* {name}" if name == self._active else f" {name}"
46
+ items.append(ListItem(Label(label), name=name))
47
+ if self._builtins:
48
+ items.append(ListItem(Label("─ built-ins ─"), name=None))
49
+ for name in self._builtins:
50
+ label = f"* {name}" if name == self._active else f" {name}"
51
+ items.append(ListItem(Label(label), name=name))
52
+ with Container():
53
+ yield Label("Load theme:")
54
+ yield ListView(*items)
55
+ yield Footer()
56
+
57
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
58
+ if event.item.name is None:
59
+ return # separator row — ignore
60
+ self.dismiss(event.item.name)
61
+
62
+ def action_dismiss_none(self) -> None:
63
+ self.dismiss(None)
@@ -0,0 +1,39 @@
1
+ from textual.binding import Binding
2
+ from textual.containers import Container
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Footer
5
+
6
+ from patchbai.events import EventBus
7
+ from patchbai.widgets.agent_transcript import AgentTranscript
8
+
9
+
10
+ class TranscriptScreen(ModalScreen[None]):
11
+ """Modal overlay showing one agent's transcript. Esc to dismiss."""
12
+
13
+ DEFAULT_CSS = """
14
+ TranscriptScreen {
15
+ align: center middle;
16
+ }
17
+ TranscriptScreen > Container {
18
+ width: 80%;
19
+ height: 80%;
20
+ border: thick $primary;
21
+ background: $surface;
22
+ padding: 1 2;
23
+ }
24
+ """
25
+
26
+ BINDINGS = [Binding("escape", "dismiss", "close")]
27
+
28
+ def __init__(self, agent_id: str, event_bus: EventBus | None = None) -> None:
29
+ super().__init__()
30
+ self._agent_id = agent_id
31
+ self._bus = event_bus
32
+
33
+ def compose(self):
34
+ with Container():
35
+ yield AgentTranscript(agent_id=self._agent_id, event_bus=self._bus)
36
+ yield Footer()
37
+
38
+ def action_dismiss(self) -> None:
39
+ self.dismiss(None)
@@ -0,0 +1,3 @@
1
+ from patchbai.workspace.spec import Tab, Workspace, workspace_from_layout
2
+
3
+ __all__ = ["Tab", "Workspace", "workspace_from_layout"]
@@ -0,0 +1,72 @@
1
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
2
+
3
+ from patchbai.layout.spec import LayoutSpec, Node, Panel, Tabs
4
+
5
+
6
+ class Tab(BaseModel):
7
+ """One app-level tab. Owns its own LayoutSpec, which is independently
8
+ mutable. `id` is stable across the tab's lifetime and used by
9
+ switch_tab/close_tab tool calls. `title` is the user-facing tab-strip
10
+ label."""
11
+ model_config = ConfigDict(extra="forbid")
12
+
13
+ id: str
14
+ title: str
15
+ layout: LayoutSpec
16
+
17
+
18
+ class Workspace(BaseModel):
19
+ """Top-level container. Holds a list of Tabs and an active id.
20
+
21
+ Invariants:
22
+ - Non-empty tab list.
23
+ - `active` references one of `tabs[].id`.
24
+ - At least one OrchestratorChat panel exists across all tabs combined.
25
+ - Tab ids are unique.
26
+ """
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ version: int = 1
30
+ tabs: list[Tab] = Field(min_length=1)
31
+ active: str
32
+ active_theme: str | None = None
33
+
34
+ @model_validator(mode="after")
35
+ def _validate(self) -> "Workspace":
36
+ ids = [t.id for t in self.tabs]
37
+ if len(ids) != len(set(ids)):
38
+ raise ValueError(f"duplicate tab id in {ids}")
39
+ if self.active not in set(ids):
40
+ raise ValueError(
41
+ f"active tab id {self.active!r} not in tab ids {ids}"
42
+ )
43
+ if not any(_contains_chat(t.layout.layout) for t in self.tabs):
44
+ raise ValueError(
45
+ "Workspace must contain at least one OrchestratorChat panel "
46
+ "across all tabs"
47
+ )
48
+ return self
49
+
50
+
51
+ def _contains_chat(node: Node) -> bool:
52
+ if isinstance(node, Panel):
53
+ return node.widget == "OrchestratorChat"
54
+ if isinstance(node, Tabs):
55
+ # Tabs.children is list[Panel] (leaf-only invariant in spec.py), so
56
+ # a flat scan is sufficient — no recursion needed. If Tabs ever
57
+ # accepts nested containers, this branch must recurse like Container.
58
+ return any(c.widget == "OrchestratorChat" for c in node.children)
59
+ # node is Container — exhausted by the discriminated Node union.
60
+ return any(_contains_chat(c) for c in node.children)
61
+
62
+
63
+ def workspace_from_layout(spec: LayoutSpec, *, tab_id: str = "default",
64
+ title: str = "default") -> Workspace:
65
+ """Build a single-tab Workspace wrapping a LayoutSpec — used by app
66
+ launch to seed the workspace from the built-in dashboard or migrate
67
+ a legacy layout.json."""
68
+ return Workspace(
69
+ version=1,
70
+ tabs=[Tab(id=tab_id, title=title, layout=spec)],
71
+ active=tab_id,
72
+ )