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,606 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from rich.markdown import Markdown as _RichMarkdown
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Vertical, VerticalScroll
10
+ from textual.widgets import Collapsible, Static
11
+
12
+
13
+ def _markup_escape(s: str) -> str:
14
+ # textual.markup.escape only escapes "[" when followed by [a-z#/@] —
15
+ # i.e. only when it already looks like a tag open. Tool output regularly
16
+ # has "[" followed by other characters (e.g. "[\n" from cat -n output,
17
+ # "[ " or "[{" inside JSON), which slips through and then crashes
18
+ # Content.from_markup with MarkupError. Escape every "[" instead.
19
+ # Backslashes pass through unchanged because Textual's parser only
20
+ # treats "\\[" specially, not bare "\\".
21
+ return s.replace("[", "\\[")
22
+
23
+ from patchfeld.agents.state import AgentState
24
+ from patchfeld.events import AgentMessageAppended, AgentStateChanged, EventBus
25
+ from patchfeld.persistence.transcript_store import (
26
+ AgentTranscript as TranscriptStore,
27
+ TranscriptEntry,
28
+ )
29
+
30
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
31
+ _SPINNER_INTERVAL_S = 0.08
32
+
33
+
34
+ class _ToolCall(Collapsible):
35
+ """One tool invocation. Expanded while running, collapsed on result."""
36
+
37
+ DEFAULT_CSS = """
38
+ _ToolCall {
39
+ margin: 0;
40
+ }
41
+ """
42
+
43
+ def __init__(self, *, tool_id: str | None, tool_name: str | None, args_text: str) -> None:
44
+ self.tool_id = tool_id
45
+ self.tool_name = tool_name or "?"
46
+ self._args_text = args_text
47
+ self._spinner_idx = 0
48
+ self._spinner_timer = None
49
+ self._done = False
50
+ self._args_static = Static(self._build_args_text())
51
+ self._result_static = Static(Text("(running…)", style="dim"))
52
+ super().__init__(
53
+ self._args_static,
54
+ self._result_static,
55
+ title=self._build_running_title(),
56
+ collapsed=False,
57
+ )
58
+
59
+ def on_mount(self) -> None:
60
+ if self._done:
61
+ return
62
+ self._spinner_timer = self.set_interval(_SPINNER_INTERVAL_S, self._tick_spinner)
63
+
64
+ def _tick_spinner(self) -> None:
65
+ self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
66
+ self.title = self._build_running_title()
67
+
68
+ def _build_args_text(self) -> Text:
69
+ line = Text()
70
+ line.append("args: ", style="bold")
71
+ line.append(self._args_text)
72
+ return line
73
+
74
+ def _build_running_title(self) -> str:
75
+ # Truncated, plain-string title — Collapsible accepts str.
76
+ # Escape user-provided text so brackets don't get parsed as markup.
77
+ short = self._args_text if len(self._args_text) <= 60 else self._args_text[:57] + "…"
78
+ return f"{_SPINNER_FRAMES[self._spinner_idx]} {_markup_escape(self.tool_name)}({_markup_escape(short)})"
79
+
80
+ def _build_done_title(self, result_text: str, *, error: bool) -> str:
81
+ marker = "✗" if error else "✓"
82
+ short = result_text.replace("\n", " ")
83
+ if len(short) > 80:
84
+ short = short[:77] + "…"
85
+ return f"{marker} {_markup_escape(self.tool_name)} → {_markup_escape(short)}"
86
+
87
+ def attach_result(self, content_text: str, *, error: bool = False) -> None:
88
+ self._done = True
89
+ if self._spinner_timer is not None:
90
+ self._spinner_timer.stop()
91
+ self._spinner_timer = None
92
+ body = Text()
93
+ body.append("result: ", style="bold")
94
+ body.append(content_text, style="red" if error else "")
95
+ self._result_static.update(body)
96
+ self.title = self._build_done_title(content_text, error=error)
97
+ self.collapsed = True
98
+
99
+ def mark_done(self) -> None:
100
+ self._done = True
101
+ if self._spinner_timer is not None:
102
+ self._spinner_timer.stop()
103
+ self._spinner_timer = None
104
+ # Called when the turn ends. If no result ever attached (shouldn't
105
+ # normally happen), still collapse the foldable.
106
+ # NOTE: use .content (not .renderable) for this Textual version.
107
+ if self._result_static.content and "(running…)" in str(self._result_static.content):
108
+ self._result_static.update(Text("(no result received)", style="dim red"))
109
+ self.title = f"? {_markup_escape(self.tool_name)} (no result)"
110
+ self.collapsed = True
111
+
112
+
113
+ class _ThinkingGroup(Collapsible):
114
+ """A contiguous run of thinking blocks. Expanded while running."""
115
+
116
+ DEFAULT_CSS = """
117
+ _ThinkingGroup {
118
+ margin: 0;
119
+ }
120
+ """
121
+
122
+ def __init__(self) -> None:
123
+ self._body_static = Static(Text(""))
124
+ self._started = time.monotonic()
125
+ self._done = False
126
+ self._spinner_idx = 0
127
+ self._spinner_timer = None
128
+ super().__init__(
129
+ self._body_static,
130
+ title=self._build_running_title(),
131
+ collapsed=False,
132
+ )
133
+
134
+ def _build_running_title(self) -> str:
135
+ return f"{_SPINNER_FRAMES[self._spinner_idx]} Thinking…"
136
+
137
+ def on_mount(self) -> None:
138
+ if self._done:
139
+ return
140
+ self._spinner_timer = self.set_interval(_SPINNER_INTERVAL_S, self._tick_spinner)
141
+
142
+ def _tick_spinner(self) -> None:
143
+ self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
144
+ self.title = self._build_running_title()
145
+
146
+ def append(self, text: str) -> None:
147
+ existing = self._body_static.content
148
+ body = existing if isinstance(existing, Text) else Text(str(existing))
149
+ if len(body) > 0:
150
+ body.append("\n")
151
+ body.append(text, style="dim")
152
+ self._body_static.update(body)
153
+
154
+ def mark_done(self) -> None:
155
+ if self._done:
156
+ return
157
+ self._done = True
158
+ if self._spinner_timer is not None:
159
+ self._spinner_timer.stop()
160
+ self._spinner_timer = None
161
+ elapsed = time.monotonic() - self._started
162
+ self.title = f"Thought for {elapsed:.1f}s"
163
+ self.collapsed = True
164
+
165
+
166
+ class _ProcessGroup(Collapsible):
167
+ """Outer fold around the thinking + tool widgets between the user
168
+ prompt and the next assistant response.
169
+
170
+ Lets the user hide the entire intermediate process behind one click,
171
+ so a finished turn reads as 'prompt → final response' with the work
172
+ one expand-step away.
173
+ """
174
+
175
+ DEFAULT_CSS = """
176
+ _ProcessGroup {
177
+ margin: 0;
178
+ }
179
+ """
180
+
181
+ def __init__(self) -> None:
182
+ # Inner Vertical we own, so add_step() has a stable mount target.
183
+ # Collapsible itself doesn't expose a public API for dynamic content
184
+ # mounting; passing _body as the only "contents" child gives us one.
185
+ self._body = Vertical()
186
+ self._pending_steps: list = []
187
+ self._tool_count = 0
188
+ self._started = time.monotonic()
189
+ self._done = False
190
+ self._spinner_idx = 0
191
+ self._spinner_timer = None
192
+ super().__init__(
193
+ self._body,
194
+ title=self._build_running_title(),
195
+ collapsed=False,
196
+ )
197
+
198
+ def _build_running_title(self) -> str:
199
+ return f"{_SPINNER_FRAMES[self._spinner_idx]} Working…"
200
+
201
+ def on_mount(self) -> None:
202
+ # _body is mounted via Collapsible.compose → Contents → _body. By
203
+ # the time on_mount fires for us, _body may or may not be attached
204
+ # yet; _flush_pending re-schedules itself if it isn't.
205
+ self._flush_pending()
206
+ if self._done:
207
+ return
208
+ self._spinner_timer = self.set_interval(_SPINNER_INTERVAL_S, self._tick)
209
+
210
+ def _tick(self) -> None:
211
+ self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
212
+ self.title = self._build_running_title()
213
+
214
+ def _flush_pending(self) -> None:
215
+ if not self._pending_steps:
216
+ return
217
+ if self._body.is_attached:
218
+ self._body.mount(*self._pending_steps)
219
+ self._pending_steps.clear()
220
+ else:
221
+ self.call_after_refresh(self._flush_pending)
222
+
223
+ def add_step(self, widget) -> None:
224
+ if isinstance(widget, _ToolCall):
225
+ self._tool_count += 1
226
+ if self._body.is_attached:
227
+ self._body.mount(widget)
228
+ return
229
+ self._pending_steps.append(widget)
230
+ if self.is_attached:
231
+ self.call_after_refresh(self._flush_pending)
232
+
233
+ def mark_done(self) -> None:
234
+ if self._done:
235
+ return
236
+ self._done = True
237
+ if self._spinner_timer is not None:
238
+ self._spinner_timer.stop()
239
+ self._spinner_timer = None
240
+ # Propagate done to pending children so they don't start spinners
241
+ # when they finally mount inside a collapsed group.
242
+ for child in self._pending_steps:
243
+ mark = getattr(child, "mark_done", None)
244
+ if mark is not None:
245
+ mark()
246
+ if self._pending_steps and self._body.is_attached:
247
+ self._body.mount(*self._pending_steps)
248
+ self._pending_steps.clear()
249
+ elapsed = time.monotonic() - self._started
250
+ if self._tool_count == 1:
251
+ self.title = f"Process · 1 tool · {elapsed:.1f}s"
252
+ elif self._tool_count > 1:
253
+ self.title = f"Process · {self._tool_count} tools · {elapsed:.1f}s"
254
+ else:
255
+ self.title = f"Process · {elapsed:.1f}s"
256
+ self.collapsed = True
257
+
258
+
259
+ class _AssistantBlock(Static):
260
+ """Final assistant text rendered as markdown via Rich.
261
+
262
+ Stores the original source on `_source` so rendered_text() (used by
263
+ tests and any plain-text consumers) returns the markdown source rather
264
+ than the renderable's repr.
265
+ """
266
+
267
+ DEFAULT_CSS = """
268
+ _AssistantBlock {
269
+ margin: 0;
270
+ padding: 0;
271
+ }
272
+ """
273
+
274
+ def __init__(self, source: str) -> None:
275
+ self._source = source
276
+ # code_theme="ansi_dark" keeps fenced-code blocks inside Textual's
277
+ # palette instead of injecting a hard-coded background color.
278
+ super().__init__(_RichMarkdown(source, code_theme="ansi_dark"))
279
+
280
+
281
+ class _TurnContainer(Vertical):
282
+ """One conversation turn: user prompt + steps + final response."""
283
+
284
+ DEFAULT_CSS = """
285
+ _TurnContainer {
286
+ height: auto;
287
+ margin-top: 1;
288
+ }
289
+ _TurnContainer.turn-running {
290
+ border-left: thick $accent;
291
+ padding-left: 1;
292
+ }
293
+ _TurnContainer.turn-done,
294
+ _TurnContainer.turn-error {
295
+ padding-left: 1;
296
+ }
297
+ _TurnContainer.turn-error {
298
+ border-left: thick $error;
299
+ }
300
+ """
301
+
302
+ def __init__(self, user_text: str) -> None:
303
+ super().__init__()
304
+ self.add_class("turn-running")
305
+ self._user_text = user_text
306
+ self._tool_widgets: dict = {}
307
+ self._current_thinking: _ThinkingGroup | None = None
308
+ self._current_process: _ProcessGroup | None = None
309
+
310
+ def compose(self) -> ComposeResult:
311
+ line = Text()
312
+ line.append("you: ", style="bold cyan")
313
+ line.append(self._user_text)
314
+ yield Static(line, classes="msg-user")
315
+
316
+ def _close_thinking_group(self) -> None:
317
+ self._current_thinking = None
318
+
319
+ def _close_process_group(self) -> None:
320
+ if self._current_process is not None:
321
+ self._current_process.mark_done()
322
+ self._current_process = None
323
+
324
+ def _ensure_process_group(self) -> _ProcessGroup:
325
+ if self._current_process is None:
326
+ group = _ProcessGroup()
327
+ self._current_process = group
328
+ self.mount(group)
329
+ return self._current_process
330
+
331
+ def add_thinking(self, text: str) -> None:
332
+ process = self._ensure_process_group()
333
+ if self._current_thinking is None:
334
+ group = _ThinkingGroup()
335
+ self._current_thinking = group
336
+ process.add_step(group)
337
+ self._current_thinking.append(text)
338
+
339
+ def add_tool_call(
340
+ self, *, tool_id: str | None, tool_name: str | None, args_text: str,
341
+ ) -> None:
342
+ self._close_thinking_group()
343
+ process = self._ensure_process_group()
344
+ widget = _ToolCall(
345
+ tool_id=tool_id, tool_name=tool_name, args_text=args_text,
346
+ )
347
+ self._tool_widgets[tool_id or id(widget)] = widget
348
+ process.add_step(widget)
349
+
350
+ def attach_tool_result(self, *, tool_id: str | None, content_text: str) -> None:
351
+ self._close_thinking_group()
352
+ widget = self._tool_widgets.get(tool_id) if tool_id else None
353
+ if widget is None:
354
+ # Old-transcript fallback or out-of-order: attach to the most
355
+ # recent _ToolCall in this turn whose result hasn't been set.
356
+ # We iterate _tool_widgets (insertion-ordered) instead of the
357
+ # DOM because tool widgets may still be queued inside a process
358
+ # group's _pending_steps and not yet attached.
359
+ # NOTE: use .content (not .renderable) for this Textual version.
360
+ for tw in reversed(list(self._tool_widgets.values())):
361
+ if "(running…)" in str(tw._result_static.content):
362
+ widget = tw
363
+ break
364
+ if widget is None:
365
+ # Truly orphaned — mount a free-floating result line, inside the
366
+ # active process group if one exists, else on the turn directly.
367
+ line = Text()
368
+ line.append("result (orphan): ", style="bold red")
369
+ line.append(content_text)
370
+ orphan = Static(line)
371
+ if self._current_process is not None:
372
+ self._current_process.add_step(orphan)
373
+ else:
374
+ self.mount(orphan)
375
+ return
376
+ # Naive error detection — refined in later tasks if needed.
377
+ is_err = content_text.lower().startswith("error")
378
+ widget.attach_result(content_text, error=is_err)
379
+
380
+ def add_text(self, text: str) -> None:
381
+ self._close_thinking_group()
382
+ # Final-response text closes the current round of process steps;
383
+ # any subsequent thinking/tools open a fresh _ProcessGroup.
384
+ self._close_process_group()
385
+ prefix = Text()
386
+ prefix.append("claude:", style="bold")
387
+ self.mount(Static(prefix, classes="msg-final-prefix"))
388
+ self.mount(_AssistantBlock(text))
389
+
390
+ def mark_done(self) -> None:
391
+ self.remove_class("turn-running")
392
+ self.add_class("turn-done")
393
+ # query() recurses into _ProcessGroup, so this still finds tools and
394
+ # thinking groups even when they live inside a process group.
395
+ for tool in self.query(_ToolCall):
396
+ tool.mark_done()
397
+ for group in self.query(_ThinkingGroup):
398
+ group.mark_done()
399
+ for proc in self.query(_ProcessGroup):
400
+ proc.mark_done()
401
+ self._current_process = None
402
+
403
+ def mark_error(self) -> None:
404
+ self.remove_class("turn-running")
405
+ self.add_class("turn-error")
406
+ for tool in self.query(_ToolCall):
407
+ tool.mark_done()
408
+ for group in self.query(_ThinkingGroup):
409
+ group.mark_done()
410
+ for proc in self.query(_ProcessGroup):
411
+ proc.mark_done()
412
+ self._current_process = None
413
+
414
+ def rendered_text(self) -> str:
415
+ parts: list[str] = []
416
+ for static in self.query(Static):
417
+ if isinstance(static, _AssistantBlock):
418
+ parts.append(static._source)
419
+ else:
420
+ parts.append(str(static.content))
421
+ return "\n".join(parts)
422
+
423
+
424
+ class RichTranscript(Vertical):
425
+ """Scrollable, live-updating transcript with per-turn grouping.
426
+
427
+ Subscribes to AgentMessageAppended (filtered by agent_id) and
428
+ AgentStateChanged (Task 9) to render turns containing thinking groups,
429
+ tool-call foldables, and final response text.
430
+ """
431
+
432
+ DEFAULT_CSS = """
433
+ RichTranscript {
434
+ border: round $surface-lighten-2;
435
+ height: 1fr;
436
+ }
437
+ RichTranscript > VerticalScroll {
438
+ height: 1fr;
439
+ }
440
+ """
441
+
442
+ def __init__(
443
+ self,
444
+ *,
445
+ agent_id: str,
446
+ event_bus: EventBus | None = None,
447
+ transcript_path: "Path | None" = None,
448
+ ) -> None:
449
+ super().__init__()
450
+ self._agent_id = agent_id
451
+ self._bus = event_bus
452
+ self._transcript_path = transcript_path
453
+ self._unsub_msg = lambda: None
454
+ self._unsub_state = lambda: None
455
+ self._unsub_switched = lambda: None
456
+ self._current_turn: _TurnContainer | None = None
457
+
458
+ @property
459
+ def agent_id(self) -> str:
460
+ """Public read-only accessor for the agent_id this transcript watches."""
461
+ return self._agent_id
462
+
463
+ def compose(self) -> ComposeResult:
464
+ yield VerticalScroll()
465
+
466
+ def on_mount(self) -> None:
467
+ from patchfeld.events import OrchestratorSessionSwitched
468
+ # Anchor the scroll to the bottom: Textual auto-pins the viewport to
469
+ # the bottom across content additions, releases the anchor when the
470
+ # user scrolls up, and restores it when they scroll back to the end.
471
+ try:
472
+ self.query_one(VerticalScroll).anchor()
473
+ except Exception:
474
+ pass
475
+ cwd: Path | None = getattr(self.app, "cwd", None)
476
+ if self._transcript_path is not None:
477
+ store = TranscriptStore(
478
+ cwd=cwd or Path("."), agent_id=self._agent_id,
479
+ path=self._transcript_path,
480
+ )
481
+ for entry in store.read_all():
482
+ self._dispatch_entry(entry)
483
+ elif cwd is not None:
484
+ store = TranscriptStore(cwd=cwd, agent_id=self._agent_id)
485
+ for entry in store.read_all():
486
+ self._dispatch_entry(entry)
487
+ if self._current_turn is not None:
488
+ self._current_turn.mark_done()
489
+ self._current_turn = None
490
+ bus = self._bus or getattr(self.app, "event_bus", None)
491
+ if bus is not None:
492
+ self._unsub_msg = bus.subscribe(AgentMessageAppended, self._on_appended)
493
+ self._unsub_state = bus.subscribe(AgentStateChanged, self._on_state_changed)
494
+ self._unsub_switched = bus.subscribe(
495
+ OrchestratorSessionSwitched, self._on_session_switched,
496
+ )
497
+
498
+ def on_unmount(self) -> None:
499
+ self._unsub_msg()
500
+ self._unsub_state()
501
+ self._unsub_switched()
502
+
503
+ def replace_source(self, transcript_path: Path) -> None:
504
+ """Clear the scroll and replay from a new transcript path.
505
+
506
+ Called when the orchestrator session is swapped via /reset or /resume.
507
+ Live event filtering still keys off `agent_id` (unchanged).
508
+ """
509
+ self._transcript_path = transcript_path
510
+ scroll = self.query_one(VerticalScroll)
511
+ for child in list(scroll.children):
512
+ child.remove()
513
+ self._current_turn = None
514
+ cwd = getattr(self.app, "cwd", None) or Path(".")
515
+ store = TranscriptStore(
516
+ cwd=cwd, agent_id=self._agent_id, path=transcript_path,
517
+ )
518
+ for entry in store.read_all():
519
+ self._dispatch_entry(entry)
520
+ if self._current_turn is not None:
521
+ self._current_turn.mark_done()
522
+ self._current_turn = None
523
+
524
+ def _on_session_switched(self, event) -> None:
525
+ # Filter by agent_id semantics: only the orchestrator transcript reacts.
526
+ if self._agent_id != "orchestrator":
527
+ return
528
+ self.replace_source(Path(event.transcript_path))
529
+
530
+ def _on_appended(self, event: AgentMessageAppended) -> None:
531
+ if event.agent_id != self._agent_id:
532
+ return
533
+ self._dispatch_entry(TranscriptEntry(
534
+ role=event.role, text=event.text,
535
+ tool_id=event.tool_id, tool_name=event.tool_name,
536
+ ))
537
+
538
+ def _on_state_changed(self, event: AgentStateChanged) -> None:
539
+ if event.info.id != self._agent_id:
540
+ return
541
+ if self._current_turn is None:
542
+ return
543
+ if event.info.state == AgentState.DONE:
544
+ self._current_turn.mark_done()
545
+ self._current_turn = None
546
+ elif event.info.state == AgentState.ERROR:
547
+ self._current_turn.mark_error()
548
+ self._current_turn = None
549
+
550
+ def _dispatch_entry(self, entry: TranscriptEntry) -> None:
551
+ if entry.role == "user":
552
+ self._open_turn(entry.text)
553
+ return
554
+ if self._current_turn is None:
555
+ # Defensive: a non-user entry arrived before any user entry.
556
+ # Open a synthetic empty turn so the entry has somewhere to live.
557
+ self._open_turn("")
558
+ turn = self._current_turn
559
+ assert turn is not None
560
+ # The VerticalScroll is `anchor()`ed in on_mount, so Textual keeps it
561
+ # pinned to the bottom across mounts and releases the anchor when the
562
+ # user scrolls up. No explicit scroll calls needed here.
563
+ if entry.role == "assistant":
564
+ turn.add_text(entry.text)
565
+ elif entry.role == "thinking":
566
+ turn.add_thinking(entry.text)
567
+ elif entry.role == "tool_use":
568
+ turn.add_tool_call(
569
+ tool_id=entry.tool_id, tool_name=entry.tool_name,
570
+ args_text=entry.text,
571
+ )
572
+ elif entry.role == "tool_result":
573
+ turn.attach_tool_result(
574
+ tool_id=entry.tool_id, content_text=entry.text,
575
+ )
576
+
577
+ def _open_turn(self, user_text: str) -> None:
578
+ scroll = self.query_one(VerticalScroll)
579
+ if self._current_turn is not None:
580
+ # Defensive — and required for history replay where no state event
581
+ # closes a previous turn.
582
+ self._current_turn.mark_done()
583
+ turn = _TurnContainer(user_text=user_text)
584
+ self._current_turn = turn
585
+ scroll.mount(turn)
586
+ scroll.scroll_end(animate=False)
587
+
588
+ # --- test helpers -----------------------------------------------------
589
+
590
+ def rendered_text(self) -> str:
591
+ """Concatenate all visible text in the scroll, for tests."""
592
+ scroll = self.query_one(VerticalScroll)
593
+ parts: list[str] = []
594
+ for child in scroll.children:
595
+ if isinstance(child, _TurnContainer):
596
+ parts.append(child.rendered_text())
597
+ elif isinstance(child, Static):
598
+ parts.append(str(child.content))
599
+ return "\n".join(parts)
600
+
601
+ @classmethod
602
+ def default_border_title(cls, props: dict) -> str:
603
+ agent_id = props.get("agent_id")
604
+ if agent_id:
605
+ return f"Transcript: {agent_id}"
606
+ return "Transcript"