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
@@ -0,0 +1,236 @@
1
+ from textual.app import ComposeResult
2
+ from textual.binding import Binding
3
+ from textual.containers import Container
4
+ from textual.widgets import DataTable
5
+
6
+ from patchfeld.agents.sort import sort_agents
7
+ from patchfeld.agents.state import AgentInfo
8
+ from patchfeld.events import (
9
+ AgentArchiveChanged,
10
+ AgentFocusRequested,
11
+ AgentMessageAppended,
12
+ AgentSpawned,
13
+ AgentStateChanged,
14
+ EventBus,
15
+ )
16
+ from patchfeld.persistence.agents_index import AgentsIndex
17
+
18
+ from patchfeld.agents.state import AgentState as _AgentState
19
+
20
+ _STATUS_STYLES: dict[_AgentState, str] = {
21
+ _AgentState.IDLE: "dim",
22
+ _AgentState.RUNNING: "green",
23
+ _AgentState.WAITING: "yellow",
24
+ _AgentState.AWAITING_PERMISSION: "orange1",
25
+ _AgentState.DONE: "bold",
26
+ _AgentState.ERROR: "red",
27
+ }
28
+
29
+
30
+ class AgentTable(Container):
31
+ """Sortable table of agents — name, status, elapsed, last action, cost.
32
+
33
+ Archived agents are hidden by default; press `a` to toggle visibility,
34
+ `d` to archive (or un-archive, when shown) the cursor's agent.
35
+ """
36
+
37
+ DEFAULT_BORDER_TITLE = "Agents"
38
+
39
+ DEFAULT_CSS = """
40
+ AgentTable {
41
+ border: round $surface-lighten-2;
42
+ padding: 0 1;
43
+ }
44
+ AgentTable DataTable {
45
+ height: 1fr;
46
+ }
47
+ """
48
+
49
+ BINDINGS = [
50
+ Binding("d", "toggle_archive", "archive/unarchive"),
51
+ Binding("a", "toggle_show_archived", "show/hide archived"),
52
+ ]
53
+
54
+ COLUMNS = ("name", "status", "elapsed", "last action", "cost")
55
+
56
+ def __init__(self, *, event_bus: EventBus | None = None) -> None:
57
+ super().__init__()
58
+ self._bus = event_bus
59
+ # agent_id → row_key for DataTable updates. Only contains agents
60
+ # whose row is currently rendered (archived rows are absent when
61
+ # `_show_archived` is False).
62
+ self._rows: dict[str, str] = {}
63
+ # agent_id → last AgentInfo snapshot. This is the canonical source
64
+ # for filtering and re-rendering; it includes archived agents that
65
+ # may not currently have a row in the DataTable.
66
+ self._infos: dict[str, AgentInfo] = {}
67
+ # agent_id → most recent message text (last action).
68
+ self._last_actions: dict[str, str] = {}
69
+ # When False, archived agents are filtered out of the table.
70
+ self._show_archived: bool = False
71
+ self._unsubs: list = []
72
+
73
+ def compose(self) -> ComposeResult:
74
+ table = DataTable(zebra_stripes=True, cursor_type="row")
75
+ for col in self.COLUMNS:
76
+ table.add_column(col, key=col)
77
+ yield table
78
+
79
+ def on_mount(self) -> None:
80
+ # Seed past agents from disk so a fresh process boot still surfaces
81
+ # the agents the user spawned in the previous session. AgentManager
82
+ # has already reconciled any non-terminal records to ERROR, so what
83
+ # we read here is safe to display as-is.
84
+ cwd = getattr(self.app, "cwd", None)
85
+ if cwd is not None:
86
+ for info in AgentsIndex(cwd=cwd).load():
87
+ if info.id == "orchestrator":
88
+ continue
89
+ # Just record into _infos; the rebuild below renders rows in order.
90
+ self._infos[info.id] = info
91
+ self._rebuild_sorted()
92
+
93
+ bus = self._bus or getattr(self.app, "event_bus", None)
94
+ if bus is None:
95
+ return
96
+ self._unsubs.append(bus.subscribe(AgentSpawned, self._on_spawned))
97
+ self._unsubs.append(bus.subscribe(AgentStateChanged, self._on_state))
98
+ self._unsubs.append(bus.subscribe(AgentMessageAppended, self._on_msg))
99
+ self._unsubs.append(
100
+ bus.subscribe(AgentArchiveChanged, self._on_archive_changed)
101
+ )
102
+ self._unsubs.append(
103
+ bus.subscribe(AgentFocusRequested, self._on_focus_requested)
104
+ )
105
+
106
+ def on_unmount(self) -> None:
107
+ for u in self._unsubs:
108
+ u()
109
+ self._unsubs = []
110
+
111
+ # --- event handlers ---------------------------------------------------
112
+
113
+ def _on_spawned(self, event: AgentSpawned) -> None:
114
+ # Record the new agent and rebuild so it lands at the right priority.
115
+ self._infos[event.info.id] = event.info
116
+ self._rebuild_sorted()
117
+
118
+ def _on_state(self, event: AgentStateChanged) -> None:
119
+ # Preserve any archived flag we already know about — AgentStateChanged
120
+ # is emitted by AgentSession with the live info, but the SDK side
121
+ # doesn't touch `archived`, so an existing snapshot's flag is the
122
+ # source of truth.
123
+ prev = self._infos.get(event.info.id)
124
+ if prev is not None and prev.archived and not event.info.archived:
125
+ event.info.archived = True
126
+ self._infos[event.info.id] = event.info
127
+ self._rebuild_sorted()
128
+
129
+ def _on_msg(self, event: AgentMessageAppended) -> None:
130
+ self._last_actions[event.agent_id] = f"[{event.role}] {event.text[:60]}"
131
+ if event.agent_id in self._infos:
132
+ # last_activity feeds the sort tiebreaker, so rebuild to let the
133
+ # row bubble up within its bucket and refresh its last-action cell.
134
+ self._rebuild_sorted()
135
+
136
+ def _on_archive_changed(self, event: AgentArchiveChanged) -> None:
137
+ self._infos[event.info.id] = event.info
138
+ self._rebuild_sorted()
139
+
140
+ def _on_focus_requested(self, event: AgentFocusRequested) -> None:
141
+ """Select the row matching event.agent_id and scroll it into view."""
142
+ agent_id = event.agent_id
143
+ if agent_id not in self._rows:
144
+ return
145
+ try:
146
+ table = self.query_one(DataTable)
147
+ except Exception:
148
+ return
149
+ for index, row_key in enumerate(table.rows.keys()):
150
+ if str(row_key.value) == agent_id:
151
+ table.move_cursor(row=index)
152
+ table.scroll_to(0, index, animate=False)
153
+ return
154
+
155
+ # --- actions ----------------------------------------------------------
156
+
157
+ def action_toggle_archive(self) -> None:
158
+ manager = getattr(self.app, "manager", None)
159
+ if manager is None:
160
+ return
161
+ agent_id = self._cursor_agent_id()
162
+ if agent_id is None:
163
+ return
164
+ info = self._infos.get(agent_id)
165
+ if info is None:
166
+ return
167
+ manager.set_archived(agent_id, archived=not info.archived)
168
+
169
+ def action_toggle_show_archived(self) -> None:
170
+ self._show_archived = not self._show_archived
171
+ self._rebuild_sorted()
172
+
173
+ # --- internals --------------------------------------------------------
174
+
175
+ def _cursor_agent_id(self) -> str | None:
176
+ table = self.query_one(DataTable)
177
+ if table.row_count == 0:
178
+ return None
179
+ try:
180
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
181
+ except Exception:
182
+ return None
183
+ return None if row_key.value is None else str(row_key.value)
184
+
185
+ def _is_visible(self, info: AgentInfo) -> bool:
186
+ return self._show_archived or not info.archived
187
+
188
+ def _rebuild_sorted(self) -> None:
189
+ """Clear and re-add rows from `_infos` in default sort order, honoring
190
+ the visibility filter. Preserves the cursor's focused agent across the
191
+ rebuild so a sort-induced reorder doesn't snap the user back to row 0."""
192
+ table = self.query_one(DataTable)
193
+
194
+ # Capture cursor agent BEFORE clear(); coordinate_to_cell_key throws
195
+ # after the table is empty.
196
+ cursor_agent_id = self._cursor_agent_id()
197
+
198
+ table.clear()
199
+ self._rows.clear()
200
+
201
+ visible = [info for info in self._infos.values() if self._is_visible(info)]
202
+ for info in sort_agents(visible):
203
+ table.add_row(*self._render_cells(info), key=info.id)
204
+ self._rows[info.id] = info.id
205
+
206
+ # Restore cursor onto the same agent if it's still visible.
207
+ if cursor_agent_id is not None and cursor_agent_id in self._rows:
208
+ for index, row_key in enumerate(table.rows.keys()):
209
+ if str(row_key.value) == cursor_agent_id:
210
+ table.move_cursor(row=index)
211
+ break
212
+
213
+ def _render_cells(self, info: AgentInfo) -> tuple:
214
+ # Wrap each cell in Rich Text so values that may contain markup-like
215
+ # text (especially the "last action" cell which echoes tool args)
216
+ # render verbatim rather than tripping the markup parser.
217
+ from rich.text import Text
218
+ elapsed = info.elapsed_seconds()
219
+ elapsed_str = f"{elapsed:5.1f}s"
220
+ last = self._last_actions.get(info.id, "")
221
+ cost_str = f"${info.cost:.4f}"
222
+ if info.archived:
223
+ status = "archived"
224
+ status_style = ""
225
+ name = f"{info.name} (archived)"
226
+ else:
227
+ status = info.state.value
228
+ status_style = _STATUS_STYLES.get(info.state, "")
229
+ name = info.name
230
+ return (
231
+ Text(name),
232
+ Text(status, style=status_style),
233
+ Text(elapsed_str),
234
+ Text(last),
235
+ Text(cost_str),
236
+ )
@@ -0,0 +1,85 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Vertical
3
+ from textual.widgets import Input
4
+
5
+ from patchfeld.events import DirectMessageToAgent, EventBus, PermissionRequested, PermissionResolved
6
+ from patchfeld.widgets.rich_transcript import RichTranscript
7
+
8
+
9
+ class AgentTranscript(Vertical):
10
+ """Per-child-agent transcript panel: RichTranscript + input box."""
11
+
12
+ DEFAULT_CSS = """
13
+ AgentTranscript {
14
+ border: round $surface-lighten-2;
15
+ padding: 0 1;
16
+ }
17
+ AgentTranscript > RichTranscript {
18
+ height: 1fr;
19
+ }
20
+ AgentTranscript #transcript-input {
21
+ dock: bottom;
22
+ height: 3;
23
+ }
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ agent_id: str,
30
+ event_bus: EventBus | None = None,
31
+ ) -> None:
32
+ super().__init__()
33
+ self._agent_id = agent_id
34
+ self._bus = event_bus
35
+ self._unsub_perm_req = lambda: None
36
+ self._unsub_perm_res = lambda: None
37
+
38
+ def compose(self) -> ComposeResult:
39
+ yield RichTranscript(agent_id=self._agent_id, event_bus=self._bus)
40
+ yield Input(placeholder=f"Message {self._agent_id}…", id="transcript-input")
41
+
42
+ def on_mount(self) -> None:
43
+ bus = self._bus or getattr(self.app, "event_bus", None)
44
+ if bus is not None:
45
+ self._unsub_perm_req = bus.subscribe(PermissionRequested, self._on_perm_request)
46
+ self._unsub_perm_res = bus.subscribe(PermissionResolved, self._on_perm_resolved)
47
+
48
+ def on_unmount(self) -> None:
49
+ self._unsub_perm_req()
50
+ self._unsub_perm_res()
51
+
52
+ def _on_perm_request(self, event: PermissionRequested) -> None:
53
+ if event.agent_id != self._agent_id:
54
+ return
55
+ from patchfeld.widgets.permission_request_bar import PermissionRequestBar
56
+ bar = PermissionRequestBar(request=event)
57
+ self.mount(bar, before=self.query_one(RichTranscript))
58
+
59
+ def _on_perm_resolved(self, event: PermissionResolved) -> None:
60
+ if event.agent_id != self._agent_id:
61
+ return
62
+ from patchfeld.widgets.permission_request_bar import PermissionRequestBar
63
+ for bar in list(self.query(PermissionRequestBar)):
64
+ if bar.request_id == event.request_id:
65
+ bar.remove()
66
+
67
+ def on_input_submitted(self, event: Input.Submitted) -> None:
68
+ text = event.value.strip()
69
+ if not text:
70
+ return
71
+ bus = self._bus or getattr(self.app, "event_bus", None)
72
+ if bus is not None:
73
+ bus.publish(DirectMessageToAgent(agent_id=self._agent_id, text=text))
74
+ event.input.value = ""
75
+
76
+ def rendered_text(self) -> str:
77
+ """Test helper — delegates to the inner RichTranscript."""
78
+ return self.query_one(RichTranscript).rendered_text()
79
+
80
+ @classmethod
81
+ def default_border_title(cls, props: dict) -> str:
82
+ agent_id = props.get("agent_id")
83
+ if agent_id:
84
+ return f"Agent: {agent_id}"
85
+ return "Agent"
@@ -0,0 +1,39 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Vertical
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Input, Static
5
+
6
+
7
+ class ChangeCwdScreen(ModalScreen[str | None]):
8
+ """Tiny modal that asks for a new workspace cwd. Dismisses with the
9
+ trimmed string on submit, or None on escape."""
10
+
11
+ DEFAULT_CSS = """
12
+ ChangeCwdScreen { align: center middle; }
13
+ ChangeCwdScreen > Vertical {
14
+ width: 70; height: auto; padding: 1 2;
15
+ background: $surface; border: round $primary;
16
+ }
17
+ """
18
+
19
+ BINDINGS = [("escape", "cancel", "cancel")]
20
+
21
+ def __init__(self, *, initial: str = "") -> None:
22
+ super().__init__()
23
+ self._initial = initial
24
+
25
+ def compose(self) -> ComposeResult:
26
+ with Vertical():
27
+ yield Static("Change workspace cwd:")
28
+ yield Input(
29
+ value=self._initial,
30
+ placeholder="e.g., ~/Developer/other-project",
31
+ id="change-cwd-input",
32
+ )
33
+
34
+ def on_input_submitted(self, event: Input.Submitted) -> None:
35
+ path = (event.value or "").strip()
36
+ self.dismiss(path or None)
37
+
38
+ def action_cancel(self) -> None:
39
+ self.dismiss(None)
@@ -0,0 +1,210 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Horizontal
6
+ from textual.widgets import Input, Static
7
+
8
+ from patchfeld.events import EventBus, OrchestratorReply, UserMessageToOrchestrator
9
+
10
+
11
+ def _format_cwd(path: Path, *, available_width: int) -> str:
12
+ """Render `path` for the StatusBar, abbreviating under $HOME and
13
+ left-truncating with '…/' to fit `available_width` characters.
14
+
15
+ Pure: no I/O, no widget access. Drives `_on_cwd_changed` and
16
+ `on_resize` in StatusBar.
17
+
18
+ Uses ``os.path.abspath`` rather than ``Path.resolve`` so the
19
+ formatter does not follow symlinks — the user sees the path they
20
+ typed (e.g. ``/var/log/foo``) rather than the kernel-canonical form
21
+ (``/private/var/log/foo`` on macOS). Path canonicalisation belongs
22
+ in the validation step inside ``App.change_cwd``, not in the footer.
23
+ """
24
+ try:
25
+ home = Path.home()
26
+ abs_p = Path(os.path.abspath(path))
27
+ try:
28
+ rel = abs_p.relative_to(home)
29
+ display = "~" + ("/" + str(rel) if str(rel) != "." else "")
30
+ except ValueError:
31
+ display = str(abs_p)
32
+ except Exception:
33
+ display = str(path)
34
+
35
+ if available_width <= 0 or len(display) <= available_width:
36
+ return display
37
+ # Try left-truncation that ends at a segment boundary.
38
+ parts = display.split("/")
39
+ # Keep peeling leading segments until "…/" + tail fits.
40
+ for keep in range(len(parts) - 1, 0, -1):
41
+ candidate = "…/" + "/".join(parts[-keep:])
42
+ if len(candidate) <= available_width:
43
+ return candidate
44
+ # Budget too tight even for "…/leaf" — return bare basename.
45
+ return parts[-1]
46
+
47
+
48
+ class CommandBar(Horizontal):
49
+ """Top bar — `/` focuses; submitting sends to the orchestrator."""
50
+
51
+ DEFAULT_CSS = """
52
+ CommandBar {
53
+ height: 3;
54
+ }
55
+ """
56
+
57
+ def __init__(self, *, event_bus: EventBus | None = None) -> None:
58
+ super().__init__()
59
+ self._bus = event_bus
60
+ # True between a command-bar submit and the next OrchestratorReply.
61
+ # Gates the toast so replies from other input surfaces (the
62
+ # orchestrator chat panel) don't pop a toast as well.
63
+ self._awaiting_reply = False
64
+ self._unsub_reply = lambda: None
65
+
66
+ def compose(self) -> ComposeResult:
67
+ # Plain Textual Input with no styling overrides — default 3-row
68
+ # height, default colors, default focus behavior. Earlier attempts
69
+ # to compress this to 1-row (custom CSS, -textual-compact) clashed
70
+ # with Textual's color/cursor internals and produced invisible text.
71
+ yield Input(placeholder="message orchestrator", id="cmd-input")
72
+
73
+ def on_mount(self) -> None:
74
+ bus = self._bus or getattr(self.app, "event_bus", None)
75
+ if bus is not None:
76
+ self._unsub_reply = bus.subscribe(OrchestratorReply, self._on_reply)
77
+
78
+ def on_unmount(self) -> None:
79
+ self._unsub_reply()
80
+
81
+ def focus_input(self) -> None:
82
+ self.query_one("#cmd-input", Input).focus()
83
+
84
+ def on_input_submitted(self, event: Input.Submitted) -> None:
85
+ if not event.value.strip():
86
+ return
87
+ bus = self._bus or getattr(self.app, "event_bus", None)
88
+ if bus is not None:
89
+ self._awaiting_reply = True
90
+ bus.publish(UserMessageToOrchestrator(event.value))
91
+ event.input.value = ""
92
+
93
+ def _on_reply(self, event: OrchestratorReply) -> None:
94
+ if not self._awaiting_reply:
95
+ return
96
+ self._awaiting_reply = False
97
+ text = (event.text or "").strip()
98
+ if not text:
99
+ return
100
+ try:
101
+ self.app.notify(text, title="orchestrator")
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ class StatusBar(Horizontal):
107
+ """Bottom bar: tokens / cost / active agents / current layout name / [E]."""
108
+
109
+ DEFAULT_CSS = """
110
+ StatusBar {
111
+ height: 1;
112
+ background: $surface-darken-1;
113
+ }
114
+ StatusBar Static {
115
+ width: auto;
116
+ padding: 0 1;
117
+ }
118
+ /* The hints Static expands to absorb all leftover horizontal space and
119
+ right-aligns its text, parking the shortcut hint flush with the right
120
+ edge of the bar regardless of how wide the terminal is. The id selector
121
+ outranks the type selector above (CSS specificity), so it overrides
122
+ `width: auto` for this one widget. */
123
+ StatusBar #sb-hints {
124
+ width: 1fr;
125
+ text-align: right;
126
+ color: $text-muted;
127
+ }
128
+ """
129
+
130
+ def __init__(self, *, event_bus: EventBus | None = None,
131
+ layout_name: str = "default") -> None:
132
+ super().__init__()
133
+ self._bus = event_bus
134
+ self._layout_name = layout_name
135
+ self._unsub = lambda: None
136
+ self._unsub_layout = lambda: None
137
+ self._unsub_cwd = lambda: None
138
+ self._cwd_path: Path | None = None
139
+
140
+ def compose(self) -> ComposeResult:
141
+ yield Static("tokens 0/0", id="sb-tokens")
142
+ yield Static("$0.00", id="sb-cost")
143
+ yield Static("0 agents", id="sb-agents")
144
+ yield Static(f"layout: {self._layout_name}", id="sb-layout")
145
+ yield Static("", id="sb-cwd")
146
+ yield Static("", id="sb-error")
147
+ # Always-visible hint for the two most fundamental keybindings:
148
+ # `?` opens the help notification, `Ctrl+Q` quits. Verified against
149
+ # PatchfeldApp.BINDINGS. New users never have to guess how to escape.
150
+ yield Static("? help · ^Q quit", id="sb-hints")
151
+
152
+ def on_mount(self) -> None:
153
+ from patchfeld.events import LayoutApplied, StatsUpdated, WorkspaceCwdChanged
154
+ bus = self._bus or getattr(self.app, "event_bus", None)
155
+ # Initial cwd render — read app.cwd directly so we display correctly
156
+ # even if the WorkspaceCwdChanged event was published before this
157
+ # widget mounted.
158
+ try:
159
+ cwd = getattr(self.app, "cwd", None)
160
+ if cwd is not None:
161
+ self._render_cwd(Path(cwd))
162
+ except Exception:
163
+ pass
164
+ if bus is None:
165
+ return
166
+ self._unsub = bus.subscribe(StatsUpdated, self._on_stats)
167
+ self._unsub_layout = bus.subscribe(LayoutApplied, self._on_layout_applied)
168
+ self._unsub_cwd = bus.subscribe(WorkspaceCwdChanged, self._on_cwd_changed)
169
+
170
+ def on_unmount(self) -> None:
171
+ self._unsub()
172
+ self._unsub_layout()
173
+ self._unsub_cwd()
174
+
175
+ def _on_stats(self, event) -> None:
176
+ self.query_one("#sb-tokens", Static).update(
177
+ f"tokens {event.tokens_in}/{event.tokens_out}"
178
+ )
179
+ self.query_one("#sb-cost", Static).update(f"${event.cost:.2f}")
180
+ self.query_one("#sb-agents", Static).update(f"{event.active_agents} agents")
181
+
182
+ def _on_layout_applied(self, event) -> None:
183
+ name = event.layout_name or "default"
184
+ self.set_layout_name(name)
185
+
186
+ def _render_cwd(self, path: Path) -> None:
187
+ self._cwd_path = path
188
+ widget = self.query_one("#sb-cwd", Static)
189
+ # Allocate roughly half the bar width to cwd, capped at 40 chars.
190
+ try:
191
+ container_width = max(self.size.width, 0)
192
+ except Exception:
193
+ container_width = 0
194
+ budget = max(0, min(40, container_width // 2 if container_width else 40))
195
+ widget.update(f"cwd: {_format_cwd(path, available_width=budget)}")
196
+
197
+ def _on_cwd_changed(self, event) -> None:
198
+ self._render_cwd(Path(event.cwd))
199
+
200
+ def on_resize(self, _event) -> None:
201
+ # Re-render so the cwd budget tracks the actual container width.
202
+ if self._cwd_path is not None:
203
+ self._render_cwd(self._cwd_path)
204
+
205
+ def set_layout_name(self, name: str) -> None:
206
+ self._layout_name = name
207
+ self.query_one("#sb-layout", Static).update(f"layout: {name}")
208
+
209
+ def set_error(self, msg: str | None) -> None:
210
+ self.query_one("#sb-error", Static).update("[E]" if msg else "")
@@ -0,0 +1,52 @@
1
+ import difflib
2
+
3
+ from rich.syntax import Syntax
4
+ from textual.containers import VerticalScroll
5
+ from textual.widgets import Static
6
+
7
+
8
+ def _compute_diff(before: str, after: str) -> str:
9
+ return "".join(
10
+ difflib.unified_diff(
11
+ before.splitlines(keepends=True),
12
+ after.splitlines(keepends=True),
13
+ fromfile="before",
14
+ tofile="after",
15
+ )
16
+ )
17
+
18
+
19
+ class DiffViewer(VerticalScroll):
20
+ """Scrollable unified-diff viewer.
21
+
22
+ Accepts either a precomputed `diff: str` or a `before` + `after` pair from
23
+ which a unified diff is computed. The result is rendered as syntax-
24
+ highlighted `diff` content.
25
+ """
26
+
27
+ DEFAULT_BORDER_TITLE = "Diff"
28
+
29
+ DEFAULT_CSS = """
30
+ DiffViewer {
31
+ border: round $surface-lighten-2;
32
+ padding: 0 1;
33
+ }
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ diff: str | None = None,
40
+ before: str | None = None,
41
+ after: str | None = None,
42
+ ) -> None:
43
+ super().__init__()
44
+ if diff is None and (before is not None or after is not None):
45
+ diff = _compute_diff(before or "", after or "")
46
+ self.diff_text = diff or ""
47
+
48
+ def compose(self):
49
+ if self.diff_text:
50
+ yield Static(Syntax(self.diff_text, "diff", theme="ansi_dark"))
51
+ else:
52
+ yield Static("[dim]No diff to display[/dim]")