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
@@ -0,0 +1,235 @@
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 patchbai.agents.sort import sort_agents
7
+ from patchbai.agents.state import AgentInfo
8
+ from patchbai.events import (
9
+ AgentArchiveChanged,
10
+ AgentFocusRequested,
11
+ AgentMessageAppended,
12
+ AgentSpawned,
13
+ AgentStateChanged,
14
+ EventBus,
15
+ )
16
+ from patchbai.persistence.agents_index import AgentsIndex
17
+
18
+ from patchbai.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.DONE: "bold",
25
+ _AgentState.ERROR: "red",
26
+ }
27
+
28
+
29
+ class AgentTable(Container):
30
+ """Sortable table of agents — name, status, elapsed, last action, cost.
31
+
32
+ Archived agents are hidden by default; press `a` to toggle visibility,
33
+ `d` to archive (or un-archive, when shown) the cursor's agent.
34
+ """
35
+
36
+ DEFAULT_BORDER_TITLE = "Agents"
37
+
38
+ DEFAULT_CSS = """
39
+ AgentTable {
40
+ border: round $surface-lighten-2;
41
+ padding: 0 1;
42
+ }
43
+ AgentTable DataTable {
44
+ height: 1fr;
45
+ }
46
+ """
47
+
48
+ BINDINGS = [
49
+ Binding("d", "toggle_archive", "archive/unarchive"),
50
+ Binding("a", "toggle_show_archived", "show/hide archived"),
51
+ ]
52
+
53
+ COLUMNS = ("name", "status", "elapsed", "last action", "cost")
54
+
55
+ def __init__(self, *, event_bus: EventBus | None = None) -> None:
56
+ super().__init__()
57
+ self._bus = event_bus
58
+ # agent_id → row_key for DataTable updates. Only contains agents
59
+ # whose row is currently rendered (archived rows are absent when
60
+ # `_show_archived` is False).
61
+ self._rows: dict[str, str] = {}
62
+ # agent_id → last AgentInfo snapshot. This is the canonical source
63
+ # for filtering and re-rendering; it includes archived agents that
64
+ # may not currently have a row in the DataTable.
65
+ self._infos: dict[str, AgentInfo] = {}
66
+ # agent_id → most recent message text (last action).
67
+ self._last_actions: dict[str, str] = {}
68
+ # When False, archived agents are filtered out of the table.
69
+ self._show_archived: bool = False
70
+ self._unsubs: list = []
71
+
72
+ def compose(self) -> ComposeResult:
73
+ table = DataTable(zebra_stripes=True, cursor_type="row")
74
+ for col in self.COLUMNS:
75
+ table.add_column(col, key=col)
76
+ yield table
77
+
78
+ def on_mount(self) -> None:
79
+ # Seed past agents from disk so a fresh process boot still surfaces
80
+ # the agents the user spawned in the previous session. AgentManager
81
+ # has already reconciled any non-terminal records to ERROR, so what
82
+ # we read here is safe to display as-is.
83
+ cwd = getattr(self.app, "cwd", None)
84
+ if cwd is not None:
85
+ for info in AgentsIndex(cwd=cwd).load():
86
+ if info.id == "orchestrator":
87
+ continue
88
+ # Just record into _infos; the rebuild below renders rows in order.
89
+ self._infos[info.id] = info
90
+ self._rebuild_sorted()
91
+
92
+ bus = self._bus or getattr(self.app, "event_bus", None)
93
+ if bus is None:
94
+ return
95
+ self._unsubs.append(bus.subscribe(AgentSpawned, self._on_spawned))
96
+ self._unsubs.append(bus.subscribe(AgentStateChanged, self._on_state))
97
+ self._unsubs.append(bus.subscribe(AgentMessageAppended, self._on_msg))
98
+ self._unsubs.append(
99
+ bus.subscribe(AgentArchiveChanged, self._on_archive_changed)
100
+ )
101
+ self._unsubs.append(
102
+ bus.subscribe(AgentFocusRequested, self._on_focus_requested)
103
+ )
104
+
105
+ def on_unmount(self) -> None:
106
+ for u in self._unsubs:
107
+ u()
108
+ self._unsubs = []
109
+
110
+ # --- event handlers ---------------------------------------------------
111
+
112
+ def _on_spawned(self, event: AgentSpawned) -> None:
113
+ # Record the new agent and rebuild so it lands at the right priority.
114
+ self._infos[event.info.id] = event.info
115
+ self._rebuild_sorted()
116
+
117
+ def _on_state(self, event: AgentStateChanged) -> None:
118
+ # Preserve any archived flag we already know about — AgentStateChanged
119
+ # is emitted by AgentSession with the live info, but the SDK side
120
+ # doesn't touch `archived`, so an existing snapshot's flag is the
121
+ # source of truth.
122
+ prev = self._infos.get(event.info.id)
123
+ if prev is not None and prev.archived and not event.info.archived:
124
+ event.info.archived = True
125
+ self._infos[event.info.id] = event.info
126
+ self._rebuild_sorted()
127
+
128
+ def _on_msg(self, event: AgentMessageAppended) -> None:
129
+ self._last_actions[event.agent_id] = f"[{event.role}] {event.text[:60]}"
130
+ if event.agent_id in self._infos:
131
+ # last_activity feeds the sort tiebreaker, so rebuild to let the
132
+ # row bubble up within its bucket and refresh its last-action cell.
133
+ self._rebuild_sorted()
134
+
135
+ def _on_archive_changed(self, event: AgentArchiveChanged) -> None:
136
+ self._infos[event.info.id] = event.info
137
+ self._rebuild_sorted()
138
+
139
+ def _on_focus_requested(self, event: AgentFocusRequested) -> None:
140
+ """Select the row matching event.agent_id and scroll it into view."""
141
+ agent_id = event.agent_id
142
+ if agent_id not in self._rows:
143
+ return
144
+ try:
145
+ table = self.query_one(DataTable)
146
+ except Exception:
147
+ return
148
+ for index, row_key in enumerate(table.rows.keys()):
149
+ if str(row_key.value) == agent_id:
150
+ table.move_cursor(row=index)
151
+ table.scroll_to(0, index, animate=False)
152
+ return
153
+
154
+ # --- actions ----------------------------------------------------------
155
+
156
+ def action_toggle_archive(self) -> None:
157
+ manager = getattr(self.app, "manager", None)
158
+ if manager is None:
159
+ return
160
+ agent_id = self._cursor_agent_id()
161
+ if agent_id is None:
162
+ return
163
+ info = self._infos.get(agent_id)
164
+ if info is None:
165
+ return
166
+ manager.set_archived(agent_id, archived=not info.archived)
167
+
168
+ def action_toggle_show_archived(self) -> None:
169
+ self._show_archived = not self._show_archived
170
+ self._rebuild_sorted()
171
+
172
+ # --- internals --------------------------------------------------------
173
+
174
+ def _cursor_agent_id(self) -> str | None:
175
+ table = self.query_one(DataTable)
176
+ if table.row_count == 0:
177
+ return None
178
+ try:
179
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
180
+ except Exception:
181
+ return None
182
+ return None if row_key.value is None else str(row_key.value)
183
+
184
+ def _is_visible(self, info: AgentInfo) -> bool:
185
+ return self._show_archived or not info.archived
186
+
187
+ def _rebuild_sorted(self) -> None:
188
+ """Clear and re-add rows from `_infos` in default sort order, honoring
189
+ the visibility filter. Preserves the cursor's focused agent across the
190
+ rebuild so a sort-induced reorder doesn't snap the user back to row 0."""
191
+ table = self.query_one(DataTable)
192
+
193
+ # Capture cursor agent BEFORE clear(); coordinate_to_cell_key throws
194
+ # after the table is empty.
195
+ cursor_agent_id = self._cursor_agent_id()
196
+
197
+ table.clear()
198
+ self._rows.clear()
199
+
200
+ visible = [info for info in self._infos.values() if self._is_visible(info)]
201
+ for info in sort_agents(visible):
202
+ table.add_row(*self._render_cells(info), key=info.id)
203
+ self._rows[info.id] = info.id
204
+
205
+ # Restore cursor onto the same agent if it's still visible.
206
+ if cursor_agent_id is not None and cursor_agent_id in self._rows:
207
+ for index, row_key in enumerate(table.rows.keys()):
208
+ if str(row_key.value) == cursor_agent_id:
209
+ table.move_cursor(row=index)
210
+ break
211
+
212
+ def _render_cells(self, info: AgentInfo) -> tuple:
213
+ # Wrap each cell in Rich Text so values that may contain markup-like
214
+ # text (especially the "last action" cell which echoes tool args)
215
+ # render verbatim rather than tripping the markup parser.
216
+ from rich.text import Text
217
+ elapsed = info.elapsed_seconds()
218
+ elapsed_str = f"{elapsed:5.1f}s"
219
+ last = self._last_actions.get(info.id, "")
220
+ cost_str = f"${info.cost:.4f}"
221
+ if info.archived:
222
+ status = "archived"
223
+ status_style = ""
224
+ name = f"{info.name} (archived)"
225
+ else:
226
+ status = info.state.value
227
+ status_style = _STATUS_STYLES.get(info.state, "")
228
+ name = info.name
229
+ return (
230
+ Text(name),
231
+ Text(status, style=status_style),
232
+ Text(elapsed_str),
233
+ Text(last),
234
+ Text(cost_str),
235
+ )
@@ -0,0 +1,58 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Vertical
3
+ from textual.widgets import Input
4
+
5
+ from patchbai.events import DirectMessageToAgent, EventBus
6
+ from patchbai.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
+
36
+ def compose(self) -> ComposeResult:
37
+ yield RichTranscript(agent_id=self._agent_id, event_bus=self._bus)
38
+ yield Input(placeholder=f"Message {self._agent_id}…", id="transcript-input")
39
+
40
+ def on_input_submitted(self, event: Input.Submitted) -> None:
41
+ text = event.value.strip()
42
+ if not text:
43
+ return
44
+ bus = self._bus or getattr(self.app, "event_bus", None)
45
+ if bus is not None:
46
+ bus.publish(DirectMessageToAgent(agent_id=self._agent_id, text=text))
47
+ event.input.value = ""
48
+
49
+ def rendered_text(self) -> str:
50
+ """Test helper — delegates to the inner RichTranscript."""
51
+ return self.query_one(RichTranscript).rendered_text()
52
+
53
+ @classmethod
54
+ def default_border_title(cls, props: dict) -> str:
55
+ agent_id = props.get("agent_id")
56
+ if agent_id:
57
+ return f"Agent: {agent_id}"
58
+ 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 patchbai.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
+ # PatchbaiApp.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 patchbai.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]")