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