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
@@ -0,0 +1,733 @@
1
+ """Multimodal user content: image placeholders, clipboard, compose blocks.
2
+
3
+ Placeholder syntax in the prompt: ``[image#1]``, ``[image#2]``, ...
4
+ Each id maps to an in-memory (or file-backed) attachment held by the TUI composer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ PLACEHOLDER_RE = re.compile(r"\[image#(\d+)\]", re.IGNORECASE)
20
+
21
+ # Common vision-friendly types.
22
+ ALLOWED_MIME = frozenset(
23
+ {
24
+ "image/png",
25
+ "image/jpeg",
26
+ "image/jpg",
27
+ "image/webp",
28
+ "image/gif",
29
+ "image/bmp",
30
+ }
31
+ )
32
+
33
+ EXT_TO_MIME = {
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".webp": "image/webp",
38
+ ".gif": "image/gif",
39
+ ".bmp": "image/bmp",
40
+ }
41
+
42
+ MIME_TO_EXT = {
43
+ "image/png": ".png",
44
+ "image/jpeg": ".jpg",
45
+ "image/jpg": ".jpg",
46
+ "image/webp": ".webp",
47
+ "image/gif": ".gif",
48
+ "image/bmp": ".bmp",
49
+ }
50
+
51
+
52
+ class AttachmentError(ValueError):
53
+ """Invalid image attachment."""
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Attachment:
58
+ """One image ready to embed in a user message."""
59
+
60
+ id: int
61
+ name: str
62
+ mime: str
63
+ data: bytes
64
+ source: str = "clipboard" # clipboard | file | path-text
65
+
66
+ @property
67
+ def size(self) -> int:
68
+ return len(self.data)
69
+
70
+ @property
71
+ def placeholder(self) -> str:
72
+ return f"[image#{self.id}]"
73
+
74
+ def data_url(self) -> str:
75
+ b64 = base64.standard_b64encode(self.data).decode("ascii")
76
+ mime = "image/jpeg" if self.mime == "image/jpg" else self.mime
77
+ return f"data:{mime};base64,{b64}"
78
+
79
+
80
+ @dataclass
81
+ class ImageBank:
82
+ """Composer-side store: placeholder id -> attachment."""
83
+
84
+ items: dict[int, Attachment] = field(default_factory=dict)
85
+ _next_id: int = 1
86
+ max_images: int = 8
87
+ max_bytes: int = 4_000_000
88
+
89
+ def clear(self) -> None:
90
+ self.items.clear()
91
+ self._next_id = 1
92
+
93
+ def remove(self, image_id: int) -> Attachment | None:
94
+ """Drop one attachment by id. Returns removed item or None."""
95
+ return self.items.pop(int(image_id), None)
96
+
97
+ def __len__(self) -> int:
98
+ return len(self.items)
99
+
100
+ def next_id(self) -> int:
101
+ return self._next_id
102
+
103
+ def add_bytes(
104
+ self,
105
+ data: bytes,
106
+ *,
107
+ mime: str,
108
+ name: str | None = None,
109
+ source: str = "clipboard",
110
+ ) -> Attachment:
111
+ if not data:
112
+ raise AttachmentError("empty image data")
113
+ mime_n = (mime or "").strip().lower()
114
+ if mime_n == "image/jpg":
115
+ mime_n = "image/jpeg"
116
+ if mime_n not in ALLOWED_MIME:
117
+ raise AttachmentError(f"unsupported image type: {mime_n or '?'}")
118
+ if len(data) > self.max_bytes:
119
+ raise AttachmentError(
120
+ f"image too large: {len(data)} bytes (max {self.max_bytes})"
121
+ )
122
+ if len(self.items) >= self.max_images:
123
+ raise AttachmentError(f"too many images (max {self.max_images})")
124
+ idx = self._next_id
125
+ self._next_id += 1
126
+ ext = MIME_TO_EXT.get(mime_n, ".png")
127
+ att = Attachment(
128
+ id=idx,
129
+ name=name or f"clipboard-{idx}{ext}",
130
+ mime=mime_n,
131
+ data=data,
132
+ source=source,
133
+ )
134
+ self.items[idx] = att
135
+ return att
136
+
137
+ def add_path(self, path: Path | str) -> Attachment:
138
+ p = Path(path).expanduser()
139
+ if not p.is_file():
140
+ raise AttachmentError(f"not a file: {p}")
141
+ mime = EXT_TO_MIME.get(p.suffix.lower())
142
+ if not mime:
143
+ raise AttachmentError(f"unsupported extension: {p.suffix or '(none)'}")
144
+ data = p.read_bytes()
145
+ return self.add_bytes(data, mime=mime, name=p.name, source="file")
146
+
147
+ def summary_line(self) -> str:
148
+ if not self.items:
149
+ return ""
150
+ parts = [f"#{a.id} {a.name} ({_fmt_size(a.size)})" for a in self.items.values()]
151
+ return "images: " + ", ".join(parts)
152
+
153
+
154
+ def _fmt_size(n: int) -> str:
155
+ if n < 1024:
156
+ return f"{n}B"
157
+ if n < 1024 * 1024:
158
+ return f"{n / 1024:.0f}KB"
159
+ return f"{n / (1024 * 1024):.1f}MB"
160
+
161
+
162
+ def find_placeholders(text: str) -> list[int]:
163
+ """Return placeholder ids in order of appearance (may repeat)."""
164
+ return [int(m.group(1)) for m in PLACEHOLDER_RE.finditer(text or "")]
165
+
166
+
167
+ def strip_placeholder(text: str, image_id: int) -> str:
168
+ """Remove ``[image#N]`` tokens for one id (and surrounding extra spaces)."""
169
+ raw = text or ""
170
+ out = re.sub(
171
+ rf"\s*\[image#{int(image_id)}\]",
172
+ "",
173
+ raw,
174
+ flags=re.IGNORECASE,
175
+ )
176
+ # Collapse leftover double-spaces but keep intentional newlines out of scope
177
+ # (composer is single-line input).
178
+ out = re.sub(r"[ \t]{2,}", " ", out).strip(" ")
179
+ return out
180
+
181
+
182
+ def extract_image_payloads(
183
+ content: Any,
184
+ *,
185
+ max_images: int = 8,
186
+ max_bytes: int = 4_000_000,
187
+ ) -> list[tuple[bytes, str]]:
188
+ """Pull (raw_bytes, mime) pairs from multimodal user content blocks.
189
+
190
+ Supports OpenAI ``image_url`` data-URLs and Anthropic ``image`` base64
191
+ sources. Oversized / invalid blocks are skipped.
192
+ """
193
+ if not isinstance(content, list):
194
+ return []
195
+ out: list[tuple[bytes, str]] = []
196
+ for block in content:
197
+ if len(out) >= max_images:
198
+ break
199
+ if not isinstance(block, dict):
200
+ continue
201
+ btype = str(block.get("type") or "").casefold()
202
+ try:
203
+ if btype == "image_url":
204
+ url_obj = block.get("image_url")
205
+ url = ""
206
+ if isinstance(url_obj, dict):
207
+ url = str(url_obj.get("url") or "")
208
+ elif isinstance(url_obj, str):
209
+ url = url_obj
210
+ parsed = _decode_data_url(url)
211
+ if parsed is None:
212
+ continue
213
+ data, mime = parsed
214
+ if 0 < len(data) <= max_bytes:
215
+ out.append((data, mime))
216
+ elif btype == "image":
217
+ # Anthropic native / LC-style
218
+ src = block.get("source")
219
+ if isinstance(src, dict) and str(src.get("type") or "") == "base64":
220
+ mime = str(src.get("media_type") or src.get("mime_type") or "image/png")
221
+ raw_b64 = str(src.get("data") or "")
222
+ data = base64.standard_b64decode(raw_b64)
223
+ if 0 < len(data) <= max_bytes:
224
+ out.append((data, "image/jpeg" if mime == "image/jpg" else mime))
225
+ elif block.get("base64"):
226
+ mime = str(block.get("mime_type") or block.get("media_type") or "image/png")
227
+ data = base64.standard_b64decode(str(block.get("base64")))
228
+ if 0 < len(data) <= max_bytes:
229
+ out.append((data, "image/jpeg" if mime == "image/jpg" else mime))
230
+ except Exception: # noqa: BLE001
231
+ continue
232
+ return out
233
+
234
+
235
+ def _decode_data_url(url: str) -> tuple[bytes, str] | None:
236
+ raw = (url or "").strip()
237
+ if not raw.startswith("data:") or ";base64," not in raw:
238
+ return None
239
+ header, b64 = raw.split(";base64,", 1)
240
+ mime = header[5:] if header.startswith("data:") else "image/png"
241
+ mime = (mime or "image/png").strip().lower() or "image/png"
242
+ if mime == "image/jpg":
243
+ mime = "image/jpeg"
244
+ try:
245
+ data = base64.standard_b64decode(b64)
246
+ except Exception: # noqa: BLE001
247
+ return None
248
+ if not data:
249
+ return None
250
+ return data, mime
251
+
252
+
253
+ def insert_at(text: str, index: int, token: str) -> tuple[str, int]:
254
+ """Insert token at index; return (new_text, new_cursor)."""
255
+ raw = text or ""
256
+ pos = max(0, min(int(index), len(raw)))
257
+ out = raw[:pos] + token + raw[pos:]
258
+ return out, pos + len(token)
259
+
260
+
261
+ def compose_user_content(
262
+ text: str,
263
+ bank: ImageBank | None = None,
264
+ *,
265
+ attachments: list[Attachment] | None = None,
266
+ provider: str = "openai",
267
+ ) -> str | list[dict[str, Any]]:
268
+ """Build message content from prompt text + image bank or explicit list.
269
+
270
+ - ``attachments=[]`` / no images -> plain string (legacy path).
271
+ - ``attachments`` with ``[image#N]`` in text -> interleave at placeholders.
272
+ - ``attachments`` without placeholders -> text then images.
273
+ - ``bank``: resolve placeholders from bank.
274
+ """
275
+ raw = text or ""
276
+ if attachments is not None:
277
+ if not attachments:
278
+ return raw
279
+ return _compose_with_attachments(raw, attachments, provider=provider)
280
+ if bank is not None:
281
+ return _compose_from_placeholders(raw, bank, provider=provider)
282
+ return raw
283
+
284
+
285
+ def _bank_from_attachments(attachments: list[Attachment]) -> ImageBank:
286
+ """Build a temporary bank keyed by attachment id (for placeholder resolve)."""
287
+ bank = ImageBank()
288
+ for att in attachments:
289
+ bank.items[int(att.id)] = att
290
+ if bank.items:
291
+ bank._next_id = max(bank.items) + 1
292
+ return bank
293
+
294
+
295
+ def _compose_with_attachments(
296
+ text: str,
297
+ attachments: list[Attachment],
298
+ *,
299
+ provider: str,
300
+ ) -> str | list[dict[str, Any]]:
301
+ """Prefer placeholder interleave; otherwise append images after text."""
302
+ raw = text or ""
303
+ if find_placeholders(raw):
304
+ return _compose_from_placeholders(
305
+ raw, _bank_from_attachments(attachments), provider=provider
306
+ )
307
+ return _compose_ordered_blocks(raw, attachments, provider=provider)
308
+
309
+
310
+ def _compose_ordered_blocks(
311
+ text: str,
312
+ attachments: list[Attachment],
313
+ *,
314
+ provider: str,
315
+ ) -> str | list[dict[str, Any]]:
316
+ """Text (or default caption) followed by images — no placeholder tokens."""
317
+ raw = text or ""
318
+ # Drop stale placeholders if present without a matching interleave path.
319
+ cleaned = PLACEHOLDER_RE.sub("", raw)
320
+ cleaned = " ".join(cleaned.split()).strip()
321
+ blocks: list[dict[str, Any]] = []
322
+ if cleaned:
323
+ blocks.append({"type": "text", "text": cleaned})
324
+ elif attachments:
325
+ blocks.append({"type": "text", "text": "(see attached image)"})
326
+ for att in attachments:
327
+ blocks.append(_image_block(att, provider=provider))
328
+ return blocks
329
+
330
+
331
+ def _compose_from_placeholders(
332
+ text: str,
333
+ bank: ImageBank,
334
+ *,
335
+ provider: str,
336
+ ) -> str | list[dict[str, Any]]:
337
+ raw = text or ""
338
+ ids = find_placeholders(raw)
339
+ if not ids:
340
+ return raw
341
+
342
+ missing = sorted({i for i in ids if i not in bank.items})
343
+ if missing:
344
+ raise AttachmentError(
345
+ "missing images for placeholders: "
346
+ + ", ".join(f"[image#{i}]" for i in missing)
347
+ )
348
+
349
+ blocks: list[dict[str, Any]] = []
350
+ pos = 0
351
+ for m in PLACEHOLDER_RE.finditer(raw):
352
+ if m.start() > pos:
353
+ seg = raw[pos : m.start()]
354
+ if seg:
355
+ blocks.append({"type": "text", "text": seg})
356
+ att = bank.items[int(m.group(1))]
357
+ blocks.append(_image_block(att, provider=provider))
358
+ pos = m.end()
359
+ if pos < len(raw):
360
+ tail = raw[pos:]
361
+ if tail:
362
+ blocks.append({"type": "text", "text": tail})
363
+
364
+ if not any(b.get("type") == "text" and str(b.get("text") or "").strip() for b in blocks):
365
+ blocks.insert(0, {"type": "text", "text": "(see attached image)"})
366
+ return blocks
367
+
368
+
369
+ def normalize_provider_family(provider: str | None) -> str:
370
+ """Map provider / model prefix to a content-block family.
371
+
372
+ Families:
373
+ - ``anthropic``: Anthropic Messages image source blocks
374
+ - ``google``: Google GenAI / Vertex (accepts OpenAI-style image_url)
375
+ - ``openai``: OpenAI Chat Completions image_url (default for most gateways)
376
+ """
377
+ raw = (provider or "openai").strip().lower()
378
+ if not raw:
379
+ return "openai"
380
+ # Allow full model ids like ``anthropic:claude-...``.
381
+ prefix = raw.split(":", 1)[0].strip() if ":" in raw else raw
382
+ if prefix in {"anthropic", "claude"} or prefix.startswith("anthropic"):
383
+ return "anthropic"
384
+ if prefix in {
385
+ "google",
386
+ "google_genai",
387
+ "google_vertexai",
388
+ "gemini",
389
+ "vertexai",
390
+ } or prefix.startswith("google"):
391
+ return "google"
392
+ # openai / azure_openai / deepseek / groq / together / openrouter / ...
393
+ return "openai"
394
+
395
+
396
+ def _image_block(att: Attachment, *, provider: str) -> dict[str, Any]:
397
+ """Shape an image content block for the resolved provider family.
398
+
399
+ Most OpenAI-compatible gateways (DeepSeek, Qwen, Groq, OpenRouter, ...) and
400
+ Google GenAI accept ``image_url`` data-URLs. Anthropic uses native
401
+ ``image`` + ``source.base64`` blocks.
402
+ """
403
+ family = normalize_provider_family(provider)
404
+ mime = "image/jpeg" if att.mime == "image/jpg" else att.mime
405
+ if family == "anthropic":
406
+ return {
407
+ "type": "image",
408
+ "source": {
409
+ "type": "base64",
410
+ "media_type": mime,
411
+ "data": base64.standard_b64encode(att.data).decode("ascii"),
412
+ },
413
+ }
414
+ # openai-compatible + google_genai
415
+ return {
416
+ "type": "image_url",
417
+ "image_url": {"url": att.data_url()},
418
+ }
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Clipboard
423
+ # ---------------------------------------------------------------------------
424
+
425
+
426
+ @dataclass(frozen=True, slots=True)
427
+ class ClipboardResult:
428
+ kind: Literal["text", "image", "empty", "error"]
429
+ text: str | None = None
430
+ data: bytes | None = None
431
+ mime: str | None = None
432
+ name: str | None = None
433
+ detail: str | None = None
434
+
435
+
436
+ def read_clipboard() -> ClipboardResult:
437
+ """Best-effort clipboard read: prefer image, else text.
438
+
439
+ Order:
440
+ 1. Platform image (Windows Forms / PIL / xclip / pngpaste)
441
+ 2. Platform text
442
+ 3. If text is an existing image path -> treat as image file
443
+ """
444
+ img = _read_clipboard_image()
445
+ if img is not None:
446
+ data, mime, name = img
447
+ if data:
448
+ return ClipboardResult(kind="image", data=data, mime=mime, name=name)
449
+
450
+ text = _read_clipboard_text()
451
+ if text is None or text == "":
452
+ return ClipboardResult(kind="empty", detail="clipboard empty")
453
+
454
+ # Path-to-image convenience: copied file path in explorer / terminal.
455
+ stripped = text.strip().strip('"').strip("'")
456
+ try:
457
+ p = Path(stripped).expanduser()
458
+ if p.is_file() and p.suffix.lower() in EXT_TO_MIME:
459
+ data = p.read_bytes()
460
+ return ClipboardResult(
461
+ kind="image",
462
+ data=data,
463
+ mime=EXT_TO_MIME[p.suffix.lower()],
464
+ name=p.name,
465
+ )
466
+ except OSError:
467
+ pass
468
+
469
+ return ClipboardResult(kind="text", text=text)
470
+
471
+
472
+ def _read_clipboard_text() -> str | None:
473
+ # 1) Windows: CF_UNICODETEXT via ctypes
474
+ if os.name == "nt":
475
+ t = _win_clipboard_text()
476
+ if t is not None:
477
+ return t
478
+ t = _ps_clipboard_text()
479
+ if t is not None:
480
+ return t
481
+ # 2) macOS
482
+ if sys_platform() == "darwin":
483
+ out = _run_capture(["pbpaste"])
484
+ if out is not None:
485
+ return out.decode("utf-8", errors="replace")
486
+ # 3) Linux
487
+ for cmd in (
488
+ ["wl-paste", "-n"],
489
+ ["xclip", "-selection", "clipboard", "-o"],
490
+ ["xsel", "--clipboard", "--output"],
491
+ ):
492
+ out = _run_capture(cmd)
493
+ if out is not None:
494
+ return out.decode("utf-8", errors="replace")
495
+ return None
496
+
497
+
498
+ def _read_clipboard_image() -> tuple[bytes, str, str] | None:
499
+ # Prefer PIL if available (cross-platform).
500
+ grabbed = _pil_clipboard_image()
501
+ if grabbed is not None:
502
+ return grabbed
503
+
504
+ if os.name == "nt":
505
+ grabbed = _ps_clipboard_image()
506
+ if grabbed is not None:
507
+ return grabbed
508
+
509
+ if sys_platform() == "darwin":
510
+ grabbed = _mac_clipboard_image()
511
+ if grabbed is not None:
512
+ return grabbed
513
+
514
+ if sys_platform().startswith("linux"):
515
+ grabbed = _linux_clipboard_image()
516
+ if grabbed is not None:
517
+ return grabbed
518
+ return None
519
+
520
+
521
+ def sys_platform() -> str:
522
+ return sys.platform
523
+
524
+
525
+ def _pil_clipboard_image() -> tuple[bytes, str, str] | None:
526
+ try:
527
+ from io import BytesIO
528
+
529
+ from PIL import ImageGrab # type: ignore[import-not-found]
530
+ except Exception: # noqa: BLE001
531
+ return None
532
+ try:
533
+ im = ImageGrab.grabclipboard()
534
+ except Exception: # noqa: BLE001
535
+ return None
536
+ if im is None:
537
+ return None
538
+ # ImageGrab may return a list of file paths
539
+ if isinstance(im, list):
540
+ for item in im:
541
+ try:
542
+ p = Path(str(item))
543
+ if p.is_file() and p.suffix.lower() in EXT_TO_MIME:
544
+ return p.read_bytes(), EXT_TO_MIME[p.suffix.lower()], p.name
545
+ except OSError:
546
+ continue
547
+ return None
548
+ try:
549
+ buf = BytesIO()
550
+ # Normalize to PNG for broad model support.
551
+ if getattr(im, "mode", "") not in {"RGB", "RGBA"}:
552
+ im = im.convert("RGBA")
553
+ im.save(buf, format="PNG")
554
+ return buf.getvalue(), "image/png", "clipboard.png"
555
+ except Exception: # noqa: BLE001
556
+ return None
557
+
558
+
559
+ def _win_clipboard_text() -> str | None:
560
+ try:
561
+ import ctypes
562
+ except Exception: # noqa: BLE001
563
+ return None
564
+
565
+ user32 = ctypes.windll.user32 # type: ignore[attr-defined]
566
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
567
+ CF_UNICODETEXT = 13
568
+ if not user32.OpenClipboard(None):
569
+ return None
570
+ try:
571
+ handle = user32.GetClipboardData(CF_UNICODETEXT)
572
+ if not handle:
573
+ return None
574
+ ptr = kernel32.GlobalLock(handle)
575
+ if not ptr:
576
+ return None
577
+ try:
578
+ data = ctypes.wstring_at(ptr)
579
+ return data
580
+ finally:
581
+ kernel32.GlobalUnlock(handle)
582
+ except Exception: # noqa: BLE001
583
+ return None
584
+ finally:
585
+ user32.CloseClipboard()
586
+
587
+
588
+ def _ps_clipboard_text() -> str | None:
589
+ script = (
590
+ "Add-Type -AssemblyName System.Windows.Forms; "
591
+ "[System.Windows.Forms.Clipboard]::GetText()"
592
+ )
593
+ out = _run_capture(
594
+ ["powershell", "-NoProfile", "-STA", "-Command", script],
595
+ timeout=5,
596
+ )
597
+ if out is None:
598
+ return None
599
+ return out.decode("utf-8", errors="replace")
600
+
601
+
602
+ def _ps_clipboard_image() -> tuple[bytes, str, str] | None:
603
+ """Windows: System.Windows.Forms.Clipboard.GetImage -> temp PNG."""
604
+ tmp = Path(tempfile.gettempdir()) / f"coding-agent-clip-{os.getpid()}.png"
605
+ # Remove stale
606
+ try:
607
+ if tmp.is_file():
608
+ tmp.unlink()
609
+ except OSError:
610
+ pass
611
+ script = f"""
612
+ Add-Type -AssemblyName System.Windows.Forms
613
+ Add-Type -AssemblyName System.Drawing
614
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
615
+ if ($null -eq $img) {{ exit 2 }}
616
+ $img.Save('{str(tmp).replace("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
617
+ $img.Dispose()
618
+ """
619
+ try:
620
+ proc = subprocess.run(
621
+ ["powershell", "-NoProfile", "-STA", "-Command", script],
622
+ capture_output=True,
623
+ timeout=8,
624
+ check=False,
625
+ )
626
+ except Exception: # noqa: BLE001
627
+ return None
628
+ if proc.returncode != 0 or not tmp.is_file():
629
+ return None
630
+ try:
631
+ data = tmp.read_bytes()
632
+ except OSError:
633
+ return None
634
+ finally:
635
+ try:
636
+ tmp.unlink(missing_ok=True)
637
+ except OSError:
638
+ pass
639
+ if not data:
640
+ return None
641
+ return data, "image/png", "clipboard.png"
642
+
643
+
644
+ def _mac_clipboard_image() -> tuple[bytes, str, str] | None:
645
+ tmp = Path(tempfile.gettempdir()) / f"coding-agent-clip-{os.getpid()}.png"
646
+ try:
647
+ if tmp.is_file():
648
+ tmp.unlink()
649
+ except OSError:
650
+ pass
651
+ # pngpaste is optional; osascript fallback is heavy — try pngpaste only.
652
+ out = _run_capture(["pngpaste", str(tmp)], timeout=5)
653
+ if out is None and not tmp.is_file():
654
+ return None
655
+ if not tmp.is_file():
656
+ return None
657
+ try:
658
+ data = tmp.read_bytes()
659
+ except OSError:
660
+ return None
661
+ finally:
662
+ try:
663
+ tmp.unlink(missing_ok=True)
664
+ except OSError:
665
+ pass
666
+ if not data:
667
+ return None
668
+ return data, "image/png", "clipboard.png"
669
+
670
+
671
+ def _linux_clipboard_image() -> tuple[bytes, str, str] | None:
672
+ for cmd in (
673
+ ["wl-paste", "-t", "image/png"],
674
+ ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
675
+ ):
676
+ out = _run_capture(cmd, timeout=5)
677
+ if out:
678
+ return out, "image/png", "clipboard.png"
679
+ return None
680
+
681
+
682
+ def _run_capture(
683
+ cmd: list[str],
684
+ *,
685
+ timeout: float = 3.0,
686
+ ) -> bytes | None:
687
+ try:
688
+ proc = subprocess.run(
689
+ cmd,
690
+ capture_output=True,
691
+ timeout=timeout,
692
+ check=False,
693
+ )
694
+ except Exception: # noqa: BLE001
695
+ return None
696
+ if proc.returncode != 0:
697
+ return None
698
+ return proc.stdout if proc.stdout else None
699
+
700
+
701
+ def provider_from_settings(settings: Any | None) -> str:
702
+ """Resolve content-block provider from settings.model prefix.
703
+
704
+ Prefer explicit ``init_chat_model``-style prefixes:
705
+ ``openai:...``, ``anthropic:...``, ``google_genai:...``, ``azure_openai:...``.
706
+
707
+ Falls back to openai-compatible blocks. Does **not** treat bare ``claude``
708
+ substrings inside an ``openai:`` model id as Anthropic (common gateway case).
709
+ """
710
+ if settings is None:
711
+ return "openai"
712
+
713
+ candidates: list[str] = []
714
+ for attr in ("model", "active_model"):
715
+ val = str(getattr(settings, attr, "") or "").strip()
716
+ if val:
717
+ candidates.append(val)
718
+
719
+ for cand in candidates:
720
+ if ":" in cand:
721
+ prefix = cand.split(":", 1)[0].strip().lower()
722
+ if prefix:
723
+ return normalize_provider_family(prefix)
724
+
725
+ # Unprefixed model ids (rare): only treat clear Anthropic/Google family names.
726
+ for cand in candidates:
727
+ low = cand.lower()
728
+ if low.startswith("claude") or low.startswith("anthropic"):
729
+ return "anthropic"
730
+ if low.startswith("gemini") or low.startswith("google"):
731
+ return "google"
732
+
733
+ return "openai"