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
patchbai/app.py
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import secrets
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding, BindingsMap
|
|
7
|
+
from textual.containers import Container
|
|
8
|
+
from textual.keys import _character_to_key
|
|
9
|
+
from textual.widgets import DataTable, TabbedContent, TabPane
|
|
10
|
+
|
|
11
|
+
from patchbai.actions import ActionRegistry
|
|
12
|
+
from patchbai.activity.log import ActivityLog
|
|
13
|
+
from patchbai.agents.manager import AgentManager
|
|
14
|
+
from patchbai.agents.sdk_adapter import RealSDKAdapter
|
|
15
|
+
from patchbai.config import ConfigStore
|
|
16
|
+
from patchbai.events import (
|
|
17
|
+
AgentSpawned, AgentStateChanged, AgentTokensTouched, EventBus, LayoutResized,
|
|
18
|
+
OpenResumePicker, StatsUpdated, TabAdded, TabClosed, TabSwitched,
|
|
19
|
+
)
|
|
20
|
+
from patchbai.layout.defaults import dashboard_layout
|
|
21
|
+
from patchbai.layout.engine import apply as apply_layout
|
|
22
|
+
from patchbai.layout.local_widgets import LocalWidgetLoader, LoadOutcome
|
|
23
|
+
from patchbai.layout.registry import WidgetRegistry
|
|
24
|
+
from patchbai.layout.spec import LayoutSpec
|
|
25
|
+
from patchbai.orchestrator.session import OrchestratorSession
|
|
26
|
+
from patchbai.persistence.layouts_store import NamedLayoutsStore
|
|
27
|
+
from patchbai.persistence.themes_store import NamedThemesStore
|
|
28
|
+
from patchbai.persistence.paths import global_config_dir, local_widgets_dir
|
|
29
|
+
from patchbai.theme.engine import _EXTRA_CSS_KEY, apply_theme, palette_from_textual_theme
|
|
30
|
+
from patchbai.theme.spec import ThemeSpec
|
|
31
|
+
from patchbai.persistence.workspace_store import (
|
|
32
|
+
load_workspace as load_local_workspace,
|
|
33
|
+
save_workspace as save_local_workspace,
|
|
34
|
+
)
|
|
35
|
+
from patchbai.persistence.agents_index import AgentsIndex
|
|
36
|
+
from patchbai.widgets.agent_table import AgentTable
|
|
37
|
+
from patchbai.widgets.agent_transcript import AgentTranscript
|
|
38
|
+
from patchbai.widgets.chrome import CommandBar, StatusBar
|
|
39
|
+
from patchbai.widgets.diff_viewer import DiffViewer
|
|
40
|
+
from patchbai.widgets.file_editor import FileEditor
|
|
41
|
+
from patchbai.widgets.file_tree import FileTree
|
|
42
|
+
from patchbai.widgets.log_tail import LogTail
|
|
43
|
+
from patchbai.widgets.notebook import Notebook
|
|
44
|
+
from patchbai.widgets.file_viewer import FileViewer
|
|
45
|
+
from patchbai.widgets.markdown import Markdown
|
|
46
|
+
from patchbai.persistence.orchestrator_sessions import OrchestratorSessionsIndex
|
|
47
|
+
from patchbai.widgets.history_screen import HistoryScreen
|
|
48
|
+
from patchbai.widgets.layout_switcher import LayoutSwitcherScreen
|
|
49
|
+
from patchbai.widgets.resume_screen import ResumeScreen
|
|
50
|
+
from patchbai.widgets.theme_switcher import ThemeSwitcherScreen
|
|
51
|
+
from patchbai.widgets.new_tab_screen import NewTabScreen
|
|
52
|
+
from patchbai.widgets.orchestrator_chat import OrchestratorChat
|
|
53
|
+
from patchbai.widgets.activity_feed import ActivityFeed
|
|
54
|
+
from patchbai.widgets.terminal import Terminal
|
|
55
|
+
from patchbai.widgets.transcript_screen import TranscriptScreen
|
|
56
|
+
from patchbai.workspace.spec import Tab, Workspace, workspace_from_layout, _contains_chat
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
log = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_container(root_layout: dict, parent_path: tuple[int, ...]) -> dict | None:
|
|
63
|
+
"""Walk `root_layout` (a dict shaped like LayoutSpec.layout) following
|
|
64
|
+
`parent_path` (each step indexes into `children`) and return the parent
|
|
65
|
+
container dict. Returns None if the path doesn't resolve to a node with
|
|
66
|
+
a `children` list."""
|
|
67
|
+
node = root_layout
|
|
68
|
+
for idx in parent_path:
|
|
69
|
+
children = node.get("children")
|
|
70
|
+
if not isinstance(children, list) or idx >= len(children):
|
|
71
|
+
return None
|
|
72
|
+
node = children[idx]
|
|
73
|
+
if not isinstance(node.get("children"), list):
|
|
74
|
+
return None
|
|
75
|
+
return node
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cells_to_percentages(cells: tuple[int, ...] | list[int]) -> list[str]:
|
|
79
|
+
"""Convert a tuple of post-drag outer cell counts into percentage strings
|
|
80
|
+
that sum to exactly 100%. Each value is at least `1%`. The last entry
|
|
81
|
+
absorbs the rounding remainder so the sum is precise."""
|
|
82
|
+
total = sum(cells)
|
|
83
|
+
if total <= 0:
|
|
84
|
+
return [f"{round(100 / max(1, len(cells)))}%" for _ in cells]
|
|
85
|
+
raw = [max(1, round(c / total * 100)) for c in cells]
|
|
86
|
+
raw[-1] += 100 - sum(raw)
|
|
87
|
+
if raw[-1] < 1:
|
|
88
|
+
raw[-1] = 1
|
|
89
|
+
return [f"{n}%" for n in raw]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_resize(
|
|
93
|
+
root_layout: dict,
|
|
94
|
+
parent_path: tuple[int, ...],
|
|
95
|
+
children_cells: tuple[int, ...],
|
|
96
|
+
) -> bool:
|
|
97
|
+
"""Renormalize the targeted parent container's children to percentages
|
|
98
|
+
summing to 100, derived from `children_cells`. Mutates `root_layout` in
|
|
99
|
+
place. Returns True iff the path resolved and the child counts match."""
|
|
100
|
+
parent = _resolve_container(root_layout, parent_path)
|
|
101
|
+
if parent is None:
|
|
102
|
+
return False
|
|
103
|
+
children = parent["children"]
|
|
104
|
+
if len(children) != len(children_cells):
|
|
105
|
+
return False
|
|
106
|
+
for child, pct in zip(children, _cells_to_percentages(children_cells)):
|
|
107
|
+
child["size"] = pct
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _normalize_layout_percentages(layout_dict: dict) -> bool:
|
|
112
|
+
"""Walk a LayoutSpec dict and, for every Container whose children all have
|
|
113
|
+
percentage sizes, scale those percentages to sum to exactly 100%. Repairs
|
|
114
|
+
workspaces saved by older Splitter code that produced sums < 100% (which
|
|
115
|
+
showed up as a growing blank gap on the layout edge). Mutates in place;
|
|
116
|
+
returns True if any container was rewritten."""
|
|
117
|
+
changed = False
|
|
118
|
+
|
|
119
|
+
def _walk(node: dict) -> None:
|
|
120
|
+
nonlocal changed
|
|
121
|
+
children = node.get("children") if isinstance(node, dict) else None
|
|
122
|
+
if not isinstance(children, list):
|
|
123
|
+
return
|
|
124
|
+
sizes = [c.get("size") for c in children]
|
|
125
|
+
if children and all(isinstance(s, str) and s.endswith("%") for s in sizes):
|
|
126
|
+
try:
|
|
127
|
+
nums = [float(s[:-1]) for s in sizes] # type: ignore[union-attr]
|
|
128
|
+
except ValueError:
|
|
129
|
+
nums = []
|
|
130
|
+
total = sum(nums) if nums else 0.0
|
|
131
|
+
if nums and total > 0 and abs(total - 100) > 0.5:
|
|
132
|
+
normalized = _cells_to_percentages(tuple(round(n * 1000) for n in nums))
|
|
133
|
+
for c, pct in zip(children, normalized):
|
|
134
|
+
c["size"] = pct
|
|
135
|
+
changed = True
|
|
136
|
+
for c in children:
|
|
137
|
+
_walk(c)
|
|
138
|
+
|
|
139
|
+
_walk(layout_dict)
|
|
140
|
+
return changed
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_default_registry() -> WidgetRegistry:
|
|
144
|
+
reg = WidgetRegistry()
|
|
145
|
+
reg.register("OrchestratorChat", OrchestratorChat)
|
|
146
|
+
reg.register("AgentTable", AgentTable)
|
|
147
|
+
reg.register(
|
|
148
|
+
"AgentTranscript", AgentTranscript,
|
|
149
|
+
description=(
|
|
150
|
+
"Live, scrolling transcript for one agent with a bottom input "
|
|
151
|
+
"that sends DirectMessageToAgent for that `agent_id`. Mount "
|
|
152
|
+
"this in a panel when the user wants to converse with a "
|
|
153
|
+
"specific child agent without going through the orchestrator."
|
|
154
|
+
),
|
|
155
|
+
props_schema={"agent_id": str},
|
|
156
|
+
)
|
|
157
|
+
reg.register("ActivityFeed", ActivityFeed)
|
|
158
|
+
reg.register(
|
|
159
|
+
"Markdown", Markdown,
|
|
160
|
+
description="Renders markdown from `source` (string) or `file_path`.",
|
|
161
|
+
props_schema={"source": str, "file_path": str},
|
|
162
|
+
)
|
|
163
|
+
reg.register(
|
|
164
|
+
"FileViewer", FileViewer,
|
|
165
|
+
description=(
|
|
166
|
+
"Read-only syntax-highlighted file display. Pass `file_path` for "
|
|
167
|
+
"an initial file. Pass `follow_selection: true` to subscribe to "
|
|
168
|
+
"FileSelected events from a FileTree panel and reload on click."
|
|
169
|
+
),
|
|
170
|
+
props_schema={"file_path": str, "follow_selection": bool},
|
|
171
|
+
)
|
|
172
|
+
reg.register(
|
|
173
|
+
"FileEditor", FileEditor,
|
|
174
|
+
description=(
|
|
175
|
+
"Editable syntax-highlighted file editor. Pass `file_path` for "
|
|
176
|
+
"an initial file. Pass `follow_selection: true` to subscribe to "
|
|
177
|
+
"FileSelected events from a FileTree panel. Ctrl+S saves; the "
|
|
178
|
+
"border title shows ' *' when there are unsaved changes. Prompts "
|
|
179
|
+
"before discarding edits or overwriting external changes."
|
|
180
|
+
),
|
|
181
|
+
props_schema={"file_path": str, "follow_selection": bool},
|
|
182
|
+
)
|
|
183
|
+
reg.register(
|
|
184
|
+
"FileTree", FileTree,
|
|
185
|
+
description=(
|
|
186
|
+
"Directory tree starting at `path`. Publishes a FileSelected "
|
|
187
|
+
"event on the bus when the user selects a file — pair with a "
|
|
188
|
+
"FileViewer(follow_selection=True) to see contents."
|
|
189
|
+
),
|
|
190
|
+
props_schema={"path": str},
|
|
191
|
+
)
|
|
192
|
+
reg.register(
|
|
193
|
+
"DiffViewer", DiffViewer,
|
|
194
|
+
description=(
|
|
195
|
+
"Unified-diff viewer. Pass a precomputed `diff` string, OR pass "
|
|
196
|
+
"`before` + `after` strings for unified-diff computation."
|
|
197
|
+
),
|
|
198
|
+
props_schema={"diff": str, "before": str, "after": str},
|
|
199
|
+
)
|
|
200
|
+
reg.register(
|
|
201
|
+
"LogTail", LogTail,
|
|
202
|
+
description=(
|
|
203
|
+
"Tails an arbitrary file. Polls every 250ms. Optional "
|
|
204
|
+
"`tail_lines` controls how much of the existing tail is shown."
|
|
205
|
+
),
|
|
206
|
+
props_schema={"file_path": str, "tail_lines": int},
|
|
207
|
+
)
|
|
208
|
+
reg.register(
|
|
209
|
+
"Notebook", Notebook,
|
|
210
|
+
description=(
|
|
211
|
+
"Editable scratch buffer; persists to <cwd>/.patchbai/scratch/<name>.md."
|
|
212
|
+
),
|
|
213
|
+
props_schema={"name": str},
|
|
214
|
+
)
|
|
215
|
+
reg.register(
|
|
216
|
+
"Terminal", Terminal,
|
|
217
|
+
description=(
|
|
218
|
+
"Real PTY in a panel. Use this for an interactive `claude` CLI "
|
|
219
|
+
"session inside Patchbai — anything typed here is OPAQUE to the "
|
|
220
|
+
"orchestrator (intentional escape-hatch behavior). Optional "
|
|
221
|
+
"`command` (argv), `cwd`, and `env` props."
|
|
222
|
+
),
|
|
223
|
+
props_schema={"command": list, "cwd": str, "env": dict},
|
|
224
|
+
)
|
|
225
|
+
return reg
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class PatchbaiApp(App):
|
|
229
|
+
"""Plan-4 App: layout + config mutability via orchestrator MCP tools."""
|
|
230
|
+
|
|
231
|
+
CSS = """
|
|
232
|
+
#app-tabs {
|
|
233
|
+
height: 1fr;
|
|
234
|
+
}
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
# Default bindings applied before any config-loaded bindings.
|
|
238
|
+
BINDINGS = [
|
|
239
|
+
Binding("/", "focus_command_bar", "command bar"),
|
|
240
|
+
Binding("ctrl+q", "quit", "quit"),
|
|
241
|
+
Binding("ctrl+h", "open_history", "history"),
|
|
242
|
+
Binding("ctrl+l", "open_layout_switcher", "layouts"),
|
|
243
|
+
Binding("ctrl+shift+l", "open_theme_switcher", "themes"),
|
|
244
|
+
Binding("ctrl+shift+r", "reset_panel_sizes", "reset panel sizes"),
|
|
245
|
+
Binding("ctrl+shift+d", "open_change_cwd", "change cwd"),
|
|
246
|
+
Binding("?", "show_help", "help"),
|
|
247
|
+
Binding("ctrl+t", "new_tab", "new tab", priority=True),
|
|
248
|
+
Binding("ctrl+w", "close_active_tab", "close tab", priority=True),
|
|
249
|
+
Binding("ctrl+1", "switch_tab_index(0)", "tab 1"),
|
|
250
|
+
Binding("ctrl+2", "switch_tab_index(1)", "tab 2"),
|
|
251
|
+
Binding("ctrl+3", "switch_tab_index(2)", "tab 3"),
|
|
252
|
+
Binding("ctrl+4", "switch_tab_index(3)", "tab 4"),
|
|
253
|
+
Binding("ctrl+5", "switch_tab_index(4)", "tab 5"),
|
|
254
|
+
Binding("ctrl+6", "switch_tab_index(5)", "tab 6"),
|
|
255
|
+
Binding("ctrl+7", "switch_tab_index(6)", "tab 7"),
|
|
256
|
+
Binding("ctrl+8", "switch_tab_index(7)", "tab 8"),
|
|
257
|
+
Binding("ctrl+9", "switch_tab_index(8)", "tab 9"),
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
*,
|
|
263
|
+
cwd: Path | None = None,
|
|
264
|
+
registry: WidgetRegistry | None = None,
|
|
265
|
+
manager: AgentManager | None = None,
|
|
266
|
+
orchestrator: OrchestratorSession | None = None,
|
|
267
|
+
global_dir: Path | None = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
super().__init__()
|
|
270
|
+
# Cache for the currently-applied theme's extra_css. Initialized to
|
|
271
|
+
# "" so save_theme can snapshot a clean state before any apply runs.
|
|
272
|
+
self._active_theme_extra_css: str = ""
|
|
273
|
+
self.cwd = Path(cwd) if cwd else Path.cwd()
|
|
274
|
+
import asyncio as _asyncio
|
|
275
|
+
self._cwd_swap_lock = _asyncio.Lock()
|
|
276
|
+
self.event_bus = EventBus()
|
|
277
|
+
self.activity_log = ActivityLog(self.event_bus)
|
|
278
|
+
self._global_dir = Path(global_dir) if global_dir else global_config_dir()
|
|
279
|
+
self.registry = registry or build_default_registry()
|
|
280
|
+
self._local_widget_outcomes: list[LoadOutcome] = []
|
|
281
|
+
# Load user-authored widgets from `~/.config/patchbai/widgets/` UNLESS
|
|
282
|
+
# the caller passed in their own registry (test/embedded use case) OR
|
|
283
|
+
# the widgets.local_dir_enabled flag is False.
|
|
284
|
+
if registry is None:
|
|
285
|
+
from patchbai.config import ConfigStore as _ConfigStore
|
|
286
|
+
_cfg = _ConfigStore(global_dir=self._global_dir).load()
|
|
287
|
+
if _cfg.widgets.local_dir_enabled:
|
|
288
|
+
self._local_widget_outcomes = LocalWidgetLoader(
|
|
289
|
+
local_widgets_dir(global_dir=self._global_dir),
|
|
290
|
+
self.registry,
|
|
291
|
+
).load()
|
|
292
|
+
_ok = sum(1 for o in self._local_widget_outcomes if o.status == "ok")
|
|
293
|
+
_err = sum(1 for o in self._local_widget_outcomes if o.status != "ok")
|
|
294
|
+
if _ok or _err:
|
|
295
|
+
log.info(
|
|
296
|
+
"loaded %d local widgets (%d errors) from %s",
|
|
297
|
+
_ok, _err,
|
|
298
|
+
local_widgets_dir(global_dir=self._global_dir),
|
|
299
|
+
)
|
|
300
|
+
self._workspace: Workspace | None = None
|
|
301
|
+
self._active_tab_id: str | None = None
|
|
302
|
+
self._current_layout_name: str | None = None # last `load_layout` name
|
|
303
|
+
self._tab_focus_snapshots: dict[str, str] = {} # tab_id -> last focused panel id
|
|
304
|
+
# While _mount_workspace is building tabs, child widgets that auto-focus
|
|
305
|
+
# on mount (Input, etc.) can drag the active TabPane around. Suppress
|
|
306
|
+
# workspace-state updates from those activations so the saved ws.active
|
|
307
|
+
# is what sticks once the dust settles. _mount_workspace clears this
|
|
308
|
+
# and explicitly re-pins tc.active after a refresh tick.
|
|
309
|
+
self._mounting_workspace: bool = False
|
|
310
|
+
self.config_store = ConfigStore(global_dir=self._global_dir)
|
|
311
|
+
self.layouts_store = NamedLayoutsStore(global_dir=self._global_dir)
|
|
312
|
+
self.themes_store = NamedThemesStore(global_dir=self._global_dir)
|
|
313
|
+
self.actions_registry = ActionRegistry()
|
|
314
|
+
self._register_actions()
|
|
315
|
+
self.manager = manager or AgentManager(
|
|
316
|
+
cwd=self.cwd,
|
|
317
|
+
bus=self.event_bus,
|
|
318
|
+
adapter_factory=RealSDKAdapter,
|
|
319
|
+
)
|
|
320
|
+
self.orchestrator = orchestrator or OrchestratorSession(
|
|
321
|
+
cwd=self.cwd,
|
|
322
|
+
bus=self.event_bus,
|
|
323
|
+
manager=self.manager,
|
|
324
|
+
apply_layout=self._orchestrator_apply_layout,
|
|
325
|
+
layouts_store=self.layouts_store,
|
|
326
|
+
themes_store=self.themes_store,
|
|
327
|
+
config_store=self.config_store,
|
|
328
|
+
actions=self.actions_registry,
|
|
329
|
+
rebind_keys=self._rebind_keys,
|
|
330
|
+
widget_registry=self.registry,
|
|
331
|
+
current_layout=lambda: self._active_layout(),
|
|
332
|
+
app=self,
|
|
333
|
+
)
|
|
334
|
+
# Production opts in to LLM-summarized session titles.
|
|
335
|
+
self.orchestrator._auto_title_enabled = True
|
|
336
|
+
|
|
337
|
+
# --- action registration -----------------------------------------------
|
|
338
|
+
|
|
339
|
+
def _register_actions(self) -> None:
|
|
340
|
+
self.actions_registry.register(
|
|
341
|
+
"focus_command_bar", self.action_focus_command_bar,
|
|
342
|
+
description="Move focus to the top command bar.", args_schema={},
|
|
343
|
+
)
|
|
344
|
+
self.actions_registry.register(
|
|
345
|
+
"focus_orchestrator",
|
|
346
|
+
lambda: self._focus_panel("orch"),
|
|
347
|
+
description="Focus the orchestrator chat panel.", args_schema={},
|
|
348
|
+
)
|
|
349
|
+
self.actions_registry.register(
|
|
350
|
+
"focus_panel",
|
|
351
|
+
lambda panel_id: self._focus_panel(panel_id),
|
|
352
|
+
description="Focus a specific panel by id.", args_schema={"panel_id": str},
|
|
353
|
+
)
|
|
354
|
+
self.actions_registry.register(
|
|
355
|
+
"cycle_focus", self.action_focus_next,
|
|
356
|
+
description="Move focus to the next focusable widget.", args_schema={},
|
|
357
|
+
)
|
|
358
|
+
self.actions_registry.register(
|
|
359
|
+
"quit", self.action_quit,
|
|
360
|
+
description="Quit the application.", args_schema={},
|
|
361
|
+
)
|
|
362
|
+
self.actions_registry.register(
|
|
363
|
+
"show_help", self.action_show_help,
|
|
364
|
+
description="Show the keybindings help notification.", args_schema={},
|
|
365
|
+
)
|
|
366
|
+
self.actions_registry.register(
|
|
367
|
+
"open_history", self.action_open_history,
|
|
368
|
+
description="Open the agent history modal.", args_schema={},
|
|
369
|
+
)
|
|
370
|
+
self.actions_registry.register(
|
|
371
|
+
"open_layout_switcher", self.action_open_layout_switcher,
|
|
372
|
+
description="Open the saved-layouts switcher modal.", args_schema={},
|
|
373
|
+
)
|
|
374
|
+
self.actions_registry.register(
|
|
375
|
+
"open_theme_switcher", self.action_open_theme_switcher,
|
|
376
|
+
description="Open the saved-themes switcher modal.", args_schema={},
|
|
377
|
+
)
|
|
378
|
+
self.actions_registry.register(
|
|
379
|
+
"reset_panel_sizes", self.action_reset_panel_sizes,
|
|
380
|
+
description=(
|
|
381
|
+
"Discard mouse-drag panel size adjustments by reloading the "
|
|
382
|
+
"active tab's layout from its named source."
|
|
383
|
+
),
|
|
384
|
+
args_schema={},
|
|
385
|
+
)
|
|
386
|
+
self.actions_registry.register(
|
|
387
|
+
"change_cwd",
|
|
388
|
+
lambda path: self._dispatch_change_cwd(path),
|
|
389
|
+
description="Change the workspace's cwd at runtime.",
|
|
390
|
+
args_schema={"path": str},
|
|
391
|
+
)
|
|
392
|
+
self.actions_registry.register(
|
|
393
|
+
"open_change_cwd", self.action_open_change_cwd,
|
|
394
|
+
description="Open the change-cwd modal.", args_schema={},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _focus_panel(self, panel_id: str) -> None:
|
|
398
|
+
try:
|
|
399
|
+
self.query_one(f"#panel-{panel_id}").focus()
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
# --- dynamic bindings --------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def _rebind_keys(self) -> None:
|
|
406
|
+
"""Load bindings from config and apply them at runtime.
|
|
407
|
+
|
|
408
|
+
Textual 8.x: _bindings is an instance-level BindingsMap set during
|
|
409
|
+
DOMNode.__init__ from the class-level _merged_bindings cache. We
|
|
410
|
+
reset the instance's BindingsMap to a fresh copy of the class-level
|
|
411
|
+
defaults, then layer the config bindings on top.
|
|
412
|
+
"""
|
|
413
|
+
# Start from a fresh copy of the class-level merged bindings
|
|
414
|
+
# (which includes BINDINGS declared above).
|
|
415
|
+
base = type(self)._merged_bindings
|
|
416
|
+
if base is not None:
|
|
417
|
+
self._bindings = base.copy()
|
|
418
|
+
else:
|
|
419
|
+
self._bindings = BindingsMap(type(self).BINDINGS)
|
|
420
|
+
|
|
421
|
+
# Layer config bindings on top.
|
|
422
|
+
cfg = self.config_store.load()
|
|
423
|
+
for key, b in cfg.bindings.items():
|
|
424
|
+
# priority=True bindings fire before the focused widget gets the
|
|
425
|
+
# key, which prevents Inputs from receiving printable characters
|
|
426
|
+
# (e.g. "/", "?"). Only modifier-combo keys (ctrl+/alt+/shift+)
|
|
427
|
+
# need priority — single-character bindings like "/" must not,
|
|
428
|
+
# so they reach the focused Input as text.
|
|
429
|
+
is_modifier_combo = "+" in key
|
|
430
|
+
# Normalize single non-alphanumeric characters to their Textual key
|
|
431
|
+
# name (e.g. "~" → "tilde") so that pilot.press / live key events
|
|
432
|
+
# match the binding key stored in key_to_bindings.
|
|
433
|
+
if len(key) == 1 and not key.isalnum():
|
|
434
|
+
key = _character_to_key(key)
|
|
435
|
+
self._bindings._add_binding(
|
|
436
|
+
Binding(
|
|
437
|
+
key, f"dispatch('{b.action}')", b.action,
|
|
438
|
+
priority=is_modifier_combo,
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Ask Textual to refresh any Footer / binding-display widgets.
|
|
443
|
+
try:
|
|
444
|
+
self.refresh_bindings()
|
|
445
|
+
except AttributeError:
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
# --- tab workspace-mutation surface ------------------------------------
|
|
449
|
+
|
|
450
|
+
def _generate_tab_id(self) -> str:
|
|
451
|
+
"""Short, collision-checked id."""
|
|
452
|
+
existing = {t.id for t in (self._workspace.tabs if self._workspace else [])}
|
|
453
|
+
while True:
|
|
454
|
+
candidate = secrets.token_hex(3) # 6 hex chars
|
|
455
|
+
if candidate not in existing:
|
|
456
|
+
return candidate
|
|
457
|
+
|
|
458
|
+
def _default_seed_layout(self) -> LayoutSpec:
|
|
459
|
+
"""Layout used when add_tab is called with no layout arg.
|
|
460
|
+
|
|
461
|
+
If the workspace already has chat in another tab, seed with a chat-less
|
|
462
|
+
ActivityFeed (most 'add a new tab' requests will be followed by a
|
|
463
|
+
set_layout). Otherwise seed with an OrchestratorChat panel."""
|
|
464
|
+
has_chat = False
|
|
465
|
+
if self._workspace is not None:
|
|
466
|
+
has_chat = any(_contains_chat(t.layout.layout) for t in self._workspace.tabs)
|
|
467
|
+
if has_chat:
|
|
468
|
+
return LayoutSpec.model_validate({
|
|
469
|
+
"version": 1,
|
|
470
|
+
"layout": {"id": "feed", "widget": "ActivityFeed"},
|
|
471
|
+
})
|
|
472
|
+
return LayoutSpec.model_validate({
|
|
473
|
+
"version": 1,
|
|
474
|
+
"layout": {"id": "orch", "widget": "OrchestratorChat"},
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
async def close_tab(self, tab_id: str) -> dict:
|
|
478
|
+
"""Close a tab. Returns a small result dict; never raises on bad input."""
|
|
479
|
+
if self._workspace is None:
|
|
480
|
+
return {"error": "workspace_not_initialized"}
|
|
481
|
+
ws = self._workspace
|
|
482
|
+
tabs = ws.tabs
|
|
483
|
+
target = next((t for t in tabs if t.id == tab_id), None)
|
|
484
|
+
if target is None:
|
|
485
|
+
return {"error": "unknown_tab_id"}
|
|
486
|
+
if len(tabs) == 1:
|
|
487
|
+
return {"error": "would_leave_zero_tabs"}
|
|
488
|
+
remaining = [t for t in tabs if t.id != tab_id]
|
|
489
|
+
if not any(_contains_chat(t.layout.layout) for t in remaining):
|
|
490
|
+
return {"error": "would_leave_no_chat",
|
|
491
|
+
"suggestion": "add OrchestratorChat to another tab before closing this one"}
|
|
492
|
+
# Determine new active: previous tab, or the first remaining if we close index 0.
|
|
493
|
+
if self._active_tab_id == tab_id:
|
|
494
|
+
idx = next(i for i, t in enumerate(tabs) if t.id == tab_id)
|
|
495
|
+
fallback_idx = max(0, idx - 1)
|
|
496
|
+
new_active = remaining[min(fallback_idx, len(remaining) - 1)].id
|
|
497
|
+
else:
|
|
498
|
+
new_active = self._active_tab_id # type: ignore[assignment]
|
|
499
|
+
self._workspace = Workspace.model_validate({
|
|
500
|
+
"version": ws.version,
|
|
501
|
+
"tabs": [t.model_dump(mode="json") for t in remaining],
|
|
502
|
+
"active": new_active,
|
|
503
|
+
})
|
|
504
|
+
self._tab_focus_snapshots.pop(tab_id, None)
|
|
505
|
+
# Update _active_tab_id BEFORE awaiting remove_pane: removing the
|
|
506
|
+
# active pane can fire TabActivated synchronously, and the handler
|
|
507
|
+
# short-circuits when new_active == self._active_tab_id. Without this
|
|
508
|
+
# ordering the handler would run with stale state.
|
|
509
|
+
if self._active_tab_id == tab_id:
|
|
510
|
+
self._active_tab_id = new_active
|
|
511
|
+
tc = self.query_one("#app-tabs", TabbedContent)
|
|
512
|
+
await tc.remove_pane(f"tab-{tab_id}")
|
|
513
|
+
if tc.active != f"tab-{new_active}":
|
|
514
|
+
tc.active = f"tab-{new_active}"
|
|
515
|
+
save_local_workspace(self.cwd, self._workspace)
|
|
516
|
+
self.event_bus.publish(TabClosed(tab_id=tab_id))
|
|
517
|
+
return {"closed": tab_id, "new_active": new_active}
|
|
518
|
+
|
|
519
|
+
async def add_tab(self, title: str, layout: LayoutSpec, *, activate: bool = True) -> str:
|
|
520
|
+
"""Append a new tab. Returns the new tab id. Updates persistence."""
|
|
521
|
+
if self._workspace is None:
|
|
522
|
+
raise RuntimeError("workspace not yet initialized")
|
|
523
|
+
ws = self._workspace
|
|
524
|
+
new_id = self._generate_tab_id()
|
|
525
|
+
new_tab = Tab(id=new_id, title=title, layout=layout)
|
|
526
|
+
new_ws = Workspace.model_validate({
|
|
527
|
+
"version": ws.version,
|
|
528
|
+
"tabs": [t.model_dump(mode="json") for t in ws.tabs] + [new_tab.model_dump(mode="json")],
|
|
529
|
+
"active": new_id if activate else ws.active,
|
|
530
|
+
})
|
|
531
|
+
self._workspace = new_ws
|
|
532
|
+
# Mount the new pane and apply its layout.
|
|
533
|
+
tc = self.query_one("#app-tabs", TabbedContent)
|
|
534
|
+
pane = TabPane(title, Container(id=f"panel-area-{new_id}"), id=f"tab-{new_id}")
|
|
535
|
+
await tc.add_pane(pane)
|
|
536
|
+
area = self.query_one(f"#panel-area-{new_id}", Container)
|
|
537
|
+
await apply_layout(area, layout, self.registry, layout_name=None)
|
|
538
|
+
if activate:
|
|
539
|
+
tc.active = f"tab-{new_id}"
|
|
540
|
+
self._active_tab_id = new_id
|
|
541
|
+
save_local_workspace(self.cwd, self._workspace)
|
|
542
|
+
self.event_bus.publish(TabAdded(tab_id=new_id, title=title))
|
|
543
|
+
return new_id
|
|
544
|
+
|
|
545
|
+
async def rename_tab(self, tab_id: str, title: str) -> dict:
|
|
546
|
+
"""Update the user-facing label of a tab. Returns a small result dict;
|
|
547
|
+
never raises on bad input."""
|
|
548
|
+
if self._workspace is None:
|
|
549
|
+
return {"error": "workspace_not_initialized"}
|
|
550
|
+
ws = self._workspace
|
|
551
|
+
if all(t.id != tab_id for t in ws.tabs):
|
|
552
|
+
return {"error": "unknown_tab_id", "tab_id": tab_id}
|
|
553
|
+
if not isinstance(title, str) or not title.strip():
|
|
554
|
+
return {"error": "title_must_be_nonempty_string"}
|
|
555
|
+
new_ws = Workspace.model_validate({
|
|
556
|
+
"version": ws.version,
|
|
557
|
+
"tabs": [
|
|
558
|
+
{**t.model_dump(mode="json"), "title": title}
|
|
559
|
+
if t.id == tab_id else t.model_dump(mode="json")
|
|
560
|
+
for t in ws.tabs
|
|
561
|
+
],
|
|
562
|
+
"active": ws.active,
|
|
563
|
+
"active_theme": ws.active_theme,
|
|
564
|
+
})
|
|
565
|
+
self._workspace = new_ws
|
|
566
|
+
# Update the strip label without re-mounting the pane (preserves widget state).
|
|
567
|
+
try:
|
|
568
|
+
tc = self.query_one("#app-tabs", TabbedContent)
|
|
569
|
+
tab = tc.get_tab(f"tab-{tab_id}")
|
|
570
|
+
tab.label = title # type: ignore[assignment]
|
|
571
|
+
except Exception:
|
|
572
|
+
# If the lookup fails (e.g., transient state), persistence still wins —
|
|
573
|
+
# the next mount will reflect the new title.
|
|
574
|
+
pass
|
|
575
|
+
save_local_workspace(self.cwd, self._workspace)
|
|
576
|
+
return {"renamed": tab_id, "title": title}
|
|
577
|
+
|
|
578
|
+
async def reorder_tabs(self, tab_ids: list[str]) -> dict:
|
|
579
|
+
"""Rearrange tabs to match `tab_ids` order. Must be a permutation of
|
|
580
|
+
the current tab ids — extras, missing ids, or duplicates are rejected.
|
|
581
|
+
Preserves widget state by moving existing Tab/TabPane children in the
|
|
582
|
+
live widget tree rather than rebuilding."""
|
|
583
|
+
if self._workspace is None:
|
|
584
|
+
return {"error": "workspace_not_initialized"}
|
|
585
|
+
ws = self._workspace
|
|
586
|
+
current_ids = [t.id for t in ws.tabs]
|
|
587
|
+
if not isinstance(tab_ids, list) or not all(isinstance(x, str) for x in tab_ids):
|
|
588
|
+
return {"error": "tab_ids_must_be_list_of_strings"}
|
|
589
|
+
if sorted(tab_ids) != sorted(current_ids):
|
|
590
|
+
return {
|
|
591
|
+
"error": "tab_ids_not_a_permutation",
|
|
592
|
+
"current_ids": current_ids,
|
|
593
|
+
"given_ids": tab_ids,
|
|
594
|
+
}
|
|
595
|
+
# Already in order → no-op.
|
|
596
|
+
if tab_ids == current_ids:
|
|
597
|
+
return {"reordered": tab_ids, "noop": True}
|
|
598
|
+
# Reorder workspace.
|
|
599
|
+
by_id = {t.id: t for t in ws.tabs}
|
|
600
|
+
new_ws = Workspace.model_validate({
|
|
601
|
+
"version": ws.version,
|
|
602
|
+
"tabs": [by_id[i].model_dump(mode="json") for i in tab_ids],
|
|
603
|
+
"active": ws.active,
|
|
604
|
+
"active_theme": ws.active_theme,
|
|
605
|
+
})
|
|
606
|
+
self._workspace = new_ws
|
|
607
|
+
# Move existing TabPane and tab-strip Tab widgets into the new order.
|
|
608
|
+
# Walk forward and place each item right after its predecessor — this
|
|
609
|
+
# establishes the chain pane[0] -> pane[1] -> ... without ever asking
|
|
610
|
+
# move_child to insert a widget before/after itself. The Tab widgets in
|
|
611
|
+
# the strip live inside a `tabs-list` Horizontal nested under
|
|
612
|
+
# ContentTabs, not directly under it — so we move each Tab through its
|
|
613
|
+
# actual parent.
|
|
614
|
+
try:
|
|
615
|
+
from textual.widget import Widget as _Widget
|
|
616
|
+
tc = self.query_one("#app-tabs", TabbedContent)
|
|
617
|
+
from textual.widgets._content_switcher import ContentSwitcher
|
|
618
|
+
switcher = tc.get_child_by_type(ContentSwitcher)
|
|
619
|
+
panes = [tc.get_pane(f"tab-{tid}") for tid in tab_ids]
|
|
620
|
+
tabs = [tc.get_tab(f"tab-{tid}") for tid in tab_ids]
|
|
621
|
+
for i in range(len(tab_ids) - 1):
|
|
622
|
+
switcher.move_child(panes[i + 1], after=panes[i])
|
|
623
|
+
tab_parent = tabs[i].parent
|
|
624
|
+
if isinstance(tab_parent, _Widget) and tabs[i + 1].parent is tab_parent:
|
|
625
|
+
tab_parent.move_child(tabs[i + 1], after=tabs[i])
|
|
626
|
+
except Exception:
|
|
627
|
+
# UI move failure leaves the in-memory workspace updated; persistence
|
|
628
|
+
# below preserves the new order across restart.
|
|
629
|
+
pass
|
|
630
|
+
save_local_workspace(self.cwd, self._workspace)
|
|
631
|
+
return {"reordered": tab_ids}
|
|
632
|
+
|
|
633
|
+
async def action_dispatch(self, name: str) -> None:
|
|
634
|
+
# Look up the action and call it. If it returns a coroutine (async
|
|
635
|
+
# actions like action_quit / action_open_history / action_open_layout_switcher),
|
|
636
|
+
# await it so the side-effect actually runs.
|
|
637
|
+
import asyncio as _asyncio
|
|
638
|
+
try:
|
|
639
|
+
spec = self.actions_registry.get(name)
|
|
640
|
+
except KeyError:
|
|
641
|
+
return
|
|
642
|
+
result = spec.callable()
|
|
643
|
+
if _asyncio.iscoroutine(result):
|
|
644
|
+
await result
|
|
645
|
+
|
|
646
|
+
# --- action handlers ---------------------------------------------------
|
|
647
|
+
|
|
648
|
+
def action_focus_command_bar(self) -> None:
|
|
649
|
+
self.query_one(CommandBar).focus_input()
|
|
650
|
+
|
|
651
|
+
def action_show_help(self) -> None:
|
|
652
|
+
self.notify(
|
|
653
|
+
"/ command bar · ctrl-q quit · ctrl-h history · ctrl-l layouts · "
|
|
654
|
+
"ctrl-shift-l themes · ctrl-shift-r reset panel sizes · "
|
|
655
|
+
"ctrl-shift-d change cwd · "
|
|
656
|
+
"ctrl-pgup/pgdn prev/next tab · ctrl-1..9 tab N · ctrl-t new tab · "
|
|
657
|
+
"ctrl-w close tab · /reset new · /resume past · /rename title · "
|
|
658
|
+
"/cd path · /help cmds · ? help",
|
|
659
|
+
title="keybindings",
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def action_switch_tab_index(self, idx: int) -> None:
|
|
663
|
+
if self._workspace is None:
|
|
664
|
+
return
|
|
665
|
+
if idx < 0 or idx >= len(self._workspace.tabs):
|
|
666
|
+
return # quietly no-op
|
|
667
|
+
target = self._workspace.tabs[idx].id
|
|
668
|
+
tc = self.query_one("#app-tabs", TabbedContent)
|
|
669
|
+
tc.active = f"tab-{target}"
|
|
670
|
+
|
|
671
|
+
def action_open_history(self) -> None:
|
|
672
|
+
# push_screen with a callback avoids the worker requirement that
|
|
673
|
+
# push_screen_wait imposes. The callback fires when the modal dismisses.
|
|
674
|
+
idx = AgentsIndex(cwd=self.cwd)
|
|
675
|
+
|
|
676
|
+
def _on_picked(agent_id: str | None) -> None:
|
|
677
|
+
if agent_id:
|
|
678
|
+
self.push_screen(
|
|
679
|
+
TranscriptScreen(agent_id=agent_id, event_bus=self.event_bus)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
self.push_screen(HistoryScreen(index=idx), _on_picked)
|
|
683
|
+
|
|
684
|
+
def action_open_layout_switcher(self) -> None:
|
|
685
|
+
import asyncio as _asyncio
|
|
686
|
+
|
|
687
|
+
def _on_picked(name: str | None) -> None:
|
|
688
|
+
if not name:
|
|
689
|
+
return
|
|
690
|
+
spec = self.layouts_store.load(name)
|
|
691
|
+
if spec is None:
|
|
692
|
+
return
|
|
693
|
+
# Apply is async; schedule it on the running loop.
|
|
694
|
+
_asyncio.create_task(self._orchestrator_apply_layout(spec, layout_name=name))
|
|
695
|
+
|
|
696
|
+
self.push_screen(LayoutSwitcherScreen(store=self.layouts_store), _on_picked)
|
|
697
|
+
|
|
698
|
+
def action_open_theme_switcher(self) -> None:
|
|
699
|
+
import asyncio as _asyncio
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
builtins = sorted(
|
|
703
|
+
n for n in self.available_themes.keys()
|
|
704
|
+
if not n.startswith("patchbai:")
|
|
705
|
+
)
|
|
706
|
+
except Exception:
|
|
707
|
+
builtins = []
|
|
708
|
+
active = self.theme or ""
|
|
709
|
+
if active.startswith("patchbai:"):
|
|
710
|
+
active = active[len("patchbai:"):]
|
|
711
|
+
|
|
712
|
+
def _on_picked(name: str | None) -> None:
|
|
713
|
+
if not name:
|
|
714
|
+
return
|
|
715
|
+
_asyncio.create_task(self._apply_theme_by_name(name, persist=True))
|
|
716
|
+
|
|
717
|
+
self.push_screen(
|
|
718
|
+
ThemeSwitcherScreen(
|
|
719
|
+
store=self.themes_store,
|
|
720
|
+
available_builtins=builtins,
|
|
721
|
+
active=active,
|
|
722
|
+
),
|
|
723
|
+
_on_picked,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
def _dispatch_change_cwd(self, path: str) -> None:
|
|
727
|
+
"""Action wrapper around change_cwd — schedules the async call."""
|
|
728
|
+
import asyncio as _asyncio
|
|
729
|
+
_asyncio.create_task(self._change_cwd_with_notify(path))
|
|
730
|
+
|
|
731
|
+
async def _change_cwd_with_notify(self, path: str) -> None:
|
|
732
|
+
result = await self.change_cwd(path)
|
|
733
|
+
if "error" in result:
|
|
734
|
+
err = result["error"]
|
|
735
|
+
if err == "agents_running":
|
|
736
|
+
names = ", ".join(a["name"] for a in result.get("agents", []))
|
|
737
|
+
self.notify(
|
|
738
|
+
f"Refusing to change cwd: agents still running ({names}).",
|
|
739
|
+
severity="warning",
|
|
740
|
+
)
|
|
741
|
+
elif err == "invalid_path":
|
|
742
|
+
self.notify(
|
|
743
|
+
f"Invalid path: {result.get('path') or result.get('detail')}",
|
|
744
|
+
severity="warning",
|
|
745
|
+
)
|
|
746
|
+
else:
|
|
747
|
+
self.notify(f"change_cwd failed: {err}", severity="warning")
|
|
748
|
+
elif "unchanged" in result:
|
|
749
|
+
self.notify("cwd unchanged.")
|
|
750
|
+
else:
|
|
751
|
+
self.notify(f"cwd → {result['changed']}")
|
|
752
|
+
|
|
753
|
+
def action_open_change_cwd(self) -> None:
|
|
754
|
+
if self._workspace is None:
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
def _on_picked(path: str | None) -> None:
|
|
758
|
+
if not path:
|
|
759
|
+
return
|
|
760
|
+
self._dispatch_change_cwd(path)
|
|
761
|
+
|
|
762
|
+
from patchbai.widgets.change_cwd_screen import ChangeCwdScreen
|
|
763
|
+
self.push_screen(
|
|
764
|
+
ChangeCwdScreen(initial=str(self.cwd)),
|
|
765
|
+
_on_picked,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
async def _apply_theme_by_name(
|
|
769
|
+
self, name: str, *, persist: bool = False, scope: str = "global",
|
|
770
|
+
) -> None:
|
|
771
|
+
"""Single seam used by boot, the modal, and the load_theme tool path."""
|
|
772
|
+
spec = self.themes_store.load(name)
|
|
773
|
+
if spec is not None:
|
|
774
|
+
await apply_theme(self, spec, theme_name=name)
|
|
775
|
+
else:
|
|
776
|
+
try:
|
|
777
|
+
if name not in self.available_themes:
|
|
778
|
+
return
|
|
779
|
+
except Exception:
|
|
780
|
+
return
|
|
781
|
+
if _EXTRA_CSS_KEY in self.stylesheet.source:
|
|
782
|
+
del self.stylesheet.source[_EXTRA_CSS_KEY]
|
|
783
|
+
self._active_theme_extra_css = ""
|
|
784
|
+
self.theme = name
|
|
785
|
+
try:
|
|
786
|
+
self.refresh_css()
|
|
787
|
+
except Exception:
|
|
788
|
+
pass
|
|
789
|
+
if not persist:
|
|
790
|
+
return
|
|
791
|
+
if scope == "global":
|
|
792
|
+
cfg = self.config_store.load()
|
|
793
|
+
cfg.ui.active_theme = name
|
|
794
|
+
self.config_store.save(cfg)
|
|
795
|
+
elif scope == "project" and self._workspace is not None:
|
|
796
|
+
ws = self._workspace.model_copy(update={"active_theme": name})
|
|
797
|
+
self._workspace = ws
|
|
798
|
+
from patchbai.persistence.workspace_store import save_workspace
|
|
799
|
+
save_workspace(self.cwd, ws)
|
|
800
|
+
|
|
801
|
+
def action_new_tab(self) -> None:
|
|
802
|
+
import asyncio as _asyncio
|
|
803
|
+
|
|
804
|
+
def _on_picked(title: str | None) -> None:
|
|
805
|
+
if not title:
|
|
806
|
+
return
|
|
807
|
+
layout = self._default_seed_layout()
|
|
808
|
+
_asyncio.create_task(self.add_tab(title, layout, activate=True))
|
|
809
|
+
|
|
810
|
+
self.push_screen(NewTabScreen(), _on_picked)
|
|
811
|
+
|
|
812
|
+
async def action_close_active_tab(self) -> None:
|
|
813
|
+
if self._active_tab_id is None:
|
|
814
|
+
return
|
|
815
|
+
result = await self.close_tab(self._active_tab_id)
|
|
816
|
+
if "error" in result:
|
|
817
|
+
self.notify(f"can't close tab: {result['error']}", severity="warning")
|
|
818
|
+
|
|
819
|
+
def _on_open_resume_picker(self, event) -> None:
|
|
820
|
+
import asyncio as _asyncio
|
|
821
|
+
index = OrchestratorSessionsIndex(cwd=self.cwd)
|
|
822
|
+
|
|
823
|
+
def _on_picked(session_id: str | None) -> None:
|
|
824
|
+
if session_id:
|
|
825
|
+
_asyncio.create_task(self.orchestrator.resume(session_id))
|
|
826
|
+
|
|
827
|
+
self.push_screen(ResumeScreen(index=index), _on_picked)
|
|
828
|
+
|
|
829
|
+
# --- helpers -----------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
def _active_layout(self) -> LayoutSpec | None:
|
|
832
|
+
if self._workspace is None or self._active_tab_id is None:
|
|
833
|
+
return None
|
|
834
|
+
for t in self._workspace.tabs:
|
|
835
|
+
if t.id == self._active_tab_id:
|
|
836
|
+
return t.layout
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
def _load_or_seed_workspace(self) -> Workspace:
|
|
840
|
+
"""Load workspace.json, fall back to migrating layout.json, fall back
|
|
841
|
+
to seeding from the built-in dashboard."""
|
|
842
|
+
ws = load_local_workspace(self.cwd)
|
|
843
|
+
if ws is not None:
|
|
844
|
+
return self._migrate_workspace_percentages(ws)
|
|
845
|
+
# Migration: legacy layout.json -> single-tab workspace.
|
|
846
|
+
from patchbai.persistence.layout_store import load_layout as _load_legacy
|
|
847
|
+
legacy = _load_legacy(self.cwd)
|
|
848
|
+
if legacy is not None:
|
|
849
|
+
return workspace_from_layout(legacy, tab_id="default", title="default")
|
|
850
|
+
return workspace_from_layout(dashboard_layout(), tab_id="default", title="default")
|
|
851
|
+
|
|
852
|
+
def _migrate_workspace_percentages(self, ws: Workspace) -> Workspace:
|
|
853
|
+
"""One-shot repair: walk every tab's layout and renormalize Container
|
|
854
|
+
percentage children to sum to 100%. Repairs workspaces saved by older
|
|
855
|
+
Splitter code whose drift left visible blank space at the layout
|
|
856
|
+
edges. No-op when sums are already at 100%."""
|
|
857
|
+
raw = ws.model_dump(mode="json")
|
|
858
|
+
any_changed = False
|
|
859
|
+
for tab in raw["tabs"]:
|
|
860
|
+
if _normalize_layout_percentages(tab["layout"]["layout"]):
|
|
861
|
+
any_changed = True
|
|
862
|
+
if not any_changed:
|
|
863
|
+
return ws
|
|
864
|
+
try:
|
|
865
|
+
migrated = Workspace.model_validate(raw)
|
|
866
|
+
except Exception:
|
|
867
|
+
return ws
|
|
868
|
+
# Persist the repaired layout so the next launch starts clean.
|
|
869
|
+
try:
|
|
870
|
+
save_local_workspace(self.cwd, migrated)
|
|
871
|
+
except Exception:
|
|
872
|
+
pass
|
|
873
|
+
return migrated
|
|
874
|
+
|
|
875
|
+
async def _mount_workspace(self, ws: Workspace) -> None:
|
|
876
|
+
tc = self.query_one("#app-tabs", TabbedContent)
|
|
877
|
+
self._mounting_workspace = True
|
|
878
|
+
# Build one TabPane per Tab, each containing a panel-area-<id> Container.
|
|
879
|
+
new_panes = []
|
|
880
|
+
for t in ws.tabs:
|
|
881
|
+
new_panes.append(
|
|
882
|
+
TabPane(t.title, Container(id=f"panel-area-{t.id}"), id=f"tab-{t.id}")
|
|
883
|
+
)
|
|
884
|
+
await tc.clear_panes()
|
|
885
|
+
for pane in new_panes:
|
|
886
|
+
await tc.add_pane(pane)
|
|
887
|
+
# Apply each tab's layout to its container, eagerly (persistent semantics).
|
|
888
|
+
# The active tab is applied first (with focus) and pinned via tc.active;
|
|
889
|
+
# non-active tabs get their layout built without their `focus` directive
|
|
890
|
+
# honored, since focusing a widget in a hidden TabPane makes Textual
|
|
891
|
+
# switch to it (a Terminal tab with focus="term" or any auto-focusing
|
|
892
|
+
# child like an Input will otherwise steal activation on launch). Each
|
|
893
|
+
# non-active layout is followed by a re-pin: widgets like Input
|
|
894
|
+
# auto-focus on mount independent of `apply_focus` and can still drag
|
|
895
|
+
# the active tab over. Pinning per-iteration keeps ws.active sticky.
|
|
896
|
+
target_active = f"tab-{ws.active}"
|
|
897
|
+
active_tab = next((t for t in ws.tabs if t.id == ws.active), None)
|
|
898
|
+
if active_tab is not None:
|
|
899
|
+
area = self.query_one(f"#panel-area-{active_tab.id}", Container)
|
|
900
|
+
try:
|
|
901
|
+
await apply_layout(
|
|
902
|
+
area, active_tab.layout, self.registry, layout_name=None,
|
|
903
|
+
apply_focus=True,
|
|
904
|
+
)
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
tc.active = target_active
|
|
908
|
+
for t in ws.tabs:
|
|
909
|
+
if t.id == ws.active:
|
|
910
|
+
continue
|
|
911
|
+
area = self.query_one(f"#panel-area-{t.id}", Container)
|
|
912
|
+
try:
|
|
913
|
+
await apply_layout(
|
|
914
|
+
area, t.layout, self.registry, layout_name=None,
|
|
915
|
+
apply_focus=False,
|
|
916
|
+
)
|
|
917
|
+
except Exception:
|
|
918
|
+
pass
|
|
919
|
+
if tc.active != target_active:
|
|
920
|
+
tc.active = target_active
|
|
921
|
+
tc.active = target_active
|
|
922
|
+
|
|
923
|
+
# Final re-pin: child widgets (Input, etc.) that auto-focus on mount
|
|
924
|
+
# may queue TabActivated messages that fire after this function returns.
|
|
925
|
+
# Schedule a deferred pin once Textual has flushed those messages, then
|
|
926
|
+
# clear the suppression flag so the canonical activation handler runs
|
|
927
|
+
# for the user's first real tab switch.
|
|
928
|
+
def _finalize_active() -> None:
|
|
929
|
+
try:
|
|
930
|
+
if tc.active != target_active:
|
|
931
|
+
tc.active = target_active
|
|
932
|
+
finally:
|
|
933
|
+
self._mounting_workspace = False
|
|
934
|
+
self._active_tab_id = ws.active
|
|
935
|
+
|
|
936
|
+
self.call_after_refresh(_finalize_active)
|
|
937
|
+
|
|
938
|
+
async def _apply_to_tab(
|
|
939
|
+
self, tab_id: str | None, spec: LayoutSpec,
|
|
940
|
+
*, layout_name: str | None = None,
|
|
941
|
+
) -> None:
|
|
942
|
+
if tab_id is None or self._workspace is None:
|
|
943
|
+
return
|
|
944
|
+
# Build candidate workspace and validate FIRST (atomic). model_copy
|
|
945
|
+
# bypasses the model_validator, so we round-trip through model_validate
|
|
946
|
+
# to catch invariant breaks (e.g., user removed the only chat) before
|
|
947
|
+
# touching the live UI or persistence.
|
|
948
|
+
candidate = Workspace.model_validate({
|
|
949
|
+
"version": self._workspace.version,
|
|
950
|
+
"tabs": [
|
|
951
|
+
{**t.model_dump(mode="json"), "layout": spec.model_dump(mode="json")}
|
|
952
|
+
if t.id == tab_id else t.model_dump(mode="json")
|
|
953
|
+
for t in self._workspace.tabs
|
|
954
|
+
],
|
|
955
|
+
"active": self._workspace.active,
|
|
956
|
+
})
|
|
957
|
+
# Validation passed → commit memory, then UI, then disk.
|
|
958
|
+
self._workspace = candidate
|
|
959
|
+
area = self.query_one(f"#panel-area-{tab_id}", Container)
|
|
960
|
+
await apply_layout(area, spec, self.registry, layout_name=layout_name)
|
|
961
|
+
self._current_layout_name = layout_name
|
|
962
|
+
save_local_workspace(self.cwd, self._workspace)
|
|
963
|
+
|
|
964
|
+
async def change_cwd(self, new_cwd: "str | Path") -> dict:
|
|
965
|
+
"""Re-root the running workspace at `new_cwd`. Stops the
|
|
966
|
+
orchestrator and manager, swaps `self.cwd`, rebuilds both, loads
|
|
967
|
+
(or seeds) the new cwd's workspace, re-applies the active theme,
|
|
968
|
+
and publishes WorkspaceCwdChanged.
|
|
969
|
+
|
|
970
|
+
Returns a result dict; never raises on user input.
|
|
971
|
+
"""
|
|
972
|
+
from patchbai.events import WorkspaceCwdChanged
|
|
973
|
+
from patchbai.agents.sdk_adapter import RealSDKAdapter
|
|
974
|
+
|
|
975
|
+
async with self._cwd_swap_lock:
|
|
976
|
+
# Validate.
|
|
977
|
+
try:
|
|
978
|
+
resolved = Path(new_cwd).expanduser().resolve()
|
|
979
|
+
except Exception as e:
|
|
980
|
+
return {"error": "invalid_path", "detail": str(e)}
|
|
981
|
+
if not resolved.exists() or not resolved.is_dir():
|
|
982
|
+
return {"error": "invalid_path", "path": str(resolved)}
|
|
983
|
+
try:
|
|
984
|
+
current = Path(self.cwd).resolve()
|
|
985
|
+
except Exception:
|
|
986
|
+
current = self.cwd
|
|
987
|
+
if resolved == current:
|
|
988
|
+
return {"unchanged": True}
|
|
989
|
+
|
|
990
|
+
# Refuse with running children.
|
|
991
|
+
running = [
|
|
992
|
+
{"id": info.id, "name": info.name}
|
|
993
|
+
for info in self.manager.list_infos()
|
|
994
|
+
if not info.state.is_terminal
|
|
995
|
+
]
|
|
996
|
+
if running:
|
|
997
|
+
return {"error": "agents_running", "agents": running}
|
|
998
|
+
|
|
999
|
+
# Save the OLD workspace one last time.
|
|
1000
|
+
if self._workspace is not None:
|
|
1001
|
+
try:
|
|
1002
|
+
save_local_workspace(self.cwd, self._workspace)
|
|
1003
|
+
except Exception:
|
|
1004
|
+
pass
|
|
1005
|
+
|
|
1006
|
+
# Tear down current orchestrator + manager.
|
|
1007
|
+
try:
|
|
1008
|
+
await self.orchestrator.stop()
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass
|
|
1011
|
+
try:
|
|
1012
|
+
await self.manager.shutdown()
|
|
1013
|
+
except Exception:
|
|
1014
|
+
pass
|
|
1015
|
+
|
|
1016
|
+
# Swap cwd and reset workspace state.
|
|
1017
|
+
self.cwd = resolved
|
|
1018
|
+
self._workspace = None
|
|
1019
|
+
self._active_tab_id = None
|
|
1020
|
+
self._current_layout_name = None
|
|
1021
|
+
self._tab_focus_snapshots.clear()
|
|
1022
|
+
|
|
1023
|
+
# Rebuild manager + orchestrator.
|
|
1024
|
+
self.manager = AgentManager(
|
|
1025
|
+
cwd=self.cwd, bus=self.event_bus,
|
|
1026
|
+
adapter_factory=RealSDKAdapter,
|
|
1027
|
+
)
|
|
1028
|
+
self.orchestrator = OrchestratorSession(
|
|
1029
|
+
cwd=self.cwd, bus=self.event_bus, manager=self.manager,
|
|
1030
|
+
apply_layout=self._orchestrator_apply_layout,
|
|
1031
|
+
layouts_store=self.layouts_store,
|
|
1032
|
+
themes_store=self.themes_store,
|
|
1033
|
+
config_store=self.config_store,
|
|
1034
|
+
actions=self.actions_registry,
|
|
1035
|
+
rebind_keys=self._rebind_keys,
|
|
1036
|
+
widget_registry=self.registry,
|
|
1037
|
+
current_layout=lambda: self._active_layout(),
|
|
1038
|
+
app=self,
|
|
1039
|
+
)
|
|
1040
|
+
self.orchestrator._auto_title_enabled = True
|
|
1041
|
+
await self.orchestrator.start()
|
|
1042
|
+
|
|
1043
|
+
# Load (or seed) the new workspace.
|
|
1044
|
+
ws = self._load_or_seed_workspace()
|
|
1045
|
+
self._workspace = ws
|
|
1046
|
+
self._active_tab_id = ws.active
|
|
1047
|
+
await self._mount_workspace(ws)
|
|
1048
|
+
save_local_workspace(self.cwd, ws)
|
|
1049
|
+
|
|
1050
|
+
# Re-apply theme.
|
|
1051
|
+
active_name = (
|
|
1052
|
+
ws.active_theme
|
|
1053
|
+
or self.config_store.load().ui.active_theme
|
|
1054
|
+
or "default"
|
|
1055
|
+
)
|
|
1056
|
+
try:
|
|
1057
|
+
await self._apply_theme_by_name(active_name, persist=False)
|
|
1058
|
+
except Exception:
|
|
1059
|
+
try:
|
|
1060
|
+
await self._apply_theme_by_name("default", persist=False)
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
self.event_bus.publish(WorkspaceCwdChanged(cwd=str(self.cwd)))
|
|
1065
|
+
return {"changed": str(self.cwd)}
|
|
1066
|
+
|
|
1067
|
+
# --- stats aggregation -------------------------------------------------
|
|
1068
|
+
|
|
1069
|
+
def _on_stats_changed(self, _event) -> None:
|
|
1070
|
+
"""Re-aggregate token / cost / active-agent counters across the
|
|
1071
|
+
orchestrator and every child agent, and publish a StatsUpdated event
|
|
1072
|
+
so the StatusBar repaints. Fired by AgentTokensTouched (every
|
|
1073
|
+
ResultMessage), AgentSpawned, and AgentStateChanged."""
|
|
1074
|
+
tokens_in = 0
|
|
1075
|
+
tokens_out = 0
|
|
1076
|
+
cost = 0.0
|
|
1077
|
+
try:
|
|
1078
|
+
orch_info = self.orchestrator.info
|
|
1079
|
+
tokens_in += orch_info.tokens_in
|
|
1080
|
+
tokens_out += orch_info.tokens_out
|
|
1081
|
+
cost += orch_info.cost
|
|
1082
|
+
except Exception:
|
|
1083
|
+
pass
|
|
1084
|
+
active = 0
|
|
1085
|
+
try:
|
|
1086
|
+
for info in self.manager.list_infos():
|
|
1087
|
+
tokens_in += info.tokens_in
|
|
1088
|
+
tokens_out += info.tokens_out
|
|
1089
|
+
cost += info.cost
|
|
1090
|
+
if not info.state.is_terminal:
|
|
1091
|
+
active += 1
|
|
1092
|
+
except Exception:
|
|
1093
|
+
pass
|
|
1094
|
+
self.event_bus.publish(StatsUpdated(
|
|
1095
|
+
tokens_in=tokens_in,
|
|
1096
|
+
tokens_out=tokens_out,
|
|
1097
|
+
cost=cost,
|
|
1098
|
+
active_agents=active,
|
|
1099
|
+
))
|
|
1100
|
+
|
|
1101
|
+
# --- splitter persistence ---------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
def _on_layout_resized(self, event: LayoutResized) -> None:
|
|
1104
|
+
"""Persist the new sizes from a Splitter drag back to the workspace.
|
|
1105
|
+
Mutates only the size fields of the targeted children — does not
|
|
1106
|
+
re-apply the layout, so the live widget tree (with the inline cell
|
|
1107
|
+
sizes the splitter just set) stays as the user left it."""
|
|
1108
|
+
if self._workspace is None:
|
|
1109
|
+
return
|
|
1110
|
+
# Find the tab and walk its layout to the parent container.
|
|
1111
|
+
new_tabs: list[dict] = []
|
|
1112
|
+
mutated = False
|
|
1113
|
+
for t in self._workspace.tabs:
|
|
1114
|
+
tab_dump = t.model_dump(mode="json")
|
|
1115
|
+
if t.id == event.tab_id:
|
|
1116
|
+
root_layout = tab_dump["layout"]["layout"]
|
|
1117
|
+
if _apply_resize(root_layout, event.parent_path, event.children_cells):
|
|
1118
|
+
mutated = True
|
|
1119
|
+
new_tabs.append(tab_dump)
|
|
1120
|
+
if not mutated:
|
|
1121
|
+
return
|
|
1122
|
+
try:
|
|
1123
|
+
candidate = Workspace.model_validate({
|
|
1124
|
+
"version": self._workspace.version,
|
|
1125
|
+
"tabs": new_tabs,
|
|
1126
|
+
"active": self._workspace.active,
|
|
1127
|
+
"active_theme": self._workspace.active_theme,
|
|
1128
|
+
})
|
|
1129
|
+
except Exception:
|
|
1130
|
+
# Validation failure leaves both memory and disk untouched.
|
|
1131
|
+
return
|
|
1132
|
+
self._workspace = candidate
|
|
1133
|
+
save_local_workspace(self.cwd, candidate)
|
|
1134
|
+
|
|
1135
|
+
async def action_reset_panel_sizes(self) -> None:
|
|
1136
|
+
"""Discard any drag-set sizes by reloading the active tab's layout
|
|
1137
|
+
from its named source (or the built-in dashboard if unnamed)."""
|
|
1138
|
+
if self._active_tab_id is None:
|
|
1139
|
+
return
|
|
1140
|
+
spec: LayoutSpec | None = None
|
|
1141
|
+
if self._current_layout_name:
|
|
1142
|
+
spec = self.layouts_store.load(self._current_layout_name)
|
|
1143
|
+
if spec is None:
|
|
1144
|
+
spec = dashboard_layout()
|
|
1145
|
+
await self._apply_to_tab(
|
|
1146
|
+
self._active_tab_id, spec, layout_name=self._current_layout_name,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
# --- tab activation handler --------------------------------------------
|
|
1150
|
+
|
|
1151
|
+
def on_tabbed_content_tab_activated(
|
|
1152
|
+
self, event: TabbedContent.TabActivated,
|
|
1153
|
+
) -> None:
|
|
1154
|
+
"""Triggered by TabbedContent when the user (or code) switches the active
|
|
1155
|
+
pane. Updates workspace state, persists, fires our TabSwitched event, and
|
|
1156
|
+
restores focus to the tab's last-focused panel id."""
|
|
1157
|
+
if self._workspace is None:
|
|
1158
|
+
return
|
|
1159
|
+
# While the workspace is being mounted, child widgets that auto-focus
|
|
1160
|
+
# on mount (Input, FocusableTextArea, etc.) can fire TabActivated for
|
|
1161
|
+
# panes that aren't the saved active tab. Ignore those — _mount_workspace
|
|
1162
|
+
# explicitly pins ws.active once mount settles via call_after_refresh.
|
|
1163
|
+
if self._mounting_workspace:
|
|
1164
|
+
return
|
|
1165
|
+
# event.tab.id carries the internal ContentTab prefix ("--content-tab-tab-logs");
|
|
1166
|
+
# event.pane.id is the TabPane id we set ("tab-logs"), which is what we want.
|
|
1167
|
+
pane_id = event.pane.id if event.pane is not None else None
|
|
1168
|
+
if not pane_id or not pane_id.startswith("tab-"):
|
|
1169
|
+
return
|
|
1170
|
+
new_active = pane_id[len("tab-"):]
|
|
1171
|
+
if new_active == self._active_tab_id:
|
|
1172
|
+
return
|
|
1173
|
+
if self._active_tab_id is not None:
|
|
1174
|
+
try:
|
|
1175
|
+
focused = self.focused
|
|
1176
|
+
if focused is not None and focused.id and focused.id.startswith("panel-"):
|
|
1177
|
+
self._tab_focus_snapshots[self._active_tab_id] = focused.id[len("panel-"):]
|
|
1178
|
+
except Exception:
|
|
1179
|
+
pass
|
|
1180
|
+
self._active_tab_id = new_active
|
|
1181
|
+
# model_copy bypasses model_validator. Safe here because TabActivated
|
|
1182
|
+
# only fires for panes that already exist in self._workspace.tabs, so
|
|
1183
|
+
# the "active id must be in tabs" invariant cannot be violated.
|
|
1184
|
+
ws = self._workspace.model_copy(update={"active": new_active})
|
|
1185
|
+
self._workspace = ws
|
|
1186
|
+
save_local_workspace(self.cwd, ws)
|
|
1187
|
+
title = next((t.title for t in ws.tabs if t.id == new_active), new_active)
|
|
1188
|
+
self.event_bus.publish(TabSwitched(tab_id=new_active, title=title))
|
|
1189
|
+
target_tab = next((t for t in ws.tabs if t.id == new_active), None)
|
|
1190
|
+
target_panel_id = (
|
|
1191
|
+
self._tab_focus_snapshots.get(new_active)
|
|
1192
|
+
or (target_tab.layout.focus if target_tab else None)
|
|
1193
|
+
)
|
|
1194
|
+
if target_panel_id:
|
|
1195
|
+
try:
|
|
1196
|
+
self.query_one(f"#panel-{target_panel_id}").focus()
|
|
1197
|
+
except Exception:
|
|
1198
|
+
pass
|
|
1199
|
+
|
|
1200
|
+
# --- composition & lifecycle -------------------------------------------
|
|
1201
|
+
|
|
1202
|
+
def compose(self) -> ComposeResult:
|
|
1203
|
+
yield CommandBar(event_bus=self.event_bus)
|
|
1204
|
+
yield TabbedContent(id="app-tabs")
|
|
1205
|
+
yield StatusBar(event_bus=self.event_bus)
|
|
1206
|
+
|
|
1207
|
+
async def on_mount(self) -> None:
|
|
1208
|
+
self._rebind_keys()
|
|
1209
|
+
# Seed the built-in dashboard as the named layout "default" if no
|
|
1210
|
+
# such file exists yet, so the user can always get back to the
|
|
1211
|
+
# canonical layout via ctrl-l or `load_layout("default")`. We only
|
|
1212
|
+
# seed once — if the user re-saves "default" with their own arrangement,
|
|
1213
|
+
# we leave it alone.
|
|
1214
|
+
if self.layouts_store.load("default") is None:
|
|
1215
|
+
self.layouts_store.save("default", dashboard_layout())
|
|
1216
|
+
await self.orchestrator.start()
|
|
1217
|
+
self.event_bus.subscribe(OpenResumePicker, self._on_open_resume_picker)
|
|
1218
|
+
self.event_bus.subscribe(LayoutResized, self._on_layout_resized)
|
|
1219
|
+
self.event_bus.subscribe(AgentTokensTouched, self._on_stats_changed)
|
|
1220
|
+
self.event_bus.subscribe(AgentStateChanged, self._on_stats_changed)
|
|
1221
|
+
self.event_bus.subscribe(AgentSpawned, self._on_stats_changed)
|
|
1222
|
+
ws = self._load_or_seed_workspace()
|
|
1223
|
+
self._workspace = ws
|
|
1224
|
+
self._active_tab_id = ws.active
|
|
1225
|
+
await self._mount_workspace(ws)
|
|
1226
|
+
save_local_workspace(self.cwd, ws)
|
|
1227
|
+
|
|
1228
|
+
# Theme seed: snapshot the current Textual theme as "default" if not present.
|
|
1229
|
+
if self.themes_store.load("default") is None:
|
|
1230
|
+
try:
|
|
1231
|
+
pal = palette_from_textual_theme(self.current_theme)
|
|
1232
|
+
self.themes_store.save(
|
|
1233
|
+
"default", ThemeSpec(palette=pal, extra_css=""),
|
|
1234
|
+
)
|
|
1235
|
+
except Exception:
|
|
1236
|
+
# Snapshot may fail if Textual's theme objects shape ever
|
|
1237
|
+
# changes — boot must not abort.
|
|
1238
|
+
pass
|
|
1239
|
+
|
|
1240
|
+
# Resolve active theme: workspace.active_theme → config.ui.active_theme → "default".
|
|
1241
|
+
active_name = (
|
|
1242
|
+
ws.active_theme
|
|
1243
|
+
or self.config_store.load().ui.active_theme
|
|
1244
|
+
or "default"
|
|
1245
|
+
)
|
|
1246
|
+
try:
|
|
1247
|
+
await self._apply_theme_by_name(active_name, persist=False)
|
|
1248
|
+
except Exception:
|
|
1249
|
+
# Bad active theme must not brick boot. Fall back to default.
|
|
1250
|
+
try:
|
|
1251
|
+
await self._apply_theme_by_name("default", persist=False)
|
|
1252
|
+
except Exception:
|
|
1253
|
+
pass # last-resort: leave Textual default in place.
|
|
1254
|
+
|
|
1255
|
+
async def _orchestrator_apply_layout(
|
|
1256
|
+
self, spec: LayoutSpec,
|
|
1257
|
+
*, layout_name: str | None = None, tab_id: str | None = None,
|
|
1258
|
+
) -> None:
|
|
1259
|
+
target = tab_id or self._active_tab_id
|
|
1260
|
+
await self._apply_to_tab(target, spec, layout_name=layout_name)
|
|
1261
|
+
|
|
1262
|
+
async def on_unmount(self) -> None:
|
|
1263
|
+
await self.orchestrator.stop()
|
|
1264
|
+
await self.manager.shutdown()
|
|
1265
|
+
|
|
1266
|
+
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
1267
|
+
if isinstance(self.screen, (HistoryScreen, LayoutSwitcherScreen, ResumeScreen)):
|
|
1268
|
+
return
|
|
1269
|
+
# Only treat the row as an agent_id if the originating DataTable lives
|
|
1270
|
+
# inside the built-in AgentTable. Without this guard, ANY user-authored
|
|
1271
|
+
# widget that owns a DataTable (e.g. MulticaIssues, whose row keys are
|
|
1272
|
+
# issue identifiers like "BUO-597") would have its RowSelected message
|
|
1273
|
+
# bubble here and push a TranscriptScreen modal with a bogus agent_id.
|
|
1274
|
+
# That modal then sits on top of the screen stack and intercepts every
|
|
1275
|
+
# subsequent tab-strip click, which is the visible "I can't click back
|
|
1276
|
+
# into the Agents tab" symptom users hit after visiting Multica.
|
|
1277
|
+
table = event.data_table
|
|
1278
|
+
node = table.parent
|
|
1279
|
+
is_agent_row = False
|
|
1280
|
+
while node is not None:
|
|
1281
|
+
if isinstance(node, AgentTable):
|
|
1282
|
+
is_agent_row = True
|
|
1283
|
+
break
|
|
1284
|
+
node = node.parent
|
|
1285
|
+
if not is_agent_row:
|
|
1286
|
+
return
|
|
1287
|
+
agent_id = str(event.row_key.value)
|
|
1288
|
+
await self.push_screen(TranscriptScreen(agent_id=agent_id, event_bus=self.event_bus))
|