soothe-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,471 @@
1
+ """Utilities for handling image and video media from clipboard and files."""
2
+
3
+ import base64
4
+ import io
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import shutil
9
+
10
+ # S404: subprocess needed for clipboard access via pngpaste/osascript
11
+ import subprocess # noqa: S404
12
+ import sys
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from langchain_core.messages.content import VideoContentBlock
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ IMAGE_EXTENSIONS: frozenset[str] = frozenset(
23
+ {
24
+ ".png",
25
+ ".jpg",
26
+ ".jpeg",
27
+ ".gif",
28
+ ".bmp",
29
+ ".tiff",
30
+ ".tif",
31
+ ".webp",
32
+ ".ico",
33
+ }
34
+ )
35
+ """Common image file extensions supported by PIL."""
36
+
37
+ VIDEO_EXTENSIONS: frozenset[str] = frozenset(
38
+ {
39
+ ".mp4",
40
+ ".mov",
41
+ ".avi",
42
+ ".webm",
43
+ ".m4v",
44
+ ".wmv",
45
+ }
46
+ )
47
+ """Video file extensions with validated magic-byte support."""
48
+
49
+ MAX_MEDIA_BYTES: int = 20 * 1024 * 1024
50
+ """Maximum media file size (20 MB). Keeps base64 payload under ~27 MB."""
51
+
52
+
53
+ def _get_executable(name: str) -> str | None:
54
+ """Get full path to an executable using shutil.which().
55
+
56
+ Args:
57
+ name: Name of the executable to find
58
+
59
+ Returns:
60
+ Full path to executable, or None if not found.
61
+ """
62
+ return shutil.which(name)
63
+
64
+
65
+ @dataclass
66
+ class ImageData:
67
+ """Represents a pasted image with its base64 encoding."""
68
+
69
+ base64_data: str
70
+ format: str # "png", "jpeg", etc.
71
+ placeholder: str # Display text like "[image 1]"
72
+
73
+ def to_message_content(self) -> dict:
74
+ """Convert to LangChain message content format.
75
+
76
+ Returns:
77
+ Dict with type and image_url for multimodal messages.
78
+ """
79
+ return {
80
+ "type": "image_url",
81
+ "image_url": {"url": f"data:image/{self.format};base64,{self.base64_data}"},
82
+ }
83
+
84
+
85
+ @dataclass
86
+ class VideoData:
87
+ """Represents a pasted video with its base64 encoding."""
88
+
89
+ base64_data: str
90
+ format: str # "mp4", "quicktime", etc.
91
+ placeholder: str # Display text like "[video 1]"
92
+
93
+ def to_message_content(self) -> "VideoContentBlock":
94
+ """Convert to LangChain `VideoContentBlock` format.
95
+
96
+ Returns:
97
+ `VideoContentBlock` with base64 data and mime_type.
98
+ """
99
+ from langchain_core.messages.content import create_video_block
100
+
101
+ return create_video_block(
102
+ base64=self.base64_data,
103
+ mime_type=f"video/{self.format}",
104
+ )
105
+
106
+
107
+ def get_clipboard_image() -> ImageData | None:
108
+ """Attempt to read an image from the system clipboard.
109
+
110
+ Supports macOS via `pngpaste` or `osascript`.
111
+
112
+ Returns:
113
+ ImageData if an image is found, None otherwise.
114
+ """
115
+ if sys.platform == "darwin":
116
+ return _get_macos_clipboard_image()
117
+ logger.warning(
118
+ "Clipboard image paste is not supported on %s. "
119
+ "Only macOS is currently supported. "
120
+ "You can still attach images by dragging and dropping file paths.",
121
+ sys.platform,
122
+ )
123
+ return None
124
+
125
+
126
+ def get_image_from_path(path: pathlib.Path) -> ImageData | None:
127
+ """Read and encode an image file from disk.
128
+
129
+ Args:
130
+ path: Path to the image file.
131
+
132
+ Returns:
133
+ `ImageData` when the file is a valid image, otherwise `None`.
134
+ """
135
+ from PIL import Image, UnidentifiedImageError
136
+
137
+ try:
138
+ file_size = path.stat().st_size
139
+ if file_size == 0:
140
+ logger.debug("Image file is empty: %s", path)
141
+ return None
142
+ if file_size > MAX_MEDIA_BYTES:
143
+ logger.warning(
144
+ "Image file %s is too large (%d MB, max %d MB)",
145
+ path,
146
+ file_size // (1024 * 1024),
147
+ MAX_MEDIA_BYTES // (1024 * 1024),
148
+ )
149
+ return None
150
+
151
+ image_bytes = path.read_bytes()
152
+ if not image_bytes:
153
+ return None
154
+
155
+ with Image.open(io.BytesIO(image_bytes)) as image:
156
+ image_format = (image.format or "").lower()
157
+
158
+ if image_format == "jpg":
159
+ image_format = "jpeg"
160
+ if not image_format:
161
+ suffix = path.suffix.lower().removeprefix(".")
162
+ image_format = "jpeg" if suffix == "jpg" else suffix
163
+ if not image_format:
164
+ image_format = "png"
165
+
166
+ return ImageData(
167
+ base64_data=encode_to_base64(image_bytes),
168
+ format=image_format,
169
+ placeholder="[image]",
170
+ )
171
+ except (UnidentifiedImageError, OSError) as e:
172
+ logger.debug("Failed to load image from %s: %s", path, e, exc_info=True)
173
+ return None
174
+
175
+
176
+ def _detect_video_format(data: bytes) -> str | None:
177
+ """Detect video MIME subtype from magic bytes.
178
+
179
+ Args:
180
+ data: Raw file bytes (at least 12 bytes for reliable detection).
181
+
182
+ Returns:
183
+ MIME subtype (e.g. "mp4", "webm") or `None` if unrecognized.
184
+ """
185
+ min_avi_len = 12
186
+ if data[4:8] == b"ftyp":
187
+ # ftyp box: major brand at bytes 8-12 distinguishes MOV vs MP4
188
+ brand = data[8:12]
189
+ if brand == b"qt ":
190
+ return "quicktime"
191
+ return "mp4"
192
+ if data[:4] == b"RIFF" and len(data) >= min_avi_len and data[8:12] == b"AVI ":
193
+ return "avi"
194
+ if data[:4] == b"\x30\x26\xb2\x75": # ASF/WMV
195
+ return "x-ms-wmv"
196
+ if data[:4] == b"\x1a\x45\xdf\xa3": # WebM/Matroska (EBML header)
197
+ return "webm"
198
+ return None
199
+
200
+
201
+ def get_video_from_path(path: pathlib.Path) -> VideoData | None:
202
+ """Read and encode a video file from disk.
203
+
204
+ Args:
205
+ path: Path to the video file.
206
+
207
+ Returns:
208
+ `VideoData` when the file is a valid video, otherwise `None`.
209
+ """
210
+ suffix = path.suffix.lower()
211
+ if suffix not in VIDEO_EXTENSIONS:
212
+ return None
213
+
214
+ try:
215
+ file_size = path.stat().st_size
216
+ if file_size == 0:
217
+ logger.debug("Video file is empty: %s", path)
218
+ return None
219
+ if file_size > MAX_MEDIA_BYTES:
220
+ logger.warning(
221
+ "Video file %s is too large (%d MB, max %d MB)",
222
+ path,
223
+ file_size // (1024 * 1024),
224
+ MAX_MEDIA_BYTES // (1024 * 1024),
225
+ )
226
+ return None
227
+
228
+ video_bytes = path.read_bytes()
229
+
230
+ # Validate it's a real video file by checking magic bytes
231
+ # MP4 starts with ftyp, MOV also uses ftyp, AVI starts with RIFF
232
+ min_video_len = 8
233
+ if len(video_bytes) < min_video_len:
234
+ logger.debug("Video file too small (%d bytes): %s", len(video_bytes), path)
235
+ return None
236
+
237
+ # Detect format from magic bytes (not extension) so renamed files
238
+ # get the correct MIME type.
239
+ detected_format = _detect_video_format(video_bytes)
240
+ if detected_format is None:
241
+ logger.warning(
242
+ "Video file %s has unrecognized signature for extension '%s'; "
243
+ "skipping. If this is a valid video, the format may not be "
244
+ "supported yet.",
245
+ path,
246
+ suffix,
247
+ )
248
+ return None
249
+
250
+ return VideoData(
251
+ base64_data=encode_to_base64(video_bytes),
252
+ format=detected_format,
253
+ placeholder="[video]",
254
+ )
255
+ except OSError as e:
256
+ logger.warning("Failed to load video from %s: %s", path, e, exc_info=True)
257
+ return None
258
+
259
+
260
+ def get_media_from_path(path: pathlib.Path) -> ImageData | VideoData | None:
261
+ """Try to load a file as an image first, then as a video.
262
+
263
+ Args:
264
+ path: Path to the media file.
265
+
266
+ Returns:
267
+ `ImageData` or `VideoData` if the file is valid media, otherwise `None`.
268
+ """
269
+ result: ImageData | VideoData | None = get_image_from_path(path)
270
+ if result is not None:
271
+ return result
272
+ return get_video_from_path(path)
273
+
274
+
275
+ def _get_macos_clipboard_image() -> ImageData | None:
276
+ """Get clipboard image on macOS using pngpaste or osascript.
277
+
278
+ First tries pngpaste (faster if installed), then falls back to osascript.
279
+
280
+ Returns:
281
+ ImageData if an image is found, None otherwise.
282
+ """
283
+ from PIL import Image, UnidentifiedImageError
284
+
285
+ # Try pngpaste first (fast if installed)
286
+ pngpaste_path = _get_executable("pngpaste")
287
+ if pngpaste_path:
288
+ try:
289
+ # S603: pngpaste_path is validated via shutil.which(), args are hardcoded
290
+ result = subprocess.run( # noqa: S603
291
+ [pngpaste_path, "-"],
292
+ capture_output=True,
293
+ check=False,
294
+ timeout=2,
295
+ )
296
+ if result.returncode == 0 and result.stdout:
297
+ # Successfully got PNG data - validate it's a real image
298
+ try:
299
+ Image.open(io.BytesIO(result.stdout))
300
+ base64_data = base64.b64encode(result.stdout).decode("utf-8")
301
+ return ImageData(
302
+ base64_data=base64_data,
303
+ format="png", # 'pngpaste -' always outputs PNG
304
+ placeholder="[image]",
305
+ )
306
+ except (
307
+ # UnidentifiedImageError: corrupted or non-image data
308
+ UnidentifiedImageError,
309
+ OSError, # OSError: I/O errors during image processing
310
+ ) as e:
311
+ logger.debug("Invalid image data from pngpaste: %s", e, exc_info=True)
312
+ except FileNotFoundError:
313
+ # pngpaste not installed - expected on systems without it
314
+ logger.debug("pngpaste not found, falling back to osascript")
315
+ except subprocess.TimeoutExpired:
316
+ logger.debug("pngpaste timed out after 2 seconds")
317
+
318
+ # Fallback to osascript with temp file (built-in but slower)
319
+ return _get_clipboard_via_osascript()
320
+
321
+
322
+ def _get_clipboard_via_osascript() -> ImageData | None:
323
+ """Get clipboard image via osascript using a temp file.
324
+
325
+ osascript outputs data in a special format that can't be captured as raw binary,
326
+ so we write to a temp file instead.
327
+
328
+ Returns:
329
+ ImageData if an image is found, None otherwise.
330
+ """
331
+ from PIL import Image, UnidentifiedImageError
332
+
333
+ # Get osascript path - it's a macOS builtin so should always exist
334
+ osascript_path = _get_executable("osascript")
335
+ if not osascript_path:
336
+ return None
337
+
338
+ # Create a temp file for the image
339
+ fd, temp_path = tempfile.mkstemp(suffix=".png")
340
+ os.close(fd)
341
+
342
+ try:
343
+ # First check if clipboard has PNG data
344
+ # S603: osascript_path is validated via shutil.which(), args are hardcoded
345
+ check_result = subprocess.run( # noqa: S603
346
+ [osascript_path, "-e", "clipboard info"],
347
+ capture_output=True,
348
+ check=False,
349
+ timeout=2,
350
+ text=True,
351
+ )
352
+
353
+ if check_result.returncode != 0:
354
+ return None
355
+
356
+ # Check for PNG or TIFF in clipboard info
357
+ clipboard_info = check_result.stdout.lower()
358
+ if "pngf" not in clipboard_info and "tiff" not in clipboard_info:
359
+ return None
360
+
361
+ # Try to get PNG first, fall back to TIFF
362
+ if "pngf" in clipboard_info:
363
+ get_script = f"""
364
+ set pngData to the clipboard as «class PNGf»
365
+ set theFile to open for access POSIX file "{temp_path}" with write permission
366
+ write pngData to theFile
367
+ close access theFile
368
+ return "success"
369
+ """ # noqa: E501
370
+ else:
371
+ get_script = f"""
372
+ set tiffData to the clipboard as TIFF picture
373
+ set theFile to open for access POSIX file "{temp_path}" with write permission
374
+ write tiffData to theFile
375
+ close access theFile
376
+ return "success"
377
+ """ # noqa: E501
378
+
379
+ # S603: osascript_path validated via shutil.which(), script is internal
380
+ result = subprocess.run( # noqa: S603
381
+ [osascript_path, "-e", get_script],
382
+ capture_output=True,
383
+ check=False,
384
+ timeout=3,
385
+ text=True,
386
+ )
387
+
388
+ if result.returncode != 0 or "success" not in result.stdout:
389
+ return None
390
+
391
+ # Check if file was created and has content
392
+ if not pathlib.Path(temp_path).exists() or pathlib.Path(temp_path).stat().st_size == 0:
393
+ return None
394
+
395
+ # Read and validate the image
396
+ image_data = pathlib.Path(temp_path).read_bytes()
397
+
398
+ try:
399
+ image = Image.open(io.BytesIO(image_data))
400
+ # Convert to PNG if it's not already (e.g., if we got TIFF)
401
+ buffer = io.BytesIO()
402
+ image.save(buffer, format="PNG")
403
+ buffer.seek(0)
404
+ base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
405
+
406
+ return ImageData(
407
+ base64_data=base64_data,
408
+ format="png",
409
+ placeholder="[image]",
410
+ )
411
+ except (
412
+ # UnidentifiedImageError: corrupted or non-image data
413
+ UnidentifiedImageError,
414
+ OSError, # OSError: I/O errors during image processing
415
+ ) as e:
416
+ logger.debug("Failed to process clipboard image via osascript: %s", e, exc_info=True)
417
+ return None
418
+
419
+ except subprocess.TimeoutExpired:
420
+ logger.debug("osascript timed out while accessing clipboard")
421
+ return None
422
+ except OSError as e:
423
+ logger.debug("OSError accessing clipboard via osascript: %s", e)
424
+ return None
425
+ finally:
426
+ # Clean up temp file
427
+ try:
428
+ pathlib.Path(temp_path).unlink()
429
+ except OSError as e:
430
+ logger.debug("Failed to clean up temp file %s: %s", temp_path, e)
431
+
432
+
433
+ def encode_to_base64(data: bytes) -> str:
434
+ """Encode raw bytes to a base64 string.
435
+
436
+ Args:
437
+ data: Raw bytes to encode.
438
+
439
+ Returns:
440
+ Base64-encoded string.
441
+ """
442
+ return base64.b64encode(data).decode("utf-8")
443
+
444
+
445
+ def create_multimodal_content(
446
+ text: str, images: list[ImageData], videos: list[VideoData] | None = None
447
+ ) -> list[dict]:
448
+ """Create multimodal message content with text, images, and videos.
449
+
450
+ Args:
451
+ text: Text content of the message
452
+ images: List of ImageData objects
453
+ videos: Optional list of VideoData objects
454
+
455
+ Returns:
456
+ List of content blocks in LangChain message format.
457
+ """
458
+ content_blocks = []
459
+
460
+ # Add text block
461
+ if text.strip():
462
+ content_blocks.append({"type": "text", "text": text})
463
+
464
+ # Add image blocks
465
+ content_blocks.extend(image.to_message_content() for image in images)
466
+
467
+ # Add video blocks
468
+ if videos:
469
+ content_blocks.extend(video.to_message_content() for video in videos)
470
+
471
+ return content_blocks