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.
- patchfeld/__init__.py +1 -0
- patchfeld/__main__.py +32 -0
- patchfeld/actions.py +34 -0
- patchfeld/activity/__init__.py +0 -0
- patchfeld/activity/log.py +237 -0
- patchfeld/agents/__init__.py +0 -0
- patchfeld/agents/child_tools.py +66 -0
- patchfeld/agents/fake_sdk_adapter.py +45 -0
- patchfeld/agents/manager.py +365 -0
- patchfeld/agents/permission_grants.py +98 -0
- patchfeld/agents/permission_inbox.py +91 -0
- patchfeld/agents/request_inbox.py +65 -0
- patchfeld/agents/sdk_adapter.py +49 -0
- patchfeld/agents/session.py +250 -0
- patchfeld/agents/sort.py +66 -0
- patchfeld/agents/state.py +81 -0
- patchfeld/app.py +1433 -0
- patchfeld/config.py +128 -0
- patchfeld/events.py +260 -0
- patchfeld/layout/__init__.py +0 -0
- patchfeld/layout/custom_widgets.py +82 -0
- patchfeld/layout/defaults.py +33 -0
- patchfeld/layout/engine.py +241 -0
- patchfeld/layout/local_widgets.py +188 -0
- patchfeld/layout/registry.py +69 -0
- patchfeld/layout/spec.py +104 -0
- patchfeld/layout/splitter.py +170 -0
- patchfeld/layout/titles.py +70 -0
- patchfeld/orchestrator/__init__.py +0 -0
- patchfeld/orchestrator/formatting.py +15 -0
- patchfeld/orchestrator/session.py +785 -0
- patchfeld/orchestrator/tabs_tools.py +149 -0
- patchfeld/orchestrator/tools.py +976 -0
- patchfeld/persistence/__init__.py +0 -0
- patchfeld/persistence/agents_index.py +68 -0
- patchfeld/persistence/atomic.py +47 -0
- patchfeld/persistence/layout_store.py +25 -0
- patchfeld/persistence/layouts_store.py +61 -0
- patchfeld/persistence/orchestrator_sessions.py +127 -0
- patchfeld/persistence/paths.py +48 -0
- patchfeld/persistence/themes_store.py +44 -0
- patchfeld/persistence/transcript_store.py +64 -0
- patchfeld/persistence/workspace_store.py +25 -0
- patchfeld/theme/__init__.py +0 -0
- patchfeld/theme/engine.py +75 -0
- patchfeld/theme/spec.py +31 -0
- patchfeld/widgets/__init__.py +0 -0
- patchfeld/widgets/_file_lang.py +36 -0
- patchfeld/widgets/_terminal_keys.py +89 -0
- patchfeld/widgets/_terminal_render.py +147 -0
- patchfeld/widgets/activity_feed.py +365 -0
- patchfeld/widgets/agent_table.py +236 -0
- patchfeld/widgets/agent_transcript.py +85 -0
- patchfeld/widgets/change_cwd_screen.py +39 -0
- patchfeld/widgets/chrome.py +210 -0
- patchfeld/widgets/diff_viewer.py +52 -0
- patchfeld/widgets/file_editor.py +258 -0
- patchfeld/widgets/file_tree.py +33 -0
- patchfeld/widgets/file_viewer.py +77 -0
- patchfeld/widgets/history_screen.py +58 -0
- patchfeld/widgets/layout_switcher.py +126 -0
- patchfeld/widgets/log_tail.py +113 -0
- patchfeld/widgets/markdown.py +65 -0
- patchfeld/widgets/new_tab_screen.py +31 -0
- patchfeld/widgets/notebook.py +45 -0
- patchfeld/widgets/orchestrator_chat.py +73 -0
- patchfeld/widgets/permission_modal.py +185 -0
- patchfeld/widgets/permission_request_bar.py +90 -0
- patchfeld/widgets/resume_screen.py +179 -0
- patchfeld/widgets/rich_transcript.py +606 -0
- patchfeld/widgets/system_usage.py +244 -0
- patchfeld/widgets/terminal.py +251 -0
- patchfeld/widgets/theme_switcher.py +63 -0
- patchfeld/widgets/transcript_screen.py +39 -0
- patchfeld/workspace/__init__.py +3 -0
- patchfeld/workspace/spec.py +72 -0
- patchfeld-0.2.0.dist-info/METADATA +584 -0
- patchfeld-0.2.0.dist-info/RECORD +81 -0
- patchfeld-0.2.0.dist-info/WHEEL +4 -0
- patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
- 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"
|