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,65 @@
1
+ from pathlib import Path
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import VerticalScroll
5
+ from textual.widgets import Markdown as _TxMarkdown
6
+
7
+
8
+ class MarkdownPanel(VerticalScroll):
9
+ """Renders markdown text from `source` or `file_path`, wrapped in a
10
+ VerticalScroll so long documents are scrollable. The internal
11
+ `_markdown` attribute holds the source string for tests.
12
+
13
+ The class is named `MarkdownPanel` rather than `Markdown` to avoid
14
+ a CSS type-selector collision with `textual.widgets.Markdown`. Textual's
15
+ Markdown widget declares `Markdown { height: auto; overflow-y: hidden; }`
16
+ in its DEFAULT_CSS; because Textual matches type selectors by class
17
+ `__name__` and (when SCOPED_CSS=True) only prepends the scope when the
18
+ rule's first selector differs from the scope name, those rules would
19
+ otherwise leak onto our outer container — sizing it to its content and
20
+ suppressing the scrollbar. With distinct class names, each widget's
21
+ DEFAULT_CSS stays in its own lane. The public alias `Markdown` below
22
+ preserves the existing import surface and registry name."""
23
+
24
+ DEFAULT_CSS = """
25
+ MarkdownPanel {
26
+ border: round $surface-lighten-2;
27
+ padding: 0 1;
28
+ }
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ source: str | None = None,
35
+ file_path: str | None = None,
36
+ ) -> None:
37
+ super().__init__()
38
+ if source is None and file_path is not None:
39
+ try:
40
+ source = Path(file_path).read_text(encoding="utf-8")
41
+ except FileNotFoundError:
42
+ source = f"*File not found: {file_path}*"
43
+ except Exception as e:
44
+ source = f"*Error loading {file_path}: {e}*"
45
+ if source is None:
46
+ source = ""
47
+ self._markdown = source
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield _TxMarkdown(self._markdown)
51
+
52
+ @classmethod
53
+ def default_border_title(cls, props: dict) -> str:
54
+ file_path = props.get("file_path")
55
+ if file_path:
56
+ return f"Markdown: {Path(file_path).name}"
57
+ return "Markdown"
58
+
59
+
60
+ # Public alias: keep `from patchbai.widgets.markdown import Markdown` working
61
+ # and the registry name "Markdown" stable. Aliasing does NOT change the
62
+ # class's `__name__` (still "MarkdownPanel"), which is the property Textual
63
+ # uses for CSS type-selector matching — so the leak from textual's own
64
+ # Markdown rule is avoided.
65
+ Markdown = MarkdownPanel
@@ -0,0 +1,31 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Vertical
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Input, Static
5
+
6
+
7
+ class NewTabScreen(ModalScreen[str | None]):
8
+ """Tiny modal that asks for a tab title and dismisses with the entered
9
+ string (or None on escape)."""
10
+
11
+ DEFAULT_CSS = """
12
+ NewTabScreen { align: center middle; }
13
+ NewTabScreen > Vertical {
14
+ width: 50; height: auto; padding: 1 2;
15
+ background: $surface; border: round $primary;
16
+ }
17
+ """
18
+
19
+ BINDINGS = [("escape", "cancel", "cancel")]
20
+
21
+ def compose(self) -> ComposeResult:
22
+ with Vertical():
23
+ yield Static("New tab title:")
24
+ yield Input(placeholder="e.g., Logs", id="new-tab-input")
25
+
26
+ def on_input_submitted(self, event: Input.Submitted) -> None:
27
+ title = (event.value or "").strip()
28
+ self.dismiss(title or None)
29
+
30
+ def action_cancel(self) -> None:
31
+ self.dismiss(None)
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+ from textual.widgets import TextArea
4
+
5
+
6
+ class Notebook(TextArea):
7
+ """Persistent scratch buffer at <cwd>/.patchbai/scratch/<name>.md."""
8
+
9
+ DEFAULT_CSS = """
10
+ Notebook {
11
+ border: round $surface-lighten-2;
12
+ }
13
+ """
14
+
15
+ def __init__(self, *, name: str) -> None:
16
+ super().__init__("", language="markdown")
17
+ self._name = name
18
+
19
+ def _path(self) -> Path:
20
+ cwd = getattr(self.app, "cwd", Path.cwd())
21
+ return Path(cwd) / ".patchbai" / "scratch" / f"{self._name}.md"
22
+
23
+ def on_mount(self) -> None:
24
+ path = self._path()
25
+ if path.exists():
26
+ try:
27
+ self.text = path.read_text(encoding="utf-8")
28
+ except Exception:
29
+ pass
30
+
31
+ def _save(self) -> None:
32
+ path = self._path()
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ path.write_text(self.text, encoding="utf-8")
35
+
36
+ def on_text_area_changed(self, _event) -> None:
37
+ # Saves on every keystroke. Cheap for a scratchpad-sized file.
38
+ self._save()
39
+
40
+ @classmethod
41
+ def default_border_title(cls, props: dict) -> str:
42
+ name = props.get("name")
43
+ if name:
44
+ return f"Note: {name}"
45
+ return "Note"
@@ -0,0 +1,73 @@
1
+ from textual.app import ComposeResult
2
+ from textual.binding import Binding
3
+ from textual.containers import Vertical
4
+ from textual.widgets import Input
5
+
6
+ from patchbai.events import EventBus, UserMessageToOrchestrator
7
+ from patchbai.widgets.rich_transcript import RichTranscript
8
+
9
+
10
+ class OrchestratorChat(Vertical):
11
+ """Manager-Claude chat panel: RichTranscript + input box."""
12
+
13
+ AGENT_ID = "orchestrator"
14
+ DEFAULT_BORDER_TITLE = "Orchestrator"
15
+
16
+ DEFAULT_CSS = """
17
+ OrchestratorChat {
18
+ border: round $primary;
19
+ padding: 0 1;
20
+ }
21
+ OrchestratorChat > RichTranscript {
22
+ height: 1fr;
23
+ }
24
+ OrchestratorChat #orch-input {
25
+ dock: bottom;
26
+ height: 3;
27
+ }
28
+ """
29
+
30
+ # priority=True so the binding fires even when the Input child has
31
+ # focus — without it, ctrl+c is consumed by Textual's default driver
32
+ # handling (which would quit the app).
33
+ BINDINGS = [
34
+ Binding("ctrl+c", "interrupt", "interrupt orchestrator", priority=True),
35
+ ]
36
+
37
+ def __init__(self, *, event_bus: EventBus | None = None) -> None:
38
+ super().__init__()
39
+ self._bus = event_bus
40
+
41
+ def compose(self) -> ComposeResult:
42
+ path = None
43
+ try:
44
+ orch = getattr(self.app, "orchestrator", None)
45
+ if orch is not None:
46
+ path = orch.active_transcript_path
47
+ except Exception:
48
+ path = None
49
+ yield RichTranscript(
50
+ agent_id=self.AGENT_ID, event_bus=self._bus, transcript_path=path,
51
+ )
52
+ yield Input(
53
+ placeholder=(
54
+ "Message orchestrator… "
55
+ "(/reset, /resume, /rename, /help, ctrl+c to interrupt)"
56
+ ),
57
+ id="orch-input",
58
+ )
59
+
60
+ def on_input_submitted(self, event: Input.Submitted) -> None:
61
+ if not event.value.strip():
62
+ return
63
+ text = event.value
64
+ bus = self._bus or getattr(self.app, "event_bus", None)
65
+ event.input.value = ""
66
+ if bus is not None:
67
+ bus.publish(UserMessageToOrchestrator(text))
68
+
69
+ async def action_interrupt(self) -> None:
70
+ orch = getattr(self.app, "orchestrator", None)
71
+ if orch is None:
72
+ return
73
+ await orch.interrupt()
@@ -0,0 +1,179 @@
1
+ import time
2
+
3
+ from textual.binding import Binding
4
+ from textual.containers import Container
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import DataTable, Footer, Input, Label
7
+
8
+ from patchbai.persistence.orchestrator_sessions import (
9
+ OrchestratorSessionEntry,
10
+ OrchestratorSessionsIndex,
11
+ )
12
+
13
+
14
+ class ResumeScreen(ModalScreen[str | None]):
15
+ """Pick a past orchestrator session. Esc dismisses with None;
16
+ Enter dismisses with the selected session_id."""
17
+
18
+ DEFAULT_CSS = """
19
+ ResumeScreen {
20
+ align: center middle;
21
+ }
22
+ ResumeScreen > Container {
23
+ width: 80%;
24
+ height: 70%;
25
+ border: thick $primary;
26
+ background: $surface;
27
+ padding: 1 2;
28
+ }
29
+ ResumeScreen DataTable {
30
+ height: 1fr;
31
+ }
32
+ """
33
+
34
+ BINDINGS = [
35
+ Binding("escape", "dismiss_none", "cancel"),
36
+ Binding("enter", "select", "resume"),
37
+ Binding("e", "rename_row", "rename"),
38
+ ]
39
+
40
+ def __init__(self, *, index: OrchestratorSessionsIndex) -> None:
41
+ super().__init__()
42
+ self._index = index
43
+ self._ordered_ids: list[str] = []
44
+
45
+ def compose(self):
46
+ with Container():
47
+ yield Label("Resume orchestrator session · e: rename"
48
+ " · enter: resume · esc: cancel")
49
+ yield DataTable(cursor_type="row")
50
+ yield Footer()
51
+
52
+ def on_mount(self) -> None:
53
+ self._refresh_table()
54
+
55
+ def _refresh_table(self) -> None:
56
+ table = self.query_one(DataTable)
57
+ table.clear(columns=True)
58
+ table.add_columns("when", "title", "turns", "tokens", "id")
59
+ entries = sorted(self._index.list(), key=lambda e: e.last_activity, reverse=True)
60
+ now = time.time()
61
+ self._ordered_ids = []
62
+ for e in entries:
63
+ table.add_row(
64
+ _relative_time(now - e.last_activity),
65
+ _display_title(e),
66
+ str(e.num_turns),
67
+ f"{e.tokens_in}/{e.tokens_out}",
68
+ e.session_id,
69
+ key=e.session_id,
70
+ )
71
+ self._ordered_ids.append(e.session_id)
72
+ table.focus()
73
+
74
+ def _row_session_ids(self) -> list[str]:
75
+ return list(self._ordered_ids)
76
+
77
+ def action_dismiss_none(self) -> None:
78
+ self.dismiss(None)
79
+
80
+ def action_select(self) -> None:
81
+ table = self.query_one(DataTable)
82
+ if table.row_count == 0:
83
+ self.dismiss(None)
84
+ return
85
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
86
+ self.dismiss(str(row_key.value))
87
+
88
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
89
+ # Stop the event so the App's global on_data_table_row_selected
90
+ # handler doesn't also fire and open a TranscriptScreen with our
91
+ # session_id (which isn't a real agent_id).
92
+ event.stop()
93
+ self.dismiss(str(event.row_key.value))
94
+
95
+ def action_rename_row(self) -> None:
96
+ table = self.query_one(DataTable)
97
+ if table.row_count == 0:
98
+ return
99
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
100
+ sid = str(row_key.value)
101
+ entry = self._index.get(sid)
102
+ if entry is None:
103
+ return
104
+ current = entry.title or ""
105
+
106
+ def _on_renamed(new_title: str | None) -> None:
107
+ if new_title is None:
108
+ return # cancelled
109
+ cleaned = new_title.strip()
110
+ self._index.set_title(sid, cleaned if cleaned else None)
111
+ self._refresh_table()
112
+
113
+ self.app.push_screen(_RenameTitleScreen(initial=current), _on_renamed)
114
+
115
+
116
+ class _RenameTitleScreen(ModalScreen[str | None]):
117
+ """Tiny single-input modal for renaming a session title.
118
+
119
+ Returns the entered string (possibly empty) or None on cancel.
120
+ """
121
+
122
+ DEFAULT_CSS = """
123
+ _RenameTitleScreen {
124
+ align: center middle;
125
+ }
126
+ _RenameTitleScreen > Container {
127
+ width: 60%;
128
+ height: 7;
129
+ border: thick $primary;
130
+ background: $surface;
131
+ padding: 1 2;
132
+ }
133
+ """
134
+
135
+ BINDINGS = [Binding("escape", "cancel", "cancel")]
136
+
137
+ def __init__(self, *, initial: str = "") -> None:
138
+ super().__init__()
139
+ self._initial = initial
140
+
141
+ def compose(self):
142
+ with Container():
143
+ yield Label("Rename session title (enter to save, esc to cancel):")
144
+ yield Input(value=self._initial, id="rename-input")
145
+
146
+ def on_mount(self) -> None:
147
+ self.query_one("#rename-input", Input).focus()
148
+
149
+ def on_input_submitted(self, event: Input.Submitted) -> None:
150
+ self.dismiss(event.value)
151
+
152
+ def action_cancel(self) -> None:
153
+ self.dismiss(None)
154
+
155
+
156
+ def _relative_time(seconds: float) -> str:
157
+ if seconds < 60:
158
+ return f"{int(seconds)}s ago"
159
+ if seconds < 3600:
160
+ return f"{int(seconds / 60)}m ago"
161
+ if seconds < 86400:
162
+ return f"{int(seconds / 3600)}h ago"
163
+ return f"{int(seconds / 86400)}d ago"
164
+
165
+
166
+ def _truncate(s: str, n: int) -> str:
167
+ if len(s) <= n:
168
+ return s
169
+ return s[: n - 1] + "…"
170
+
171
+
172
+ def _display_title(entry: OrchestratorSessionEntry) -> str:
173
+ if entry.title:
174
+ return _truncate(entry.title, 80)
175
+ if entry.first_user_message:
176
+ return _truncate(entry.first_user_message, 80)
177
+ return "(no title)"
178
+
179
+