synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
synapse/ui/timeline.py ADDED
@@ -0,0 +1,621 @@
1
+ """Cursor-style tool timeline pure model (no Textual dependency).
2
+
3
+ Used by TUI (and optionally stream) to:
4
+ - classify tools
5
+ - build single-item labels (``Read README.md``)
6
+ - aggregate group headers (``Read 22 files, Searched 1 pattern``)
7
+ - truncate tool-result previews
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections import OrderedDict
14
+ from dataclasses import dataclass, field
15
+ from pathlib import PurePosixPath, PureWindowsPath
16
+ from typing import Any
17
+
18
+ # Default preview budget (matches docs/tui-cursor-refactor.md).
19
+ DEFAULT_PREVIEW_CHARS = 2000
20
+ DEFAULT_PREVIEW_LINES = 40
21
+
22
+ _PATH_KEYS = (
23
+ "path",
24
+ "file_path",
25
+ "filename",
26
+ "file",
27
+ "target",
28
+ "target_file",
29
+ "source",
30
+ "src",
31
+ "directory",
32
+ "dir",
33
+ "glob",
34
+ )
35
+
36
+ _CMD_KEYS = ("command", "cmd", "code", "script")
37
+ _PATTERN_KEYS = ("pattern", "query", "regex", "needle")
38
+ _INTENT_KEYS = ("intent", "purpose", "reason")
39
+ _TODO_TOOL_NAMES = frozenset({"write_todos", "todo_write", "todos"})
40
+ _TODO_DONE = frozenset({"completed", "done", "complete", "finished"})
41
+ _TODO_ACTIVE = frozenset({"in_progress", "in-progress", "running", "active", "doing"})
42
+
43
+
44
+ @dataclass
45
+ class ToolItem:
46
+ id: str
47
+ name: str
48
+ category: str
49
+ label: str
50
+ path: str | None = None
51
+ status: str = "running"
52
+ preview: str | None = None
53
+ error: bool = False
54
+ sub: bool = False
55
+ parent_id: str | None = None
56
+ call_id: str | None = None
57
+
58
+
59
+ @dataclass
60
+ class ToolGroup:
61
+ id: str
62
+ summary: str
63
+ items: list[ToolItem] = field(default_factory=list)
64
+ collapsed: bool = False
65
+ running: bool = True
66
+
67
+
68
+ @dataclass
69
+ class ThoughtBlock:
70
+ elapsed_s: float
71
+ body: str
72
+ collapsed: bool = False
73
+
74
+
75
+ def tool_category(name: str) -> str:
76
+ n = (name or "").lower()
77
+ if n in {"read_file", "read", "read_file_lines"}:
78
+ return "read"
79
+ if n in {"write_file", "edit_file", "write", "edit", "create_file"}:
80
+ return "edit"
81
+ if n in {"ls", "list_dir", "list_directory"}:
82
+ return "list"
83
+ if n in {"glob", "find"}:
84
+ return "glob"
85
+ if n in {"grep", "search", "rg"}:
86
+ return "search"
87
+ if n in {"execute", "run", "shell", "bash"}:
88
+ return "run"
89
+ if n in {"task", "subagent"}:
90
+ return "task"
91
+ return "other"
92
+
93
+
94
+ def _basename(path: str | None) -> str | None:
95
+ if not path:
96
+ return None
97
+ s = str(path).strip().replace("\\", "/")
98
+ if not s:
99
+ return None
100
+ # Keep trailing slash dirs readable.
101
+ if s.endswith("/") and s != "/":
102
+ s = s.rstrip("/")
103
+ base = PurePosixPath(s).name
104
+ return f"{base}/" if base else s
105
+ name = PurePosixPath(s).name
106
+ if not name:
107
+ # Windows drive edge
108
+ name = PureWindowsPath(path).name
109
+ return name or s
110
+
111
+
112
+ def extract_path(args: Any) -> str | None:
113
+ if not isinstance(args, dict):
114
+ return None
115
+ for key in _PATH_KEYS:
116
+ val = args.get(key)
117
+ if isinstance(val, str) and val.strip():
118
+ return val.strip()
119
+ return None
120
+
121
+
122
+ def extract_command(args: Any) -> str | None:
123
+ if not isinstance(args, dict):
124
+ return None
125
+ for key in _CMD_KEYS:
126
+ val = args.get(key)
127
+ if val is None:
128
+ continue
129
+ s = str(val).replace("\n", " ").strip()
130
+ if s:
131
+ return s if len(s) <= 72 else s[:71] + "…"
132
+ return None
133
+
134
+
135
+ def extract_pattern(args: Any) -> str | None:
136
+ if not isinstance(args, dict):
137
+ return None
138
+ for key in _PATTERN_KEYS:
139
+ val = args.get(key)
140
+ if isinstance(val, str) and val.strip():
141
+ s = val.strip()
142
+ return s if len(s) <= 40 else s[:39] + "…"
143
+ return None
144
+
145
+
146
+
147
+ def extract_intent(args: Any, *, limit: int = 96) -> str | None:
148
+ """Return the model-provided user-facing intent string, if any."""
149
+ if not isinstance(args, dict):
150
+ return None
151
+ for key in _INTENT_KEYS:
152
+ val = args.get(key)
153
+ if not isinstance(val, str):
154
+ continue
155
+ s = " ".join(val.split()).strip()
156
+ if not s:
157
+ continue
158
+ return s if len(s) <= limit else s[: limit - 1].rstrip() + "…"
159
+ return None
160
+
161
+
162
+ def is_todo_tool(name: str | None) -> bool:
163
+ return (name or "").lower() in _TODO_TOOL_NAMES
164
+
165
+
166
+ def _todo_status_key(raw: Any) -> str:
167
+ return str(raw or "pending").strip().casefold().replace(" ", "_").replace("-", "_")
168
+
169
+
170
+ def extract_todos(args: Any) -> list[dict[str, Any]]:
171
+ """Normalize ``write_todos`` args into ``{content,status}`` dicts."""
172
+ if not isinstance(args, dict):
173
+ return []
174
+ todos = args.get("todos")
175
+ if not isinstance(todos, list):
176
+ return []
177
+ out: list[dict[str, Any]] = []
178
+ for t in todos:
179
+ if isinstance(t, dict):
180
+ content = str(
181
+ t.get("content") or t.get("text") or t.get("title") or ""
182
+ ).strip()
183
+ status = _todo_status_key(t.get("status")) or "pending"
184
+ out.append({"content": content, "status": status})
185
+ elif isinstance(t, str) and t.strip():
186
+ out.append({"content": t.strip(), "status": "pending"})
187
+ return out
188
+
189
+
190
+ def todo_counts(todos: list[dict[str, Any]]) -> tuple[int, int, int]:
191
+ """Return ``(done, active, pending)`` counts."""
192
+ done = active = pending = 0
193
+ for t in todos:
194
+ st = _todo_status_key(t.get("status"))
195
+ if st in _TODO_DONE:
196
+ done += 1
197
+ elif st in _TODO_ACTIVE:
198
+ active += 1
199
+ else:
200
+ pending += 1
201
+ return done, active, pending
202
+
203
+
204
+ def summarize_todos(todos: list[dict[str, Any]] | None) -> str | None:
205
+ """One-line label: ``Todos 1/4 · in progress: explore agent``."""
206
+ if not todos:
207
+ return None
208
+ done, active, _pending = todo_counts(todos)
209
+ total = len(todos)
210
+ label = f"Todos {done}/{total}"
211
+ if active:
212
+ current = next(
213
+ (
214
+ str(t.get("content") or "").strip()
215
+ for t in todos
216
+ if _todo_status_key(t.get("status")) in _TODO_ACTIVE
217
+ ),
218
+ "",
219
+ )
220
+ if current:
221
+ if len(current) > 42:
222
+ current = current[:41].rstrip() + "…"
223
+ return f"{label} · in progress: {current}"
224
+ return f"{label} · {active} in progress"
225
+ if done == total and total:
226
+ return f"{label} · all done"
227
+ return label
228
+
229
+
230
+ # Status marks for plain-text preview + TUI checklist widget.
231
+ # Prefer common Unicode (not emoji): ✓ done, ● active, ○ pending.
232
+ TODO_MARK_DONE = "✓"
233
+ TODO_MARK_ACTIVE = "●"
234
+ TODO_MARK_PENDING = "○"
235
+ _TODO_MARK_BY_KIND = {
236
+ "done": TODO_MARK_DONE,
237
+ "active": TODO_MARK_ACTIVE,
238
+ "pending": TODO_MARK_PENDING,
239
+ }
240
+ # Accept older ASCII marks when re-parsing stored previews.
241
+ _TODO_KIND_BY_MARK = {
242
+ TODO_MARK_DONE: "done",
243
+ TODO_MARK_ACTIVE: "active",
244
+ TODO_MARK_PENDING: "pending",
245
+ "x": "done",
246
+ "X": "done",
247
+ "~": "active",
248
+ "…": "active",
249
+ "·": "pending",
250
+ " ": "pending",
251
+ "-": "pending",
252
+ }
253
+
254
+
255
+ def todo_status_kind(status: Any) -> str:
256
+ """Map raw status to ``done`` / ``active`` / ``pending``."""
257
+ st = _todo_status_key(status)
258
+ if st in _TODO_DONE:
259
+ return "done"
260
+ if st in _TODO_ACTIVE:
261
+ return "active"
262
+ return "pending"
263
+
264
+
265
+ def todo_mark(kind_or_status: Any) -> str:
266
+ """Return the display mark for a kind or raw status string."""
267
+ kind = str(kind_or_status or "").strip().casefold()
268
+ if kind in _TODO_MARK_BY_KIND:
269
+ return _TODO_MARK_BY_KIND[kind]
270
+ return _TODO_MARK_BY_KIND[todo_status_kind(kind_or_status)]
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class TodoRow:
275
+ """One checklist row for rendering."""
276
+
277
+ content: str
278
+ status: str # raw / normalized status text
279
+ kind: str # done | active | pending
280
+ mark: str
281
+
282
+ @property
283
+ def line(self) -> str:
284
+ return f"{self.mark} {self.content}"
285
+
286
+
287
+ def iter_todo_rows(
288
+ todos: list[dict[str, Any]] | None,
289
+ *,
290
+ max_items: int | None = None,
291
+ ) -> list[TodoRow]:
292
+ """Build structured checklist rows from normalized todos."""
293
+ if not todos:
294
+ return []
295
+ rows: list[TodoRow] = []
296
+ items = todos if max_items is None else todos[:max_items]
297
+ for t in items:
298
+ content = str(t.get("content") or "").strip() or "(empty)"
299
+ status = _todo_status_key(t.get("status")) or "pending"
300
+ kind = todo_status_kind(status)
301
+ rows.append(
302
+ TodoRow(
303
+ content=content,
304
+ status=status,
305
+ kind=kind,
306
+ mark=todo_mark(kind),
307
+ )
308
+ )
309
+ return rows
310
+
311
+
312
+ def format_todos_preview(
313
+ todos: list[dict[str, Any]] | None,
314
+ *,
315
+ max_items: int = 16,
316
+ ) -> str | None:
317
+ """Multi-line checklist: ``✓`` done / ``●`` active / ``○`` pending."""
318
+ if not todos:
319
+ return None
320
+ rows = iter_todo_rows(todos, max_items=max_items)
321
+ lines = [r.line for r in rows]
322
+ if len(todos) > max_items:
323
+ lines.append(f"… +{len(todos) - max_items} more")
324
+ done, active, pending = todo_counts(todos)
325
+ lines.append(f"— done {done} · doing {active} · todo {pending}")
326
+ return "\n".join(lines)
327
+
328
+
329
+ def parse_todo_preview_lines(preview: str | None) -> list[TodoRow]:
330
+ """Best-effort parse of a stored checklist preview back into rows."""
331
+ if not preview or not str(preview).strip():
332
+ return []
333
+ rows: list[TodoRow] = []
334
+ for raw in str(preview).splitlines():
335
+ line = raw.strip()
336
+ if not line or line.startswith("—") or line.startswith("…"):
337
+ continue
338
+ # New form: "✓ content" / "● content" / "○ content"
339
+ if line[0] in _TODO_KIND_BY_MARK and (len(line) == 1 or line[1] == " "):
340
+ mark = line[0]
341
+ kind = _TODO_KIND_BY_MARK[mark]
342
+ content = line[1:].strip() or "(empty)"
343
+ rows.append(TodoRow(content=content, status=kind, kind=kind, mark=todo_mark(kind)))
344
+ continue
345
+ # Legacy form: "[x] content" / "[~] content" / "[ ] content"
346
+ if line.startswith("[") and "]" in line[:4]:
347
+ close = line.find("]")
348
+ mark_raw = line[1:close]
349
+ kind = _TODO_KIND_BY_MARK.get(mark_raw, "pending")
350
+ content = line[close + 1 :].strip() or "(empty)"
351
+ rows.append(
352
+ TodoRow(
353
+ content=content,
354
+ status=kind,
355
+ kind=kind,
356
+ mark=todo_mark(kind),
357
+ )
358
+ )
359
+ return rows
360
+
361
+
362
+ def category_phrase(cat: str, n: int, *, running: bool = False) -> str:
363
+ if cat == "read":
364
+ return f"Read {n} file{'s' if n != 1 else ''}"
365
+ if cat == "edit":
366
+ return f"Edited {n} file{'s' if n != 1 else ''}"
367
+ if cat == "list":
368
+ return f"Listed {n} dir{'s' if n != 1 else ''}"
369
+ if cat == "glob":
370
+ return f"Matched {n} glob{'s' if n != 1 else ''}"
371
+ if cat == "search":
372
+ return f"Searched {n} pattern{'s' if n != 1 else ''}"
373
+ if cat == "run":
374
+ return f"Ran {n} command{'s' if n != 1 else ''}"
375
+ if cat == "task":
376
+ verb = "Running" if running else "Launched"
377
+ return f"{verb} {n} subagent{'s' if n != 1 else ''}"
378
+ return f"{n} tools"
379
+
380
+
381
+ def item_label(name: str, args: Any = None) -> str:
382
+ """Single-line Cursor label, e.g. ``Read README.md`` / ``Run pytest``."""
383
+ n = (name or "").lower()
384
+
385
+ # Todos: prefer checklist state over generic intent.
386
+ if is_todo_tool(n):
387
+ summary = summarize_todos(extract_todos(args))
388
+ if summary:
389
+ return summary
390
+ intent = extract_intent(args)
391
+ if intent:
392
+ return intent
393
+ return "Updated todos"
394
+
395
+ intent = extract_intent(args)
396
+ if intent:
397
+ return intent
398
+
399
+ cat = tool_category(name)
400
+ path = extract_path(args)
401
+ base = _basename(path)
402
+
403
+ if cat == "task" and isinstance(args, dict):
404
+ desc = args.get("description") or args.get("prompt")
405
+ if isinstance(desc, str) and desc.strip():
406
+ s = " ".join(desc.split()).strip()
407
+ return s if len(s) <= 96 else s[:95].rstrip() + "…"
408
+
409
+ if cat == "read":
410
+ return f"Read {base}" if base else "Read file"
411
+ if cat == "edit":
412
+ return f"Edited {base}" if base else "Edited file"
413
+ if cat == "list":
414
+ target = base or (path or "dir")
415
+ return f"Listed {target}"
416
+ if cat == "glob":
417
+ return f"Matched {base or path or 'glob'}"
418
+ if cat == "search":
419
+ pat = extract_pattern(args)
420
+ return f"Searched {pat}" if pat else "Searched pattern"
421
+ if cat == "run":
422
+ cmd = extract_command(args)
423
+ return f"Run {cmd}" if cmd else f"Run {name}"
424
+ if cat == "task":
425
+ return "Launched subagent"
426
+ return name or "tool"
427
+
428
+
429
+ def summarize_categories(
430
+ names: list[str],
431
+ *,
432
+ running: bool = False,
433
+ ) -> str:
434
+ """Group header: ``Read 22 files, Searched 1 pattern``."""
435
+ order: OrderedDict[str, int] = OrderedDict()
436
+ for name in names:
437
+ cat = tool_category(name)
438
+ order[cat] = order.get(cat, 0) + 1
439
+
440
+ parts: list[str] = []
441
+ others: list[str] = []
442
+ for cat, n in order.items():
443
+ if cat == "other":
444
+ continue
445
+ parts.append(category_phrase(cat, n, running=running))
446
+ for name in names:
447
+ if tool_category(name) == "other" and name not in others:
448
+ others.append(name)
449
+ if others:
450
+ shown = ", ".join(others[:3])
451
+ if len(others) > 3:
452
+ shown += "…"
453
+ parts.append(shown)
454
+
455
+ body = ", ".join(parts) if parts else f"{len(names)} tools"
456
+ has_task = any(tool_category(name) == "task" for name in names)
457
+ return f"Running {body}" if running and not has_task else body
458
+
459
+
460
+ def summarize_items(items: list[ToolItem], *, running: bool = False) -> str:
461
+ top_level = [it for it in items if not it.sub]
462
+ if not top_level:
463
+ return summarize_categories([], running=running)
464
+
465
+ task_items = [it for it in top_level if tool_category(it.name) == "task"]
466
+ other_items = [it for it in top_level if tool_category(it.name) != "task"]
467
+ parts: list[str] = []
468
+ if task_items:
469
+ task_running = running and any(it.status == "running" for it in task_items)
470
+ parts.append(
471
+ category_phrase("task", len(task_items), running=task_running)
472
+ )
473
+ if other_items:
474
+ other_running = running and any(it.status == "running" for it in other_items)
475
+ parts.append(
476
+ summarize_categories(
477
+ [it.name for it in other_items], running=other_running
478
+ )
479
+ )
480
+ return ", ".join(parts)
481
+
482
+
483
+ _WS = re.compile(r"\s+")
484
+
485
+
486
+ def content_to_text(content: Any) -> str:
487
+ if content is None:
488
+ return ""
489
+ if isinstance(content, str):
490
+ return content
491
+ if isinstance(content, (bytes, bytearray)):
492
+ return bytes(content).decode("utf-8", errors="replace")
493
+ if isinstance(content, list):
494
+ parts: list[str] = []
495
+ for part in content:
496
+ if isinstance(part, str):
497
+ parts.append(part)
498
+ elif isinstance(part, dict):
499
+ if part.get("type") == "text" and part.get("text"):
500
+ parts.append(str(part["text"]))
501
+ elif "text" in part:
502
+ parts.append(str(part["text"]))
503
+ else:
504
+ parts.append(str(part))
505
+ return "".join(parts)
506
+ return str(content)
507
+
508
+
509
+ def is_error_status(status: str, content: str = "") -> bool:
510
+ s = (status or "").lower()
511
+ c = (content or "").lower()
512
+ if s.startswith("error"):
513
+ return True
514
+ if c.startswith("error") or "traceback" in c or "not supported" in c:
515
+ return True
516
+ return False
517
+
518
+
519
+ def truncate_preview(
520
+ content: Any,
521
+ *,
522
+ max_chars: int = DEFAULT_PREVIEW_CHARS,
523
+ max_lines: int = DEFAULT_PREVIEW_LINES,
524
+ ) -> str | None:
525
+ """Truncate tool result body for UI preview. None if empty."""
526
+ text = content_to_text(content)
527
+ if not text or not text.strip():
528
+ return None
529
+ # Drop NULs / control noise lightly.
530
+ text = text.replace("\x00", "")
531
+ lines = text.splitlines()
532
+ truncated = False
533
+ if len(lines) > max_lines:
534
+ lines = lines[:max_lines]
535
+ truncated = True
536
+ body = "\n".join(lines)
537
+ if len(body) > max_chars:
538
+ body = body[: max_chars - 1].rstrip() + "…"
539
+ truncated = True
540
+ elif truncated:
541
+ body = body.rstrip() + "\n…"
542
+ return body
543
+
544
+
545
+ def format_preview_with_lines(preview: str, *, max_lines: int = 12) -> str:
546
+ """Render a small line-numbered preview block."""
547
+ lines = preview.splitlines()
548
+ out: list[str] = []
549
+ for i, line in enumerate(lines[:max_lines], start=1):
550
+ out.append(f"{i:>3} {line}")
551
+ if len(lines) > max_lines:
552
+ out.append(" …")
553
+ return "\n".join(out)
554
+
555
+
556
+ def build_tool_item(
557
+ call: Any,
558
+ *,
559
+ item_id: str,
560
+ index: int = 0,
561
+ sub: bool = False,
562
+ ) -> ToolItem:
563
+ """Build a ToolItem from a LangChain-style tool call object/dict."""
564
+ if isinstance(call, dict):
565
+ name = str(call.get("name") or "?")
566
+ args = call.get("args")
567
+ call_id = call.get("id") or call.get("tool_call_id")
568
+ else:
569
+ name = str(getattr(call, "name", "?") or "?")
570
+ args = getattr(call, "args", {}) or {}
571
+ call_id = getattr(call, "id", None) or getattr(call, "tool_call_id", None)
572
+
573
+ cat = tool_category(name)
574
+ path = extract_path(args)
575
+ label = item_label(name, args)
576
+ preview = None
577
+ if is_todo_tool(name):
578
+ preview = format_todos_preview(extract_todos(args))
579
+ return ToolItem(
580
+ id=item_id or f"{name}-{index}",
581
+ name=name,
582
+ category=cat,
583
+ label=label,
584
+ path=path,
585
+ status="running",
586
+ preview=preview,
587
+ error=False,
588
+ sub=sub,
589
+ call_id=str(call_id) if call_id else None,
590
+ )
591
+
592
+
593
+ def match_tool_result(
594
+ items: list[ToolItem],
595
+ name: str,
596
+ call_id: str | None = None,
597
+ ) -> ToolItem | None:
598
+ """Pick the unfinished item matching call ID first, then tool name.
599
+
600
+ Do **not** fall back to an arbitrary running item. Nested subagent tool
601
+ results (``read_file`` while the parent only has ``task`` pending) would
602
+ otherwise finish the parent task early and leave a trail of empty groups.
603
+ """
604
+ want = (name or "").strip()
605
+ wanted_id = str(call_id or "").strip()
606
+ if wanted_id:
607
+ for it in items:
608
+ if it.status == "running" and it.call_id == wanted_id:
609
+ return it
610
+ return None
611
+ for it in items:
612
+ if it.status != "running":
613
+ continue
614
+ if it.name == want:
615
+ return it
616
+ # Only tolerate a missing/empty tool name when exactly one item is running.
617
+ if not want:
618
+ running = [it for it in items if it.status == "running"]
619
+ if len(running) == 1:
620
+ return running[0]
621
+ return None