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