symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,458 @@
1
+ """Restore persisted conversation messages into a TUI transcript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Protocol
8
+
9
+ from coding_agent.agent import CodingAgent
10
+ from coding_agent.addons.persistence.collection import COLLECTABLE_EVENT_TYPES, is_collected
11
+ from coding_agent.tui.runtime.events import _duration
12
+ from coding_agent.tui.tools.activity import choose_completion_verb
13
+ from coding_agent.tui.tools.images import display_from_content
14
+ from coding_agent.tui.tools.snapshots import (
15
+ CompletedRunSummary,
16
+ ThoughtSnapshot,
17
+ ToolCallSnapshot,
18
+ ToolCallSummary,
19
+ snapshot_from_call,
20
+ )
21
+ from coding_agent.tui.transcript import UserMessage
22
+ from core_ai.content import text_from_content
23
+ from core_harness.context import COMPACTED_CONTEXT_MARK, estimate_prompt_tokens
24
+
25
+
26
+ class HistoryView(Protocol):
27
+ def add_notice(self, text: str, tone: str = "info") -> None: ...
28
+
29
+ def mount_transcript(self, widget: Any) -> None: ...
30
+
31
+ def set_context_metrics(self, tokens_used: int, context_limit: int) -> None: ...
32
+
33
+ def finalize_transcript_history(self) -> None: ...
34
+
35
+
36
+ async def load_session_history(agent: CodingAgent, view: HistoryView) -> None:
37
+ """Restore compact history with interactive child and background-job cards."""
38
+ messages, context_messages = await agent.persistence.load_resume(session_id=agent.session_id)
39
+ # Footer usage describes the active context projection, matching /context
40
+ # and the messages the next request will send.
41
+ context_limit = agent.harness.state.context_limit(agent.harness.model_id)
42
+ view.set_context_metrics(estimate_prompt_tokens(context_messages), context_limit)
43
+ view.add_notice(f"Resumed session · {agent.session_id}")
44
+
45
+ load_events = getattr(agent.persistence, "load_events", None)
46
+ events = await load_events(session_id=agent.session_id) if callable(load_events) else []
47
+ restored = _history_widgets(messages, events)
48
+ tools = getattr(view, "_tools", None)
49
+ if isinstance(tools, dict):
50
+ from coding_agent.tui.tools.calls import ToolCallWidget
51
+
52
+ tools.update((widget.call_id, widget) for widget in restored if isinstance(widget, ToolCallWidget))
53
+ mount_batch = getattr(view, "mount_transcript_batch", None)
54
+ if callable(mount_batch):
55
+ mount_batch(restored)
56
+ else:
57
+ for widget in restored:
58
+ view.mount_transcript(widget)
59
+ view.finalize_transcript_history()
60
+
61
+
62
+ def _history_widgets(
63
+ messages: list[Any],
64
+ events: list[tuple[str, dict[str, Any]]] | None = None,
65
+ ) -> list[Any]:
66
+ from coding_agent.tui.tools.calls import make_tool_widget
67
+ from coding_agent.tui.transcript import AssistantMessage
68
+
69
+ # Parent journals can contain forwarded child events. They belong to the
70
+ # child screen, never to the parent's tool/thought reconstruction.
71
+ events = [
72
+ (kind, payload) for kind, payload in events or []
73
+ if not payload.get("parent_id") or kind.startswith("agent_")
74
+ ]
75
+ notifications, job_results = _completion_notifications(messages, events)
76
+ restored: list[Any] = []
77
+ pending: dict[str, dict[str, Any]] = {}
78
+ batch: list[ToolCallSnapshot | ThoughtSnapshot] = []
79
+ thoughts = _thoughts_from_events(events or [])
80
+ event_calls = _tool_fields_from_events(events or [])
81
+ collected_runs = _collected_run_ids(events or [])
82
+ run_completions = _run_completions_from_events(events or [])
83
+ current_run: list[Any] = []
84
+ collecting = False
85
+ active_run_id = ""
86
+
87
+ def flush_batch() -> None:
88
+ if not batch:
89
+ return
90
+ summary = ToolCallSummary(tuple(batch))
91
+ if collecting:
92
+ current_run.append(summary)
93
+ else:
94
+ restored.append(summary)
95
+ batch.clear()
96
+
97
+ def add_snapshot(snapshot: ToolCallSnapshot | ThoughtSnapshot) -> None:
98
+ batch.append(snapshot)
99
+
100
+ def flush_run() -> None:
101
+ nonlocal collecting
102
+ flush_batch()
103
+ if current_run:
104
+ restored.extend(
105
+ _fold_collected_run(
106
+ current_run,
107
+ **(run_completions.pop(0) if run_completions else {}),
108
+ )
109
+ )
110
+ current_run.clear()
111
+ collecting = False
112
+
113
+ def add_visible(widget: Any) -> None:
114
+ if collecting:
115
+ current_run.append(widget)
116
+ else:
117
+ restored.append(widget)
118
+
119
+ def add_call(fields: dict[str, Any]) -> None:
120
+ nonlocal collecting, active_run_id
121
+ run_id = str(fields.get("run_id") or "")
122
+ if collected_runs and run_id and run_id != active_run_id:
123
+ flush_run()
124
+ active_run_id = run_id
125
+ if collected_runs == {""}:
126
+ collecting = True
127
+ elif collected_runs:
128
+ collecting = bool(run_id and run_id in collected_runs)
129
+ arguments = fields.get("arguments") or {}
130
+ if fields["tool_name"] in {"spawn_agent", "bash"}:
131
+ flush_batch()
132
+ widget = make_tool_widget(str(fields["call_id"]), str(fields["tool_name"]))
133
+ widget.set_arguments(arguments, str(fields.get("raw_arguments") or ""))
134
+ result = str(fields.get("result") or "")
135
+ job = re.search(r"started background job ([A-Za-z0-9_-]+)", result)
136
+ if job and job.group(1) in job_results:
137
+ result = job_results[job.group(1)]
138
+ widget.set_result(result)
139
+ if fields.get("status") == "failed":
140
+ widget.status = "failed"
141
+ add_visible(widget)
142
+ else:
143
+ add_snapshot(snapshot_from_call(**{key: value for key, value in fields.items() if key != "run_id"}))
144
+
145
+ for index, message in enumerate(messages):
146
+ if index in notifications:
147
+ continue
148
+ if message.role == "user":
149
+ if text_from_content(message.content).startswith(COMPACTED_CONTEXT_MARK):
150
+ continue
151
+ flush_run()
152
+ pending.clear()
153
+ text, images = display_from_content(message.content)
154
+ restored.append(UserMessage(text, images=images))
155
+ collecting = bool(collected_runs)
156
+ elif message.role == "assistant":
157
+ content = text_from_content(message.content)
158
+ calls = [_call_fields(call) for call in message.tool_calls or []]
159
+ for fields in calls:
160
+ call_id = str(fields["call_id"])
161
+ fields = _merge_call_fields(fields, event_calls.get(call_id, {}))
162
+ run_id = str(fields.get("run_id") or "")
163
+ if collected_runs and run_id and run_id != active_run_id:
164
+ flush_run()
165
+ active_run_id = run_id
166
+ if collected_runs == {""}:
167
+ collecting = True
168
+ elif collected_runs:
169
+ collecting = bool(run_id and run_id in collected_runs)
170
+ if content:
171
+ flush_batch()
172
+ add_visible(AssistantMessage(content, streaming=False))
173
+ if thoughts:
174
+ add_snapshot(thoughts.pop(0))
175
+ for fields in calls:
176
+ call_id = str(fields["call_id"])
177
+ pending[call_id] = _merge_call_fields(fields, event_calls.pop(call_id, {}))
178
+ elif message.role == "tool":
179
+ call_id = str(message.tool_call_id or "")
180
+ fields = pending.pop(call_id, None) or event_calls.pop(call_id, None) or {
181
+ "call_id": call_id or "history-tool",
182
+ "tool_name": "tool",
183
+ "arguments": {},
184
+ "raw_arguments": "",
185
+ }
186
+ result = text_from_content(message.content)
187
+ add_call({
188
+ **fields,
189
+ "result": result,
190
+ "status": "failed" if result.startswith("error:") else "done",
191
+ })
192
+
193
+ for fields in pending.values():
194
+ add_call({"status": "done", **fields})
195
+ for leftover_fields in event_calls.values():
196
+ add_call({"status": "done", **leftover_fields})
197
+ for leftover in thoughts:
198
+ add_snapshot(leftover)
199
+ flush_run()
200
+ return restored
201
+
202
+
203
+ def _completion_notifications(
204
+ messages: list[Any], events: list[tuple[str, dict[str, Any]]],
205
+ ) -> tuple[set[int], dict[str, str]]:
206
+ """Recognize legacy runtime user messages only for known children/jobs.
207
+
208
+ These messages remain in model context, but are not actual user prompts.
209
+ Correlation avoids hiding ordinary user text mentioning background work.
210
+ """
211
+ children = {
212
+ str(payload["child_id"]) for kind, payload in events
213
+ if kind == "agent_spawned" and payload.get("child_id")
214
+ }
215
+ jobs: set[str] = set()
216
+ tool_results = {str(message.tool_call_id): message for message in messages if message.role == "tool"}
217
+ for message in messages:
218
+ if message.role == "assistant":
219
+ for call in message.tool_calls or []:
220
+ fields = _call_fields(call)
221
+ if fields["tool_name"] != "spawn_agent":
222
+ continue
223
+ # Older journals may lack agent_spawned events, but the spawn
224
+ # result still carries the child identity.
225
+ result = tool_results.get(fields["call_id"])
226
+ if result is None:
227
+ continue
228
+ try:
229
+ child = json.loads(text_from_content(result.content))
230
+ except (ValueError, TypeError):
231
+ continue
232
+ if isinstance(child, dict) and child.get("child_id"):
233
+ children.add(str(child["child_id"]))
234
+ if message.role == "tool":
235
+ match = re.match(r"started background job ([A-Za-z0-9_-]+)\nlog: ", text_from_content(message.content))
236
+ if match:
237
+ jobs.add(match.group(1))
238
+ hidden: set[int] = set()
239
+ results: dict[str, str] = {}
240
+ for index, message in enumerate(messages):
241
+ if message.role != "user":
242
+ continue
243
+ text = text_from_content(message.content)
244
+ child = re.match(r"Subagent .+ \(([^()]+)\) (?:completed|failed|cancelled|interrupted)\.\n", text)
245
+ job = re.match(r"Background job ([A-Za-z0-9_-]+) .+ after [\d.]+s\. Last output:\n", text)
246
+ if child and child.group(1) in children:
247
+ hidden.add(index)
248
+ elif job and job.group(1) in jobs and "\nFull log: " in text:
249
+ hidden.add(index)
250
+ results[job.group(1)] = text
251
+ return hidden, results
252
+
253
+
254
+ def _thoughts_from_events(events: list[tuple[str, dict[str, Any]]]) -> list[ThoughtSnapshot]:
255
+ """Restore completed thoughts from persisted events when they exist.
256
+
257
+ Live reasoning deltas are skipped by session persistence, so this only
258
+ reconstructs compact snapshots from completed thought payloads. History
259
+ never remounts live ReasoningWidget cards.
260
+ """
261
+ thoughts: list[ThoughtSnapshot] = []
262
+ for event_type, payload in events:
263
+ if event_type not in {"reasoning_completed", "thought_completed"}:
264
+ continue
265
+ content = str(payload.get("text") or payload.get("content") or "").strip()
266
+ if not content:
267
+ continue
268
+ title = str(payload.get("title") or "Thought").strip() or "Thought"
269
+ thoughts.append(ThoughtSnapshot(title=title, content=content))
270
+ return thoughts
271
+
272
+
273
+ def _collected_run_ids(events: list[tuple[str, dict[str, Any]]]) -> set[str]:
274
+ """Runs whose persisted mid-run work was explicitly folded.
275
+
276
+ Empty means a legacy collected event had no run ID. Those journals cannot
277
+ distinguish runs, so resume keeps the historical session-wide fold.
278
+ """
279
+ collected = [
280
+ payload for event_type, payload in events
281
+ if event_type in COLLECTABLE_EVENT_TYPES and is_collected(payload)
282
+ ]
283
+ if not collected:
284
+ return set()
285
+ if any(not payload.get("run_id") for payload in collected):
286
+ return {""}
287
+ return {str(payload["run_id"]) for payload in collected}
288
+
289
+
290
+ def _tool_fields_from_events(
291
+ events: list[tuple[str, dict[str, Any]]],
292
+ ) -> dict[str, dict[str, Any]]:
293
+ """Use persisted protocol arguments as the source of truth for history tools."""
294
+ calls: dict[str, dict[str, Any]] = {}
295
+ for event_type, payload in events:
296
+ if event_type not in {"tool_call_started", "tool_execution_started", "tool_execution_completed", "agent_spawned"}:
297
+ continue
298
+ call_id = str(payload.get("tool_call_id") or "")
299
+ if not call_id:
300
+ continue
301
+ current = calls.setdefault(
302
+ call_id,
303
+ {
304
+ "call_id": call_id,
305
+ "tool_name": "tool",
306
+ "arguments": {},
307
+ "raw_arguments": "",
308
+ "run_id": "",
309
+ },
310
+ )
311
+ tool_name = str(payload.get("tool_name") or "")
312
+ if tool_name:
313
+ current["tool_name"] = tool_name
314
+ arguments = payload.get("arguments")
315
+ if isinstance(arguments, dict) and arguments:
316
+ current["arguments"] = dict(arguments)
317
+ current["raw_arguments"] = json.dumps(arguments, ensure_ascii=False)
318
+ run_id = str(payload.get("run_id") or "")
319
+ if run_id:
320
+ current["run_id"] = run_id
321
+ if event_type == "agent_spawned":
322
+ call_id = str(payload.get("tool_call_id") or "")
323
+ if call_id:
324
+ calls.setdefault(call_id, {
325
+ "call_id": call_id,
326
+ "tool_name": "spawn_agent",
327
+ "arguments": {},
328
+ "raw_arguments": "",
329
+ "run_id": "",
330
+ })["run_id"] = str(payload.get("run_id") or calls[call_id]["run_id"])
331
+ continue
332
+ if event_type == "tool_execution_completed":
333
+ result = payload.get("result")
334
+ if result:
335
+ current["result"] = str(result)
336
+ status = str(payload.get("status") or "")
337
+ if status in {"error", "cancelled", "timeout"}:
338
+ current["status"] = "failed"
339
+ elif status:
340
+ current["status"] = "done"
341
+ return calls
342
+
343
+
344
+ def _merge_call_fields(preferred: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
345
+ merged = dict(fallback)
346
+ for key, value in preferred.items():
347
+ if value in ("", None, {}, []) or key == "tool_name" and value == "tool":
348
+ continue
349
+ merged[key] = value
350
+ return merged
351
+
352
+
353
+ def _run_completions_from_events(
354
+ events: list[tuple[str, dict[str, Any]]],
355
+ ) -> list[dict[str, str]]:
356
+ """Restore completed-run verb and duration from persisted run events."""
357
+ completions: list[dict[str, str]] = []
358
+ started: dict[str, float] = {}
359
+ fallback_started: float | None = None
360
+ for event_type, payload in events:
361
+ run_id = str(payload.get("run_id") or "")
362
+ if event_type == "run_started":
363
+ ts = payload.get("ts")
364
+ if isinstance(ts, (int, float)):
365
+ if run_id:
366
+ started[run_id] = float(ts)
367
+ else:
368
+ fallback_started = float(ts)
369
+ continue
370
+ if event_type not in {"run_completed", "run_completed_meta"}:
371
+ continue
372
+ duration = str(payload.get("duration") or "").strip()
373
+ stored = payload.get("elapsed_seconds")
374
+ if not duration and isinstance(stored, (int, float)):
375
+ duration = _duration(float(stored))
376
+ if not duration and event_type == "run_completed":
377
+ start = started.get(run_id, fallback_started)
378
+ end = payload.get("ts")
379
+ if isinstance(start, (int, float)) and isinstance(end, (int, float)):
380
+ duration = _duration(max(0.0, float(end) - float(start)))
381
+ verb = str(payload.get("completion_verb") or "").strip()
382
+ if event_type == "run_completed_meta" and completions:
383
+ current = completions[-1]
384
+ if verb:
385
+ current["verb"] = verb
386
+ if duration:
387
+ current["duration"] = duration
388
+ continue
389
+ completions.append({"verb": verb, "duration": duration})
390
+ return completions
391
+
392
+
393
+ def _folds_on_completion(item: Any) -> bool:
394
+ """Match the live completed-run rule for foreground cards."""
395
+ from coding_agent.tui.tools.calls import BashToolWidget, PatchDiffWidget, ToolCallWidget
396
+
397
+ return isinstance(item, ToolCallWidget) and (
398
+ not item.keep_in_transcript
399
+ or isinstance(item, PatchDiffWidget)
400
+ or isinstance(item, BashToolWidget) and not item.arguments.get("background")
401
+ )
402
+
403
+
404
+ def _fold_collected_run(
405
+ items: list[Any],
406
+ *,
407
+ verb: str = "",
408
+ duration: str = "",
409
+ ) -> list[Any]:
410
+ """Fold mid-run history into a completed-run collection, keep the final reply."""
411
+ from coding_agent.tui.transcript import AssistantMessage
412
+
413
+ summary = CompletedRunSummary(
414
+ verb=verb or choose_completion_verb(),
415
+ duration=duration,
416
+ )
417
+ assistants: list[Any] = []
418
+ cards: list[Any] = []
419
+ for item in items:
420
+ if isinstance(item, AssistantMessage):
421
+ assistants.append(item)
422
+ continue
423
+ if isinstance(item, ToolCallSummary):
424
+ for entry in item.entries:
425
+ if isinstance(entry, ThoughtSnapshot):
426
+ summary.add_thought(entry.title, entry.content, layout=False)
427
+ else:
428
+ summary.add_call(entry, layout=False)
429
+ elif _folds_on_completion(item):
430
+ summary.add_call(item.snapshot(), layout=False)
431
+ else:
432
+ cards.append(item)
433
+ folded: list[Any] = list(cards)
434
+ if summary.entries:
435
+ folded.append(summary)
436
+ if assistants:
437
+ folded.append(assistants[-1])
438
+ elif not folded:
439
+ folded.extend(items)
440
+ return folded
441
+
442
+
443
+ def _call_fields(call: Any) -> dict[str, Any]:
444
+ function = call.get("function") or {}
445
+ raw = function.get("arguments") or call.get("arguments") or ""
446
+ try:
447
+ args = json.loads(raw) if isinstance(raw, str) else raw
448
+ except json.JSONDecodeError:
449
+ args = {}
450
+ if not isinstance(args, dict):
451
+ args = {}
452
+ return {
453
+ "call_id": str(call.get("id") or "history-tool"),
454
+ "run_id": "",
455
+ "tool_name": str(function.get("name") or call.get("name") or "tool"),
456
+ "arguments": args,
457
+ "raw_arguments": raw if isinstance(raw, str) else "",
458
+ }
@@ -0,0 +1,142 @@
1
+ """Small in-session setup form for Langfuse credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Container
9
+ from textual.widgets import Input, Static
10
+
11
+ from coding_agent.config.credentials import global_env_path, upsert_dotenv
12
+ from coding_agent.tui.screens.modal import ModalBase, ModalCloseButton
13
+
14
+
15
+ class LangfuseSetupScreen(ModalBase[bool]):
16
+ """Collect Langfuse credentials without displaying the secret key."""
17
+
18
+ # Match the provider onboarding modal: a full-size, dark modal pane with
19
+ # the same header, input, error, and footer treatment.
20
+ CSS = """
21
+ LangfuseSetupScreen {
22
+ align: center middle;
23
+ background: rgba(0, 0, 0, 0.52);
24
+ }
25
+
26
+ #langfuse-pane {
27
+ width: 98%;
28
+ max-width: 180;
29
+ height: 94%;
30
+ padding: 0 1;
31
+ background: #0A0A0A;
32
+ border: round #262626;
33
+ }
34
+
35
+ #langfuse-pane > #modal-close {
36
+ background: #0A0A0A;
37
+ }
38
+
39
+ #langfuse-title {
40
+ width: 100%;
41
+ height: 3;
42
+ min-height: 3;
43
+ padding: 1 8 0 1;
44
+ color: #d9dde0;
45
+ text-style: bold;
46
+ background: #0A0A0A;
47
+ border-bottom: solid #262626;
48
+ }
49
+
50
+ #langfuse-subtitle {
51
+ width: 100%;
52
+ height: 2;
53
+ padding: 1 1 0 1;
54
+ color: #858d91;
55
+ background: #0A0A0A;
56
+ }
57
+
58
+ #langfuse-pane Input {
59
+ width: 100%;
60
+ height: 3;
61
+ margin: 1 1 0 1;
62
+ background: #141414;
63
+ color: #ededed;
64
+ border: tall #2a2a2a;
65
+ }
66
+
67
+ #langfuse-pane Input:focus {
68
+ border: tall #e1c16e;
69
+ }
70
+
71
+ #langfuse-error {
72
+ height: 1;
73
+ margin: 1 1 0 1;
74
+ color: #db6767;
75
+ }
76
+
77
+ #langfuse-hint {
78
+ width: 100%;
79
+ height: 2;
80
+ padding: 1 1 0 1;
81
+ color: #686868;
82
+ background: #0A0A0A;
83
+ border-top: solid #262626;
84
+ }
85
+ """
86
+
87
+ def compose(self) -> ComposeResult:
88
+ with Container(id="langfuse-pane", classes="modal-pane"):
89
+ yield ModalCloseButton("Esc", id="modal-close")
90
+ yield Static("Langfuse setup", id="langfuse-title")
91
+ yield Static(
92
+ "Send run traces to Langfuse. Keys are saved to ~/.symphony/.env (0600).",
93
+ id="langfuse-subtitle",
94
+ )
95
+ yield Input(
96
+ value=os.environ.get("LANGFUSE_PUBLIC_KEY", ""),
97
+ placeholder="pk-lf-...",
98
+ id="langfuse-public-key",
99
+ )
100
+ yield Input(
101
+ placeholder="sk-lf-...",
102
+ password=True,
103
+ id="langfuse-secret-key",
104
+ )
105
+ yield Input(
106
+ value=os.environ.get("LANGFUSE_BASE_URL", "https://cloud.langfuse.com"),
107
+ placeholder="https://cloud.langfuse.com",
108
+ id="langfuse-base-url",
109
+ )
110
+ yield Static("Enter submits · Esc cancels", id="langfuse-hint")
111
+ yield Static("", id="langfuse-error")
112
+
113
+ def on_mount(self) -> None:
114
+ self.query_one("#langfuse-public-key", Input).focus()
115
+
116
+ def on_input_submitted(self, event: Input.Submitted) -> None:
117
+ if event.input.id == "langfuse-public-key":
118
+ self.query_one("#langfuse-secret-key", Input).focus()
119
+ return
120
+ if event.input.id == "langfuse-secret-key":
121
+ self.query_one("#langfuse-base-url", Input).focus()
122
+ return
123
+ public_key = self.query_one("#langfuse-public-key", Input).value.strip()
124
+ secret_key = self.query_one("#langfuse-secret-key", Input).value.strip()
125
+ base_url = self.query_one("#langfuse-base-url", Input).value.strip()
126
+ if not public_key or not secret_key:
127
+ self.query_one("#langfuse-error", Static).update(
128
+ "Public and secret keys are required."
129
+ )
130
+ return
131
+ updates = {
132
+ "LANGFUSE_PUBLIC_KEY": public_key,
133
+ "LANGFUSE_SECRET_KEY": secret_key,
134
+ }
135
+ if base_url:
136
+ updates["LANGFUSE_BASE_URL"] = base_url
137
+ upsert_dotenv(global_env_path(), updates)
138
+ os.environ.update(updates)
139
+ self.dismiss(True)
140
+
141
+
142
+ __all__ = ["LangfuseSetupScreen"]