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.
Files changed (40) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  13. ripperdoc/cli/ui/message_display.py +7 -0
  14. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  15. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  16. ripperdoc/cli/ui/panels.py +19 -4
  17. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  18. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  19. ripperdoc/cli/ui/provider_options.py +220 -80
  20. ripperdoc/cli/ui/rich_ui.py +91 -83
  21. ripperdoc/cli/ui/tips.py +89 -0
  22. ripperdoc/cli/ui/wizard.py +98 -45
  23. ripperdoc/core/config.py +3 -0
  24. ripperdoc/core/permissions.py +66 -104
  25. ripperdoc/core/providers/anthropic.py +11 -0
  26. ripperdoc/protocol/stdio.py +3 -1
  27. ripperdoc/tools/bash_tool.py +2 -0
  28. ripperdoc/tools/file_edit_tool.py +100 -181
  29. ripperdoc/tools/file_read_tool.py +101 -25
  30. ripperdoc/tools/multi_edit_tool.py +239 -91
  31. ripperdoc/tools/notebook_edit_tool.py +11 -29
  32. ripperdoc/utils/file_editing.py +164 -0
  33. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  34. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  35. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
  36. ripperdoc/cli/ui/interrupt_handler.py +0 -208
  37. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  38. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  39. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  40. {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."""
@@ -0,0 +1,5 @@
1
+ """Textual-based models TUI."""
2
+
3
+ from .textual_app import run_models_tui
4
+
5
+ __all__ = ["run_models_tui"]