ripperdoc 0.3.0__py3-none-any.whl → 0.3.2__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +9 -1
- ripperdoc/cli/commands/agents_cmd.py +93 -53
- ripperdoc/cli/commands/mcp_cmd.py +3 -0
- ripperdoc/cli/commands/models_cmd.py +768 -283
- ripperdoc/cli/commands/permissions_cmd.py +107 -52
- ripperdoc/cli/commands/resume_cmd.py +61 -51
- ripperdoc/cli/commands/themes_cmd.py +31 -1
- ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
- ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
- ripperdoc/cli/ui/choice.py +376 -0
- ripperdoc/cli/ui/interrupt_listener.py +233 -0
- ripperdoc/cli/ui/message_display.py +7 -0
- ripperdoc/cli/ui/models_tui/__init__.py +5 -0
- ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
- ripperdoc/cli/ui/panels.py +19 -4
- ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
- ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
- ripperdoc/cli/ui/provider_options.py +220 -80
- ripperdoc/cli/ui/rich_ui.py +91 -83
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +66 -104
- ripperdoc/core/providers/anthropic.py +11 -0
- ripperdoc/protocol/stdio.py +3 -1
- ripperdoc/tools/bash_tool.py +2 -0
- ripperdoc/tools/file_edit_tool.py +100 -181
- ripperdoc/tools/file_read_tool.py +101 -25
- ripperdoc/tools/multi_edit_tool.py +239 -91
- ripperdoc/tools/notebook_edit_tool.py +11 -29
- ripperdoc/utils/file_editing.py +164 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
- ripperdoc/cli/ui/interrupt_handler.py +0 -208
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Unified choice UI component for Ripperdoc.
|
|
2
|
+
|
|
3
|
+
This module provides a reusable, visually consistent choice interface
|
|
4
|
+
that can be used across onboarding, permission prompts, and other
|
|
5
|
+
user interactions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import shutil
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from prompt_toolkit.filters import is_done
|
|
15
|
+
from prompt_toolkit.formatted_text import HTML, fragment_list_to_text, to_formatted_text
|
|
16
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
17
|
+
from prompt_toolkit.shortcuts import choice
|
|
18
|
+
from prompt_toolkit.styles import Style
|
|
19
|
+
from prompt_toolkit.utils import get_cwidth
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Shared style system for all choice prompts
|
|
23
|
+
def _choice_style() -> Style:
|
|
24
|
+
"""Create the unified style for choice prompts.
|
|
25
|
+
|
|
26
|
+
Uses a golden/amber theme with cyan accents for consistent branding.
|
|
27
|
+
"""
|
|
28
|
+
return Style.from_dict(
|
|
29
|
+
{
|
|
30
|
+
"frame.border": "#d4a017", # Golden/amber border
|
|
31
|
+
"selected-option": "bold",
|
|
32
|
+
"option": "#5fd7ff", # Cyan for unselected options
|
|
33
|
+
"title": "#ffaf00", # Orange/amber for titles
|
|
34
|
+
"description": "#ffffff", # White for descriptions
|
|
35
|
+
"question": "#ffd700", # Gold for questions
|
|
36
|
+
"label": "#87afff", # Light blue for field labels
|
|
37
|
+
"warning": "#ff5555", # Red for warnings
|
|
38
|
+
"info": "#5fd7ff", # Cyan for info text
|
|
39
|
+
"dim": "#626262", # Dimmed text
|
|
40
|
+
"yes-option": "#ffffff", # Neutral for Yes options
|
|
41
|
+
"no-option": "#ffffff", # Neutral for No options
|
|
42
|
+
"value": "#f8f8f2", # Off-white for values
|
|
43
|
+
"default": "#50fa7b", # Green for defaults
|
|
44
|
+
"marker": "#ffb86c", # Orange for markers (→, etc.)
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def onboarding_style() -> Style:
|
|
50
|
+
"""Create the style for onboarding prompts.
|
|
51
|
+
|
|
52
|
+
Uses a balanced theme with purple accents for a modern setup experience.
|
|
53
|
+
"""
|
|
54
|
+
return Style.from_dict(
|
|
55
|
+
{
|
|
56
|
+
"frame.border": "#8b9dc3", # Soft silver-blue border
|
|
57
|
+
"selected-option": "bold",
|
|
58
|
+
"option": "#f8f8f2", # Off-white for options (cleaner)
|
|
59
|
+
"title": "#bd93f9", # Soft purple for titles
|
|
60
|
+
"description": "#f0f0f0", # Off-white for descriptions
|
|
61
|
+
"question": "#ff79c6", # Pink for questions
|
|
62
|
+
"label": "#8be9fd", # Cyan for labels
|
|
63
|
+
"warning": "#ffb86c", # Orange for warnings
|
|
64
|
+
"info": "#8be9fd", # Cyan for info text
|
|
65
|
+
"dim": "#626262", # Dimmed text
|
|
66
|
+
"yes-option": "#ffffff",
|
|
67
|
+
"no-option": "#ffffff",
|
|
68
|
+
"value": "#f8f8f2",
|
|
69
|
+
"default": "#50fa7b", # Green for defaults
|
|
70
|
+
"marker": "#ff79c6", # Pink for markers
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def theme_style() -> Style:
|
|
76
|
+
"""Create the style for theme selection prompts.
|
|
77
|
+
|
|
78
|
+
Uses a subtle gray border for a clean, minimal appearance.
|
|
79
|
+
"""
|
|
80
|
+
return Style.from_dict(
|
|
81
|
+
{
|
|
82
|
+
"frame.border": "#626262", # Subtle gray border
|
|
83
|
+
"selected-option": "bold",
|
|
84
|
+
"option": "#f8f8f2",
|
|
85
|
+
"title": "#f8f8f2", # Off-white for titles
|
|
86
|
+
"description": "#f0f0f0",
|
|
87
|
+
"question": "#f8f8f2",
|
|
88
|
+
"label": "#8be9fd",
|
|
89
|
+
"warning": "#ffb86c",
|
|
90
|
+
"info": "#5fd7ff",
|
|
91
|
+
"dim": "#626262",
|
|
92
|
+
"yes-option": "#ffffff",
|
|
93
|
+
"no-option": "#ffffff",
|
|
94
|
+
"value": "#f8f8f2",
|
|
95
|
+
"default": "#50fa7b",
|
|
96
|
+
"marker": "#8be9fd",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ChoiceOption:
|
|
102
|
+
"""Represents a single choice option.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
value: The value to return when this option is selected
|
|
106
|
+
label: The display label (can contain HTML tags)
|
|
107
|
+
description: Optional description text
|
|
108
|
+
is_default: Whether this is the default choice
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
value: str,
|
|
114
|
+
label: str,
|
|
115
|
+
description: Optional[str] = None,
|
|
116
|
+
is_default: bool = False,
|
|
117
|
+
):
|
|
118
|
+
self.value = value
|
|
119
|
+
self.label = label
|
|
120
|
+
self.description = description
|
|
121
|
+
self.is_default = is_default
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
return f"ChoiceOption(value={self.value!r}, label={self.label!r})"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _terminal_width(default: int = 80) -> int:
|
|
128
|
+
"""Return the current terminal width with a reasonable fallback."""
|
|
129
|
+
try:
|
|
130
|
+
width = shutil.get_terminal_size(fallback=(default, 24)).columns
|
|
131
|
+
except OSError:
|
|
132
|
+
width = default
|
|
133
|
+
return max(1, width)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _count_display_lines(text: Any, terminal_width: int) -> int:
|
|
137
|
+
"""Estimate the number of lines a formatted text will occupy.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
text: The text to measure (plain or formatted)
|
|
141
|
+
terminal_width: Terminal width for word wrapping
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Estimated number of lines
|
|
145
|
+
"""
|
|
146
|
+
if not text or terminal_width <= 0:
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
fragments = to_formatted_text(text)
|
|
150
|
+
plain_text = fragment_list_to_text(fragments)
|
|
151
|
+
if not plain_text:
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
# Normalize newlines and keep trailing empty lines.
|
|
155
|
+
plain_text = plain_text.replace("\r\n", "\n").replace("\r", "\n")
|
|
156
|
+
lines = plain_text.split("\n")
|
|
157
|
+
|
|
158
|
+
# Account for text wrapping using display width (CJK-safe).
|
|
159
|
+
total_lines = 0
|
|
160
|
+
for line in lines:
|
|
161
|
+
if not line:
|
|
162
|
+
total_lines += 1
|
|
163
|
+
else:
|
|
164
|
+
width = get_cwidth(line)
|
|
165
|
+
wrapped = (width + terminal_width - 1) // terminal_width
|
|
166
|
+
total_lines += max(1, wrapped)
|
|
167
|
+
|
|
168
|
+
return total_lines
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def prompt_choice(
|
|
172
|
+
message: str,
|
|
173
|
+
options: list[ChoiceOption] | list[tuple[str, str]],
|
|
174
|
+
*,
|
|
175
|
+
title: Optional[str] = None,
|
|
176
|
+
description: Optional[str] = None,
|
|
177
|
+
warning: Optional[str] = None,
|
|
178
|
+
allow_esc: bool = True,
|
|
179
|
+
esc_value: Optional[str] = None,
|
|
180
|
+
style: Optional[Style] = None,
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Prompt the user to make a choice from a list of options.
|
|
183
|
+
|
|
184
|
+
This is the unified choice interface used across Ripperdoc for
|
|
185
|
+
consistent user experience in onboarding, permission prompts, etc.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
message: The main prompt message (supports HTML formatting)
|
|
189
|
+
options: List of ChoiceOption objects or (value, label) tuples
|
|
190
|
+
title: Optional title to display above the prompt
|
|
191
|
+
description: Optional description text
|
|
192
|
+
warning: Optional warning message to display
|
|
193
|
+
allow_esc: Whether ESC key can be used to cancel (defaults to True)
|
|
194
|
+
esc_value: Value to return when ESC is pressed (defaults to first option's value)
|
|
195
|
+
style: Optional custom style (defaults to unified choice style)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The value of the selected option
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
```python
|
|
202
|
+
# Simple usage with tuples
|
|
203
|
+
result = prompt_choice(
|
|
204
|
+
"Choose a provider",
|
|
205
|
+
[("openai", "OpenAI"), ("anthropic", "Anthropic")]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Rich usage with ChoiceOption objects
|
|
209
|
+
result = prompt_choice(
|
|
210
|
+
"Choose a provider",
|
|
211
|
+
[
|
|
212
|
+
ChoiceOption("openai", "<info>OpenAI</info>", "GPT models"),
|
|
213
|
+
ChoiceOption("anthropic", "<info>Anthropic</info>", "Claude models"),
|
|
214
|
+
],
|
|
215
|
+
title="AI Provider Selection",
|
|
216
|
+
description="Select your preferred AI model provider"
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
"""
|
|
220
|
+
# Normalize options to ChoiceOption objects
|
|
221
|
+
choice_options: list[ChoiceOption] = []
|
|
222
|
+
for opt in options:
|
|
223
|
+
if isinstance(opt, ChoiceOption):
|
|
224
|
+
choice_options.append(opt)
|
|
225
|
+
else:
|
|
226
|
+
value, label = opt
|
|
227
|
+
choice_options.append(ChoiceOption(value, label))
|
|
228
|
+
|
|
229
|
+
# Build formatted prompt HTML
|
|
230
|
+
prompt_html = ""
|
|
231
|
+
if title:
|
|
232
|
+
prompt_html += f"<title>{html.escape(title)}</title>\n"
|
|
233
|
+
if description:
|
|
234
|
+
prompt_html += f"<description>{html.escape(description)}</description>\n"
|
|
235
|
+
prompt_html += message
|
|
236
|
+
if warning:
|
|
237
|
+
prompt_html += f"\n<warning>{html.escape(warning)}</warning>"
|
|
238
|
+
|
|
239
|
+
formatted_prompt = HTML(f"\n{prompt_html}\n")
|
|
240
|
+
|
|
241
|
+
# Convert to prompt_toolkit format
|
|
242
|
+
choice_options_formatted = []
|
|
243
|
+
for opt in choice_options:
|
|
244
|
+
label = opt.label
|
|
245
|
+
# Add default marker after the label if applicable
|
|
246
|
+
if opt.is_default:
|
|
247
|
+
label = f"{label} <default>*</default>"
|
|
248
|
+
choice_options_formatted.append((opt.value, HTML(label) if "<" in label else label))
|
|
249
|
+
|
|
250
|
+
# Set up ESC key binding
|
|
251
|
+
key_bindings = KeyBindings()
|
|
252
|
+
if allow_esc:
|
|
253
|
+
default_esc_value = esc_value or (choice_options[0].value if choice_options else "")
|
|
254
|
+
result_on_esc = default_esc_value
|
|
255
|
+
|
|
256
|
+
@key_bindings.add("escape", eager=True)
|
|
257
|
+
def _esc_handler(event: Any) -> None: # noqa: ANN001 (called by key_binding)
|
|
258
|
+
event.app.exit(result=result_on_esc, style="class:aborting")
|
|
259
|
+
|
|
260
|
+
# Show the choice dialog
|
|
261
|
+
result = choice(
|
|
262
|
+
message=formatted_prompt,
|
|
263
|
+
options=choice_options_formatted,
|
|
264
|
+
style=style or _choice_style(),
|
|
265
|
+
show_frame=~is_done,
|
|
266
|
+
key_bindings=key_bindings if allow_esc else None,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Clear the prompt from screen by calculating the exact number of lines
|
|
270
|
+
# ANSI codes: ESC[F = move cursor to beginning of previous line
|
|
271
|
+
# ESC[2K = clear entire line
|
|
272
|
+
#
|
|
273
|
+
# Calculate lines to clear:
|
|
274
|
+
# - Options: len(choice_options)
|
|
275
|
+
# NOTE: prompt_toolkit renders a final "done" state without the frame
|
|
276
|
+
# when show_frame=~is_done, so we should not include frame borders here.
|
|
277
|
+
term_width = _terminal_width()
|
|
278
|
+
message_width = max(1, term_width - 2) # account for label padding
|
|
279
|
+
lines_to_clear = _count_display_lines(formatted_prompt, message_width)
|
|
280
|
+
lines_to_clear += len(choice_options_formatted)
|
|
281
|
+
|
|
282
|
+
for _ in range(lines_to_clear):
|
|
283
|
+
print("\033[F\033[2K", end="", flush=True)
|
|
284
|
+
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def prompt_yes_no(
|
|
289
|
+
message: str,
|
|
290
|
+
*,
|
|
291
|
+
title: Optional[str] = None,
|
|
292
|
+
allow_session: bool = True,
|
|
293
|
+
) -> str:
|
|
294
|
+
"""Prompt a yes/no question with optional session remember option.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
message: The question to ask
|
|
298
|
+
title: Optional title
|
|
299
|
+
allow_session: Whether to include "Yes, for this session" option
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
"y" for yes, "s" for session, "n" for no
|
|
303
|
+
|
|
304
|
+
Example:
|
|
305
|
+
```python
|
|
306
|
+
answer = prompt_yes_no("Continue with installation?")
|
|
307
|
+
if answer in ("y", "s"):
|
|
308
|
+
# User approved
|
|
309
|
+
...
|
|
310
|
+
```
|
|
311
|
+
"""
|
|
312
|
+
options: list[tuple[str, str]] = [
|
|
313
|
+
("y", "<yes-option>Yes</yes-option>"),
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
if allow_session:
|
|
317
|
+
options.append(("s", "<yes-option>Yes, for this session</yes-option>"))
|
|
318
|
+
|
|
319
|
+
options.append(("n", "<no-option>No</no-option>"))
|
|
320
|
+
|
|
321
|
+
return prompt_choice(
|
|
322
|
+
message=f"<question>{html.escape(message)}</question>",
|
|
323
|
+
options=options,
|
|
324
|
+
title=title,
|
|
325
|
+
allow_esc=True,
|
|
326
|
+
esc_value="n", # ESC means no
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def prompt_select(
|
|
331
|
+
message: str,
|
|
332
|
+
options: list[str],
|
|
333
|
+
*,
|
|
334
|
+
default: Optional[str] = None,
|
|
335
|
+
title: Optional[str] = None,
|
|
336
|
+
) -> Optional[str]:
|
|
337
|
+
"""Prompt the user to select from a list of string options.
|
|
338
|
+
|
|
339
|
+
A simplified version of prompt_choice for simple string lists.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
message: The prompt message
|
|
343
|
+
options: List of option strings
|
|
344
|
+
default: The default option value
|
|
345
|
+
title: Optional title
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
The selected option value, or None if canceled
|
|
349
|
+
|
|
350
|
+
Example:
|
|
351
|
+
```python
|
|
352
|
+
provider = prompt_select(
|
|
353
|
+
"Choose your model provider",
|
|
354
|
+
["openai", "anthropic", "deepseek"],
|
|
355
|
+
default="deepseek"
|
|
356
|
+
)
|
|
357
|
+
```
|
|
358
|
+
"""
|
|
359
|
+
choice_options = [
|
|
360
|
+
ChoiceOption(
|
|
361
|
+
opt,
|
|
362
|
+
f"<info>{opt}</info>",
|
|
363
|
+
is_default=(opt == default),
|
|
364
|
+
)
|
|
365
|
+
for opt in options
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
result = prompt_choice(
|
|
369
|
+
message=message,
|
|
370
|
+
options=choice_options,
|
|
371
|
+
title=title,
|
|
372
|
+
allow_esc=True,
|
|
373
|
+
esc_value=default or (options[0] if options else None),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return result if result else None
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""ESC key interrupt listener for the Rich UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
from ripperdoc.utils.log import get_logger
|
|
12
|
+
|
|
13
|
+
if os.name != "nt":
|
|
14
|
+
import select
|
|
15
|
+
import termios
|
|
16
|
+
import tty
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EscInterruptListener:
|
|
20
|
+
"""Listen for ESC keypresses in a background thread and invoke a callback."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, on_interrupt: Callable[[], None], *, logger: Optional[Any] = None) -> None:
|
|
23
|
+
self._on_interrupt = on_interrupt
|
|
24
|
+
self._logger = logger or get_logger()
|
|
25
|
+
self._thread: Optional[threading.Thread] = None
|
|
26
|
+
self._stop_event = threading.Event()
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._pause_depth = 0
|
|
29
|
+
self._interrupt_sent = False
|
|
30
|
+
self._fd: Optional[int] = None
|
|
31
|
+
self._owns_fd = False
|
|
32
|
+
self._orig_termios = None
|
|
33
|
+
self._cbreak_active = False
|
|
34
|
+
self._availability_checked = False
|
|
35
|
+
self._available = True
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_running(self) -> bool:
|
|
39
|
+
return self._thread is not None and self._thread.is_alive()
|
|
40
|
+
|
|
41
|
+
def start(self) -> None:
|
|
42
|
+
if self.is_running or not self._available:
|
|
43
|
+
return
|
|
44
|
+
if os.name != "nt" and not self._setup_posix_input():
|
|
45
|
+
return
|
|
46
|
+
self._stop_event.clear()
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._pause_depth = 0
|
|
49
|
+
self._interrupt_sent = False
|
|
50
|
+
self._thread = threading.Thread(
|
|
51
|
+
target=self._run,
|
|
52
|
+
name="ripperdoc-esc-listener",
|
|
53
|
+
daemon=True,
|
|
54
|
+
)
|
|
55
|
+
self._thread.start()
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
self._stop_event.set()
|
|
59
|
+
if self._thread is not None:
|
|
60
|
+
self._thread.join(timeout=0.25)
|
|
61
|
+
self._thread = None
|
|
62
|
+
if os.name != "nt":
|
|
63
|
+
self._restore_posix_input()
|
|
64
|
+
|
|
65
|
+
def pause(self) -> None:
|
|
66
|
+
if os.name == "nt":
|
|
67
|
+
return
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._pause_depth += 1
|
|
70
|
+
if self._pause_depth == 1:
|
|
71
|
+
self._restore_termios_locked()
|
|
72
|
+
|
|
73
|
+
def resume(self) -> None:
|
|
74
|
+
if os.name == "nt":
|
|
75
|
+
return
|
|
76
|
+
with self._lock:
|
|
77
|
+
if self._pause_depth == 0:
|
|
78
|
+
return
|
|
79
|
+
self._pause_depth -= 1
|
|
80
|
+
if self._pause_depth == 0:
|
|
81
|
+
self._apply_cbreak_locked()
|
|
82
|
+
|
|
83
|
+
def _run(self) -> None:
|
|
84
|
+
if os.name == "nt":
|
|
85
|
+
self._run_windows()
|
|
86
|
+
else:
|
|
87
|
+
self._run_posix()
|
|
88
|
+
|
|
89
|
+
def _run_windows(self) -> None:
|
|
90
|
+
import msvcrt
|
|
91
|
+
|
|
92
|
+
while not self._stop_event.is_set():
|
|
93
|
+
with self._lock:
|
|
94
|
+
paused = self._pause_depth > 0
|
|
95
|
+
if paused:
|
|
96
|
+
time.sleep(0.05)
|
|
97
|
+
continue
|
|
98
|
+
if msvcrt.kbhit():
|
|
99
|
+
ch = msvcrt.getwch()
|
|
100
|
+
if ch == "\x1b":
|
|
101
|
+
self._signal_interrupt()
|
|
102
|
+
time.sleep(0.02)
|
|
103
|
+
|
|
104
|
+
def _run_posix(self) -> None:
|
|
105
|
+
while not self._stop_event.is_set():
|
|
106
|
+
with self._lock:
|
|
107
|
+
paused = self._pause_depth > 0
|
|
108
|
+
fd = self._fd
|
|
109
|
+
if paused or fd is None:
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
readable, _, _ = select.select([fd], [], [], 0.1)
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
time.sleep(0.05)
|
|
116
|
+
continue
|
|
117
|
+
if not readable:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
ch = os.read(fd, 1)
|
|
121
|
+
except OSError:
|
|
122
|
+
continue
|
|
123
|
+
if ch == b"\x1b":
|
|
124
|
+
if self._is_escape_sequence(fd):
|
|
125
|
+
continue
|
|
126
|
+
self._signal_interrupt()
|
|
127
|
+
|
|
128
|
+
def _is_escape_sequence(self, fd: int) -> bool:
|
|
129
|
+
try:
|
|
130
|
+
readable, _, _ = select.select([fd], [], [], 0.02)
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
return False
|
|
133
|
+
if not readable:
|
|
134
|
+
return False
|
|
135
|
+
self._drain_pending_bytes(fd)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def _drain_pending_bytes(self, fd: int) -> None:
|
|
139
|
+
while True:
|
|
140
|
+
try:
|
|
141
|
+
readable, _, _ = select.select([fd], [], [], 0)
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
return
|
|
144
|
+
if not readable:
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
os.read(fd, 32)
|
|
148
|
+
except OSError:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
def _signal_interrupt(self) -> None:
|
|
152
|
+
with self._lock:
|
|
153
|
+
if self._interrupt_sent:
|
|
154
|
+
return
|
|
155
|
+
self._interrupt_sent = True
|
|
156
|
+
try:
|
|
157
|
+
self._on_interrupt()
|
|
158
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
159
|
+
self._logger.debug(
|
|
160
|
+
"[ui] ESC interrupt callback failed: %s: %s",
|
|
161
|
+
type(exc).__name__,
|
|
162
|
+
exc,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _setup_posix_input(self) -> bool:
|
|
166
|
+
if self._fd is not None:
|
|
167
|
+
return True
|
|
168
|
+
fd: Optional[int] = None
|
|
169
|
+
owns = False
|
|
170
|
+
try:
|
|
171
|
+
if sys.stdin.isatty():
|
|
172
|
+
fd = sys.stdin.fileno()
|
|
173
|
+
elif os.path.exists("/dev/tty"):
|
|
174
|
+
fd = os.open("/dev/tty", os.O_RDONLY)
|
|
175
|
+
owns = True
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
self._disable_listener(f"input error: {exc}")
|
|
178
|
+
return False
|
|
179
|
+
if fd is None:
|
|
180
|
+
self._disable_listener("no TTY available")
|
|
181
|
+
return False
|
|
182
|
+
try:
|
|
183
|
+
self._orig_termios = termios.tcgetattr(fd)
|
|
184
|
+
except (termios.error, OSError) as exc:
|
|
185
|
+
if owns:
|
|
186
|
+
try:
|
|
187
|
+
os.close(fd)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
self._disable_listener(f"termios unavailable: {exc}")
|
|
191
|
+
return False
|
|
192
|
+
self._fd = fd
|
|
193
|
+
self._owns_fd = owns
|
|
194
|
+
self._apply_cbreak_locked()
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def _restore_posix_input(self) -> None:
|
|
198
|
+
with self._lock:
|
|
199
|
+
self._restore_termios_locked()
|
|
200
|
+
if self._fd is not None and self._owns_fd:
|
|
201
|
+
try:
|
|
202
|
+
os.close(self._fd)
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
self._fd = None
|
|
206
|
+
self._owns_fd = False
|
|
207
|
+
self._orig_termios = None
|
|
208
|
+
self._cbreak_active = False
|
|
209
|
+
|
|
210
|
+
def _apply_cbreak_locked(self) -> None:
|
|
211
|
+
if self._fd is None or self._orig_termios is None or self._cbreak_active:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
tty.setcbreak(self._fd)
|
|
215
|
+
self._cbreak_active = True
|
|
216
|
+
except (termios.error, OSError):
|
|
217
|
+
self._disable_listener("failed to enter cbreak mode")
|
|
218
|
+
|
|
219
|
+
def _restore_termios_locked(self) -> None:
|
|
220
|
+
if self._fd is None or self._orig_termios is None or not self._cbreak_active:
|
|
221
|
+
return
|
|
222
|
+
try:
|
|
223
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._orig_termios)
|
|
224
|
+
except (termios.error, OSError):
|
|
225
|
+
pass
|
|
226
|
+
self._cbreak_active = False
|
|
227
|
+
|
|
228
|
+
def _disable_listener(self, reason: str) -> None:
|
|
229
|
+
if self._availability_checked:
|
|
230
|
+
return
|
|
231
|
+
self._availability_checked = True
|
|
232
|
+
self._available = False
|
|
233
|
+
self._logger.debug("[ui] ESC interrupt listener disabled: %s", reason)
|
|
@@ -218,6 +218,13 @@ class MessageDisplay:
|
|
|
218
218
|
if preview:
|
|
219
219
|
self.console.print(f"[dim italic]Thinking: {escape(preview)}[/]")
|
|
220
220
|
|
|
221
|
+
def print_interrupt_notice(self) -> None:
|
|
222
|
+
"""Display an interrupt notice when the user cancels with ESC."""
|
|
223
|
+
self.console.print(
|
|
224
|
+
"\n[red]■ Conversation interrupted[/red] · "
|
|
225
|
+
"[dim]Tell the model what to do differently.[/dim]"
|
|
226
|
+
)
|
|
227
|
+
|
|
221
228
|
|
|
222
229
|
def parse_bash_output_sections(content: str) -> Tuple[List[str], List[str]]:
|
|
223
230
|
"""Parse stdout/stderr sections from a bash output text block."""
|