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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- 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)
|