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,128 @@
1
+ """Clipboard utilities for Soothe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import os
8
+ import pathlib
9
+ from typing import TYPE_CHECKING
10
+
11
+ from soothe_cli.tui.config import get_glyphs
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.app import App
17
+
18
+ _PREVIEW_MAX_LENGTH = 40
19
+
20
+
21
+ def _copy_osc52(text: str) -> None:
22
+ """Copy text using OSC 52 escape sequence (works over SSH/tmux)."""
23
+ encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
24
+ osc52_seq = f"\033]52;c;{encoded}\a"
25
+ if os.environ.get("TMUX"):
26
+ osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\"
27
+
28
+ with pathlib.Path("/dev/tty").open("w", encoding="utf-8") as tty:
29
+ tty.write(osc52_seq)
30
+ tty.flush()
31
+
32
+
33
+ def _shorten_preview(texts: list[str]) -> str:
34
+ """Shorten text for notification preview.
35
+
36
+ Returns:
37
+ Shortened preview text suitable for notification display.
38
+ """
39
+ glyphs = get_glyphs()
40
+ dense_text = glyphs.newline.join(texts).replace("\n", glyphs.newline)
41
+ if len(dense_text) > _PREVIEW_MAX_LENGTH:
42
+ return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}{glyphs.ellipsis}"
43
+ return dense_text
44
+
45
+
46
+ def copy_selection_to_clipboard(app: App) -> None:
47
+ """Copy selected text from app widgets to clipboard.
48
+
49
+ This queries all widgets for their text_selection and copies
50
+ any selected text to the system clipboard.
51
+ """
52
+ selected_texts = []
53
+
54
+ for widget in app.query("*"):
55
+ if not hasattr(widget, "text_selection") or not widget.text_selection:
56
+ continue
57
+
58
+ selection = widget.text_selection
59
+
60
+ if selection.end is None:
61
+ continue
62
+
63
+ try:
64
+ result = widget.get_selection(selection)
65
+ except (AttributeError, TypeError, ValueError, IndexError) as e:
66
+ logger.debug(
67
+ "Failed to get selection from widget %s: %s",
68
+ type(widget).__name__,
69
+ e,
70
+ exc_info=True,
71
+ )
72
+ continue
73
+
74
+ if not result:
75
+ continue
76
+
77
+ selected_text, _ = result
78
+ if selected_text.strip():
79
+ selected_texts.append(selected_text)
80
+
81
+ if not selected_texts:
82
+ return
83
+
84
+ combined_text = "\n".join(selected_texts)
85
+
86
+ # Try multiple clipboard methods
87
+ # Prefer pyperclip/app clipboard first (works reliably on local machines)
88
+ # OSC 52 is last resort (for SSH/remote where native clipboard unavailable)
89
+ copy_methods = [app.copy_to_clipboard]
90
+
91
+ # Try pyperclip if available (preferred - uses pbcopy on macOS)
92
+ try:
93
+ import pyperclip
94
+
95
+ copy_methods.insert(0, pyperclip.copy)
96
+ except ImportError:
97
+ pass
98
+
99
+ # OSC 52 as fallback for remote/SSH sessions
100
+ copy_methods.append(_copy_osc52)
101
+
102
+ for copy_fn in copy_methods:
103
+ try:
104
+ copy_fn(combined_text)
105
+ # Use markup=False to prevent copied text from being parsed as Rich markup
106
+ app.notify(
107
+ f'"{_shorten_preview(selected_texts)}" copied',
108
+ severity="information",
109
+ timeout=2,
110
+ markup=False,
111
+ )
112
+ except (OSError, RuntimeError, TypeError) as e:
113
+ logger.debug(
114
+ "Clipboard copy method %s failed: %s",
115
+ getattr(copy_fn, "__name__", repr(copy_fn)),
116
+ e,
117
+ exc_info=True,
118
+ )
119
+ continue
120
+ else:
121
+ return
122
+
123
+ # If all methods fail, still notify but warn
124
+ app.notify(
125
+ "Failed to copy - no clipboard method available",
126
+ severity="warning",
127
+ timeout=3,
128
+ )
@@ -0,0 +1,240 @@
1
+ """Enhanced diff widget for displaying unified diffs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from textual.containers import Vertical
9
+ from textual.content import Content
10
+ from textual.widgets import Static
11
+
12
+ from soothe_cli.tui import theme
13
+ from soothe_cli.tui.config import get_glyphs, is_ascii_mode
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.app import ComposeResult
17
+
18
+
19
+ def compose_diff_lines(
20
+ diff: str,
21
+ max_lines: int | None = 100,
22
+ ) -> ComposeResult:
23
+ """Yield per-line Static widgets for a unified diff.
24
+
25
+ Each added/removed line gets a CSS class (`.diff-line-added`,
26
+ `.diff-line-removed`) so background colors are driven by CSS variables
27
+ and update automatically on theme change.
28
+
29
+ Args:
30
+ diff: Unified diff string.
31
+ max_lines: Maximum number of diff lines to show (None for unlimited).
32
+
33
+ Yields:
34
+ Static widgets — one per diff line — with appropriate CSS classes.
35
+ """
36
+ if not diff:
37
+ yield Static(Content.styled("No changes detected", "dim"))
38
+ else:
39
+ yield from _compose_diff_content(diff, max_lines)
40
+
41
+
42
+ def _compose_diff_content(
43
+ diff: str,
44
+ max_lines: int | None,
45
+ ) -> ComposeResult:
46
+ """Yield styled diff line widgets for non-empty diff content.
47
+
48
+ Args:
49
+ diff: Non-empty unified diff string.
50
+ max_lines: Maximum number of diff lines to show (None for unlimited).
51
+
52
+ Yields:
53
+ Static widgets for stats header and individual diff lines.
54
+ """
55
+ colors = theme.get_theme_colors()
56
+ glyphs = get_glyphs()
57
+ lines = diff.splitlines()
58
+
59
+ # Compute stats first
60
+ additions = sum(1 for ln in lines if ln.startswith("+") and not ln.startswith("+++"))
61
+ deletions = sum(1 for ln in lines if ln.startswith("-") and not ln.startswith("---"))
62
+
63
+ # Stats header
64
+ stats_parts: list[str | tuple[str, str] | Content] = []
65
+ if additions:
66
+ stats_parts.append((f"+{additions}", colors.success))
67
+ if deletions:
68
+ if stats_parts:
69
+ stats_parts.append(" ")
70
+ stats_parts.append((f"-{deletions}", colors.error))
71
+ if stats_parts:
72
+ yield Static(Content.assemble(*stats_parts))
73
+
74
+ # Find max line number for width calculation
75
+ max_line = 0
76
+ for line in lines:
77
+ if m := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)", line):
78
+ max_line = max(max_line, int(m.group(1)), int(m.group(2)))
79
+ width = max(3, len(str(max_line + len(lines))))
80
+
81
+ old_num = new_num = 0
82
+ line_count = 0
83
+
84
+ for line in lines:
85
+ if max_lines and line_count >= max_lines:
86
+ yield Static(Content.styled(f"\n... ({len(lines) - line_count} more lines)", "dim"))
87
+ break
88
+
89
+ # Skip file headers (--- and +++)
90
+ if line.startswith(("---", "+++")):
91
+ continue
92
+
93
+ # Handle hunk headers - just update line numbers, don't display
94
+ if m := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)", line):
95
+ old_num, new_num = int(m.group(1)), int(m.group(2))
96
+ continue
97
+
98
+ # Handle diff lines - use gutter bar instead of +/- prefix
99
+ content = line[1:] if line else ""
100
+
101
+ if line.startswith("-"):
102
+ # Deletion — red gutter bar, background via CSS
103
+ yield Static(
104
+ Content.assemble(
105
+ (f"{glyphs.gutter_bar}", f"{colors.error} bold"),
106
+ (f"{old_num:>{width}}", "dim"),
107
+ f" {content}",
108
+ ),
109
+ classes="diff-line-removed",
110
+ )
111
+ old_num += 1
112
+ line_count += 1
113
+ elif line.startswith("+"):
114
+ # Addition — green gutter bar, background via CSS
115
+ yield Static(
116
+ Content.assemble(
117
+ (f"{glyphs.gutter_bar}", f"{colors.success} bold"),
118
+ (f"{new_num:>{width}}", "dim"),
119
+ f" {content}",
120
+ ),
121
+ classes="diff-line-added",
122
+ )
123
+ new_num += 1
124
+ line_count += 1
125
+ elif line.startswith(" "):
126
+ # Context line — dim gutter
127
+ yield Static(
128
+ Content.assemble(
129
+ (f"{glyphs.box_vertical}{old_num:>{width}}", "dim"),
130
+ f" {content}",
131
+ ),
132
+ )
133
+ old_num += 1
134
+ new_num += 1
135
+ line_count += 1
136
+ elif line.strip() == "...":
137
+ # Truncation marker
138
+ yield Static(Content.styled("...", "dim"))
139
+ line_count += 1
140
+ else:
141
+ # Unrecognized diff line (e.g., "")
142
+ yield Static(Content.styled(line, "dim"))
143
+ line_count += 1
144
+
145
+
146
+ class EnhancedDiff(Vertical):
147
+ """Widget for displaying a unified diff with syntax highlighting."""
148
+
149
+ DEFAULT_CSS = """
150
+ EnhancedDiff {
151
+ height: auto;
152
+ padding: 1;
153
+ background: $surface-darken-1;
154
+ border: round $primary;
155
+ }
156
+
157
+ EnhancedDiff .diff-title {
158
+ color: $primary;
159
+ text-style: bold;
160
+ margin-bottom: 1;
161
+ }
162
+
163
+ EnhancedDiff .diff-content {
164
+ height: auto;
165
+ }
166
+
167
+ EnhancedDiff .diff-stats {
168
+ color: $text-muted;
169
+ margin-top: 1;
170
+ }
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ diff: str,
176
+ title: str = "Diff",
177
+ max_lines: int | None = 100,
178
+ **kwargs: Any,
179
+ ) -> None:
180
+ """Initialize the diff widget.
181
+
182
+ Args:
183
+ diff: Unified diff string
184
+ title: Title to display above the diff
185
+ max_lines: Maximum number of diff lines to show
186
+ **kwargs: Additional arguments passed to parent
187
+ """
188
+ super().__init__(**kwargs)
189
+ self._diff = diff
190
+ self._title = title
191
+ self._max_lines = max_lines
192
+ self._stats = self._compute_stats()
193
+
194
+ def _compute_stats(self) -> tuple[int, int]:
195
+ """Compute additions and deletions count.
196
+
197
+ Returns:
198
+ Tuple of (additions count, deletions count).
199
+ """
200
+ additions = 0
201
+ deletions = 0
202
+ for line in self._diff.splitlines():
203
+ if line.startswith("+") and not line.startswith("+++"):
204
+ additions += 1
205
+ elif line.startswith("-") and not line.startswith("---"):
206
+ deletions += 1
207
+ return additions, deletions
208
+
209
+ def on_mount(self) -> None:
210
+ """Set border style based on charset mode."""
211
+ if is_ascii_mode():
212
+ colors = theme.get_theme_colors(self)
213
+ self.styles.border = ("ascii", colors.primary)
214
+
215
+ def compose(self) -> ComposeResult:
216
+ """Compose the diff widget layout.
217
+
218
+ Yields:
219
+ Widgets for title, formatted diff content, and stats.
220
+ """
221
+ colors = theme.get_theme_colors(self)
222
+ glyphs = get_glyphs()
223
+ h = glyphs.box_double_horizontal
224
+ yield Static(
225
+ Content.styled(f"{h}{h}{h} {self._title} {h}{h}{h}", f"bold {colors.primary}"),
226
+ classes="diff-title",
227
+ )
228
+
229
+ yield from compose_diff_lines(self._diff, self._max_lines)
230
+
231
+ additions, deletions = self._stats
232
+ if additions or deletions:
233
+ content_parts: list[str | tuple[str, str]] = []
234
+ if additions:
235
+ content_parts.append((f"+{additions}", colors.success))
236
+ if deletions:
237
+ if content_parts:
238
+ content_parts.append(" ")
239
+ content_parts.append((f"-{deletions}", colors.error))
240
+ yield Static(Content.assemble(*content_parts), classes="diff-stats")
@@ -0,0 +1,140 @@
1
+ """External editor support for composing prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import subprocess # noqa: S404
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ GUI_WAIT_FLAG: dict[str, str] = {
17
+ "code": "--wait",
18
+ "cursor": "--wait",
19
+ "zed": "--wait",
20
+ "atom": "--wait",
21
+ "subl": "-w",
22
+ "windsurf": "--wait",
23
+ }
24
+ """Mapping of GUI editor base names to their blocking flag."""
25
+
26
+ VIM_EDITORS = {"vi", "vim", "nvim"}
27
+ """Set of vim-family editor base names that receive the `-i NONE` flag."""
28
+
29
+
30
+ def resolve_editor() -> list[str] | None:
31
+ """Resolve editor command from environment.
32
+
33
+ Checks $VISUAL, then $EDITOR, then falls back to platform default.
34
+
35
+ Returns:
36
+ Tokenized command list, or `None` if the env var was set but empty after
37
+ tokenization.
38
+ """
39
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
40
+ if not editor:
41
+ if sys.platform == "win32":
42
+ return ["notepad"]
43
+ return ["vi"]
44
+ tokens = shlex.split(editor)
45
+ return tokens or None
46
+
47
+
48
+ def _prepare_command(cmd: list[str], filepath: str) -> list[str]:
49
+ """Build the full command list with appropriate flags.
50
+
51
+ Adds --wait/-w for GUI editors and `-i NONE` for vim-family editors.
52
+
53
+ Returns:
54
+ The complete command list with flags and filepath appended.
55
+ """
56
+ cmd = list(cmd) # copy
57
+ exe = Path(cmd[0]).stem.lower()
58
+
59
+ # Auto-inject wait flag for GUI editors
60
+ if exe in GUI_WAIT_FLAG:
61
+ flag = GUI_WAIT_FLAG[exe]
62
+ if flag not in cmd:
63
+ cmd.insert(1, flag)
64
+
65
+ # Vim workaround: avoid viminfo errors in temp environments
66
+ if exe in VIM_EDITORS and "-i" not in cmd:
67
+ cmd.extend(["-i", "NONE"])
68
+
69
+ cmd.append(filepath)
70
+ return cmd
71
+
72
+
73
+ def open_in_editor(current_text: str) -> str | None:
74
+ """Open current_text in an external editor.
75
+
76
+ Creates a temp .md file, launches the editor, and reads back the result.
77
+
78
+ Args:
79
+ current_text: The text to pre-populate in the editor.
80
+
81
+ Returns:
82
+ The edited text with normalized line endings, or `None` if the editor
83
+ exited with a non-zero status, was not found, or the result was
84
+ empty/whitespace-only.
85
+ """
86
+ cmd = resolve_editor()
87
+ if cmd is None:
88
+ return None
89
+
90
+ tmp_path: str | None = None
91
+ try:
92
+ with tempfile.NamedTemporaryFile(
93
+ suffix=".md",
94
+ prefix="Soothe-edit-",
95
+ delete=False,
96
+ mode="w",
97
+ encoding="utf-8",
98
+ ) as tmp:
99
+ tmp_path = tmp.name
100
+ tmp.write(current_text)
101
+
102
+ full_cmd = _prepare_command(cmd, tmp_path)
103
+
104
+ # S603: editor command comes from user's own $EDITOR env var
105
+ result = subprocess.run( # noqa: S603
106
+ full_cmd,
107
+ stdin=sys.stdin,
108
+ stdout=sys.stdout,
109
+ stderr=sys.stderr,
110
+ check=False,
111
+ )
112
+ if result.returncode != 0:
113
+ logger.warning("Editor exited with code %d: %s", result.returncode, full_cmd)
114
+ return None
115
+
116
+ edited = Path(tmp_path).read_text(encoding="utf-8")
117
+
118
+ # Normalize line endings
119
+ edited = edited.replace("\r\n", "\n").replace("\r", "\n")
120
+
121
+ # Most editors append a final newline on save (POSIX convention).
122
+ # Strip exactly one so the cursor lands on content, not a blank line,
123
+ # while preserving any intentional trailing newlines the user added.
124
+ edited = edited.removesuffix("\n")
125
+
126
+ # Treat empty result as cancellation
127
+ if not edited.strip():
128
+ return None
129
+
130
+ except FileNotFoundError:
131
+ return None
132
+ except Exception:
133
+ logger.warning("Editor failed", exc_info=True)
134
+ return None
135
+ else:
136
+ return edited
137
+ finally:
138
+ if tmp_path is not None:
139
+ with contextlib.suppress(OSError):
140
+ Path(tmp_path).unlink(missing_ok=True)