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