ripperdoc 0.3.1__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 (37) 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/models_tui/__init__.py +5 -0
  13. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  14. ripperdoc/cli/ui/panels.py +19 -4
  15. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  16. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  17. ripperdoc/cli/ui/provider_options.py +220 -80
  18. ripperdoc/cli/ui/rich_ui.py +9 -11
  19. ripperdoc/cli/ui/tips.py +89 -0
  20. ripperdoc/cli/ui/wizard.py +98 -45
  21. ripperdoc/core/config.py +3 -0
  22. ripperdoc/core/permissions.py +25 -70
  23. ripperdoc/core/providers/anthropic.py +11 -0
  24. ripperdoc/protocol/stdio.py +3 -1
  25. ripperdoc/tools/bash_tool.py +2 -0
  26. ripperdoc/tools/file_edit_tool.py +100 -181
  27. ripperdoc/tools/file_read_tool.py +101 -25
  28. ripperdoc/tools/multi_edit_tool.py +239 -91
  29. ripperdoc/tools/notebook_edit_tool.py +11 -29
  30. ripperdoc/utils/file_editing.py +164 -0
  31. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  32. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  33. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
  34. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  35. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  36. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  37. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,13 @@
1
+ import sys
2
+ import textwrap
1
3
  from typing import Any, Optional
2
4
 
5
+ from rich import box
6
+ from rich.layout import Layout
3
7
  from rich.markup import escape
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
4
11
 
5
12
  from ripperdoc.cli.ui.helpers import get_profile_for_pointer
6
13
  from ripperdoc.core.config import (
@@ -9,6 +16,7 @@ from ripperdoc.core.config import (
9
16
  add_model_profile,
10
17
  delete_model_profile,
11
18
  get_global_config,
19
+ model_supports_vision,
12
20
  set_model_pointer,
13
21
  )
14
22
  from ripperdoc.utils.log import get_logger
@@ -19,6 +27,749 @@ from .base import SlashCommand
19
27
  logger = get_logger()
20
28
 
21
29
 
30
+ def _parse_int(console: Any, prompt_text: str, default_value: Optional[int]) -> Optional[int]:
31
+ raw = console.input(prompt_text).strip()
32
+ if not raw:
33
+ return default_value
34
+ try:
35
+ return int(raw)
36
+ except ValueError:
37
+ console.print("[yellow]Invalid number, keeping previous value.[/yellow]")
38
+ return default_value
39
+
40
+
41
+ def _parse_float(console: Any, prompt_text: str, default_value: float) -> float:
42
+ raw = console.input(prompt_text).strip()
43
+ if not raw:
44
+ return default_value
45
+ try:
46
+ return float(raw)
47
+ except ValueError:
48
+ console.print("[yellow]Invalid number, keeping previous value.[/yellow]")
49
+ return default_value
50
+
51
+
52
+ def _prompt_provider(console: Any, default_provider: str) -> Optional[ProviderType]:
53
+ provider_input = (
54
+ console.input(
55
+ f"Protocol ({', '.join(p.value for p in ProviderType)}) [{default_provider}]: "
56
+ )
57
+ .strip()
58
+ .lower()
59
+ or default_provider
60
+ )
61
+ try:
62
+ return ProviderType(provider_input)
63
+ except ValueError:
64
+ console.print(f"[red]Invalid provider: {escape(provider_input)}[/red]")
65
+ return None
66
+
67
+
68
+ def _prompt_supports_vision_add(console: Any, default_value: Optional[bool]) -> Optional[bool]:
69
+ vision_default_display = (
70
+ "auto" if default_value is None else ("yes" if default_value else "no")
71
+ )
72
+ supports_vision_input = (
73
+ console.input(f"Supports vision (images)? [{vision_default_display}] (Y/n/auto): ")
74
+ .strip()
75
+ .lower()
76
+ )
77
+ if supports_vision_input in ("y", "yes"):
78
+ return True
79
+ if supports_vision_input in ("n", "no"):
80
+ return False
81
+ if supports_vision_input in ("auto", ""):
82
+ return None
83
+ return default_value
84
+
85
+
86
+ def _prompt_supports_vision_edit(console: Any, current_value: Optional[bool]) -> Optional[bool]:
87
+ vision_default_display = "auto" if current_value is None else ("yes" if current_value else "no")
88
+ supports_vision_input = (
89
+ console.input(
90
+ f"Supports vision (images)? [{vision_default_display}] (Y/n/auto/C=clear): "
91
+ )
92
+ .strip()
93
+ .lower()
94
+ )
95
+ if supports_vision_input in ("y", "yes"):
96
+ return True
97
+ if supports_vision_input in ("n", "no"):
98
+ return False
99
+ if supports_vision_input in ("c", "clear", "-"):
100
+ return None
101
+ if supports_vision_input in ("auto", ""):
102
+ return current_value
103
+ return current_value
104
+
105
+
106
+ def _collect_add_profile_input(
107
+ console: Any,
108
+ config: Any,
109
+ existing_profile: Optional[ModelProfile],
110
+ current_profile: Optional[ModelProfile],
111
+ ) -> tuple[Optional[ModelProfile], bool]:
112
+ default_provider = (
113
+ (current_profile.provider.value) if current_profile else ProviderType.ANTHROPIC.value
114
+ )
115
+ provider = _prompt_provider(console, default_provider)
116
+ if provider is None:
117
+ return None, False
118
+
119
+ default_model = (
120
+ existing_profile.model
121
+ if existing_profile
122
+ else (current_profile.model if current_profile else "")
123
+ )
124
+ model_prompt = f"Model name to send{f' [{default_model}]' if default_model else ''}: "
125
+ model_name = console.input(model_prompt).strip() or default_model
126
+ if not model_name:
127
+ console.print("[red]Model name is required.[/red]")
128
+ return None, False
129
+
130
+ api_key_input = prompt_secret("API key (leave blank to keep unset)").strip()
131
+ api_key = api_key_input or (existing_profile.api_key if existing_profile else None)
132
+
133
+ auth_token = existing_profile.auth_token if existing_profile else None
134
+ if provider == ProviderType.ANTHROPIC:
135
+ auth_token_input = prompt_secret(
136
+ "Auth token (Anthropic only, leave blank to keep unset)"
137
+ ).strip()
138
+ auth_token = auth_token_input or auth_token
139
+ else:
140
+ auth_token = None
141
+
142
+ api_base_default = existing_profile.api_base if existing_profile else ""
143
+ api_base = (
144
+ console.input(
145
+ f"API base (optional){f' [{api_base_default}]' if api_base_default else ''}: "
146
+ ).strip()
147
+ or api_base_default
148
+ or None
149
+ )
150
+
151
+ max_tokens_default = existing_profile.max_tokens if existing_profile else 4096
152
+ max_tokens = (
153
+ _parse_int(
154
+ console,
155
+ f"Max output tokens [{max_tokens_default}]: ",
156
+ max_tokens_default,
157
+ )
158
+ or max_tokens_default
159
+ )
160
+
161
+ temp_default = existing_profile.temperature if existing_profile else 0.7
162
+ temperature = _parse_float(
163
+ console,
164
+ f"Temperature [{temp_default}]: ",
165
+ temp_default,
166
+ )
167
+
168
+ context_window_default = existing_profile.context_window if existing_profile else None
169
+ context_prompt = "Context window tokens (optional"
170
+ if context_window_default:
171
+ context_prompt += f", current {context_window_default}"
172
+ context_prompt += "): "
173
+ context_window = _parse_int(console, context_prompt, context_window_default)
174
+
175
+ supports_vision_default = existing_profile.supports_vision if existing_profile else None
176
+ supports_vision = _prompt_supports_vision_add(console, supports_vision_default)
177
+
178
+ default_set_main = (
179
+ not config.model_profiles
180
+ or getattr(config.model_pointers, "main", "") not in config.model_profiles
181
+ )
182
+ set_main_input = (
183
+ console.input(f"Set as main model? ({'Y' if default_set_main else 'y'}/N): ")
184
+ .strip()
185
+ .lower()
186
+ )
187
+ set_as_main = set_main_input in ("y", "yes") if set_main_input else default_set_main
188
+
189
+ profile = ModelProfile(
190
+ provider=provider,
191
+ model=model_name,
192
+ api_key=api_key,
193
+ api_base=api_base,
194
+ max_tokens=max_tokens,
195
+ temperature=temperature,
196
+ context_window=context_window,
197
+ auth_token=auth_token,
198
+ supports_vision=supports_vision,
199
+ )
200
+
201
+ return profile, set_as_main
202
+
203
+
204
+ def _collect_edit_profile_input(
205
+ console: Any,
206
+ existing_profile: ModelProfile,
207
+ ) -> Optional[ModelProfile]:
208
+ provider_default = existing_profile.provider.value
209
+ provider = _prompt_provider(console, provider_default)
210
+ if provider is None:
211
+ return None
212
+
213
+ model_name = (
214
+ console.input(f"Model name to send [{existing_profile.model}]: ").strip()
215
+ or existing_profile.model
216
+ )
217
+
218
+ api_key_label = "[set]" if existing_profile.api_key else "[not set]"
219
+ api_key_prompt = f"API key {api_key_label} (Enter=keep, '-'=clear)"
220
+ api_key_input = prompt_secret(api_key_prompt).strip()
221
+ if api_key_input == "-":
222
+ api_key = None
223
+ elif api_key_input:
224
+ api_key = api_key_input
225
+ else:
226
+ api_key = existing_profile.api_key
227
+
228
+ auth_token = existing_profile.auth_token
229
+ if provider == ProviderType.ANTHROPIC or existing_profile.provider == ProviderType.ANTHROPIC:
230
+ auth_label = "[set]" if auth_token else "[not set]"
231
+ auth_prompt = f"Auth token (Anthropic only) {auth_label} (Enter=keep, '-'=clear)"
232
+ auth_token_input = prompt_secret(auth_prompt).strip()
233
+ if auth_token_input == "-":
234
+ auth_token = None
235
+ elif auth_token_input:
236
+ auth_token = auth_token_input
237
+ else:
238
+ auth_token = None
239
+
240
+ api_base = (
241
+ console.input(f"API base (optional) [{existing_profile.api_base or ''}]: ").strip()
242
+ or existing_profile.api_base
243
+ )
244
+ if api_base == "":
245
+ api_base = None
246
+
247
+ max_tokens = (
248
+ _parse_int(
249
+ console,
250
+ f"Max output tokens [{existing_profile.max_tokens}]: ",
251
+ existing_profile.max_tokens,
252
+ )
253
+ or existing_profile.max_tokens
254
+ )
255
+
256
+ temperature = _parse_float(
257
+ console,
258
+ f"Temperature [{existing_profile.temperature}]: ",
259
+ existing_profile.temperature,
260
+ )
261
+
262
+ context_window = _parse_int(
263
+ console,
264
+ f"Context window tokens [{existing_profile.context_window or 'unset'}]: ",
265
+ existing_profile.context_window,
266
+ )
267
+
268
+ supports_vision = _prompt_supports_vision_edit(console, existing_profile.supports_vision)
269
+
270
+ updated_profile = ModelProfile(
271
+ provider=provider,
272
+ model=model_name,
273
+ api_key=api_key,
274
+ api_base=api_base,
275
+ max_tokens=max_tokens,
276
+ temperature=temperature,
277
+ context_window=context_window,
278
+ auth_token=auth_token,
279
+ supports_vision=supports_vision,
280
+ )
281
+ return updated_profile
282
+
283
+
284
+ def _pointer_markers(pointer_map: dict[str, str], name: str) -> list[str]:
285
+ return [ptr for ptr, value in pointer_map.items() if value == name]
286
+
287
+
288
+ def _vision_labels(profile: ModelProfile) -> tuple[str, str]:
289
+ if profile.supports_vision is None:
290
+ detected = model_supports_vision(profile)
291
+ return "auto", f"auto (detected {'yes' if detected else 'no'})"
292
+ if profile.supports_vision:
293
+ return "yes", "yes"
294
+ return "no", "no"
295
+
296
+
297
+ def _render_models_plain(console: Any, config: Any) -> None:
298
+ pointer_map = config.model_pointers.model_dump()
299
+ if not config.model_profiles:
300
+ console.print(" • No models configured")
301
+ return
302
+
303
+ console.print("\n[bold]Configured Models:[/bold]")
304
+ for name, profile in config.model_profiles.items():
305
+ markers = [ptr for ptr, value in pointer_map.items() if value == name]
306
+ marker_text = f" ({', '.join(markers)})" if markers else ""
307
+ console.print(f" • {escape(name)}{marker_text}", markup=False)
308
+ console.print(f" protocol: {profile.provider.value}", markup=False)
309
+ console.print(f" model: {profile.model}", markup=False)
310
+ if profile.api_base:
311
+ console.print(f" api_base: {profile.api_base}", markup=False)
312
+ if profile.context_window:
313
+ console.print(f" context: {profile.context_window} tokens", markup=False)
314
+ console.print(
315
+ f" max_tokens: {profile.max_tokens}, temperature: {profile.temperature}",
316
+ markup=False,
317
+ )
318
+ console.print(f" api_key: {'***' if profile.api_key else 'Not set'}", markup=False)
319
+ if profile.provider == ProviderType.ANTHROPIC:
320
+ console.print(
321
+ f" auth_token: {'***' if getattr(profile, 'auth_token', None) else 'Not set'}",
322
+ markup=False,
323
+ )
324
+ if profile.openai_tool_mode:
325
+ console.print(f" openai_tool_mode: {profile.openai_tool_mode}", markup=False)
326
+ if profile.thinking_mode:
327
+ console.print(f" thinking_mode: {profile.thinking_mode}", markup=False)
328
+ if profile.supports_vision is None:
329
+ vision_display = "auto-detect"
330
+ elif profile.supports_vision:
331
+ vision_display = "yes"
332
+ else:
333
+ vision_display = "no"
334
+ console.print(f" supports_vision: {vision_display}", markup=False)
335
+ pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
336
+ console.print(f"[dim]Pointers: {escape(pointer_labels)}[/dim]")
337
+
338
+
339
+ def _render_models_table(console: Any, config: Any) -> None:
340
+ pointer_map = config.model_pointers.model_dump()
341
+ table = Table(box=box.SIMPLE_HEAVY, expand=True)
342
+ table.add_column("Name", style="cyan", no_wrap=True)
343
+ table.add_column("Ptr", style="magenta", no_wrap=True)
344
+ table.add_column("Provider", style="green", no_wrap=True)
345
+ table.add_column("Model", style="white", overflow="fold")
346
+ table.add_column("Ctx", style="dim", justify="right", no_wrap=True)
347
+ table.add_column("Max", style="dim", justify="right", no_wrap=True)
348
+ table.add_column("Temp", style="dim", justify="right", no_wrap=True)
349
+ table.add_column("Vision", style="yellow", no_wrap=True)
350
+ table.add_column("Key", style="dim", no_wrap=True)
351
+ table.add_column("API Base", style="dim", overflow="fold", max_width=28)
352
+
353
+ for name, profile in config.model_profiles.items():
354
+ markers = _pointer_markers(pointer_map, name)
355
+ pointer_label = ",".join(markers) if markers else "-"
356
+ context_display = str(profile.context_window) if profile.context_window else "-"
357
+ vision_display = _vision_labels(profile)[0]
358
+ api_base = profile.api_base or "-"
359
+ if profile.api_base:
360
+ api_base = textwrap.shorten(profile.api_base, width=28, placeholder="...")
361
+ key_display = "set" if profile.api_key else "-"
362
+ table.add_row(
363
+ escape(name),
364
+ pointer_label,
365
+ profile.provider.value,
366
+ escape(profile.model),
367
+ context_display,
368
+ str(profile.max_tokens),
369
+ f"{profile.temperature:.2f}",
370
+ vision_display,
371
+ key_display,
372
+ escape(api_base),
373
+ )
374
+
375
+ title = f"Models ({len(config.model_profiles)})"
376
+ console.print(Panel(table, title=title, box=box.ROUNDED, padding=(1, 2)))
377
+ pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
378
+ console.print(f"[dim]Pointers: {escape(pointer_labels)}[/dim]")
379
+
380
+
381
+ def _build_model_details_panel(
382
+ name: str, profile: ModelProfile, pointer_map: dict[str, str]
383
+ ) -> Panel:
384
+ markers = _pointer_markers(pointer_map, name)
385
+ marker_text = ", ".join(markers) if markers else "-"
386
+ vision_short, vision_detail = _vision_labels(profile)
387
+
388
+ details = Table.grid(padding=(0, 2))
389
+ details.add_column(style="cyan", no_wrap=True)
390
+ details.add_column(style="white")
391
+ details.add_row("Profile", escape(name))
392
+ details.add_row("Pointers", escape(marker_text))
393
+ details.add_row("Provider", escape(profile.provider.value))
394
+ details.add_row("Model", escape(profile.model))
395
+ details.add_row("API base", escape(profile.api_base or "-"))
396
+ details.add_row("Context", escape(str(profile.context_window) if profile.context_window else "auto"))
397
+ details.add_row("Max tokens", escape(str(profile.max_tokens)))
398
+ details.add_row("Temperature", escape(str(profile.temperature)))
399
+ details.add_row("Vision", escape(vision_detail if vision_short == "auto" else vision_short))
400
+ details.add_row("API key", "set" if profile.api_key else "unset")
401
+ if profile.provider == ProviderType.ANTHROPIC:
402
+ details.add_row("Auth token", "set" if getattr(profile, "auth_token", None) else "unset")
403
+ if profile.openai_tool_mode:
404
+ details.add_row("OpenAI tool mode", escape(profile.openai_tool_mode))
405
+ if profile.thinking_mode:
406
+ details.add_row("Thinking mode", escape(profile.thinking_mode))
407
+
408
+ return Panel(
409
+ details,
410
+ title=f"Model: {escape(name)}",
411
+ box=box.ROUNDED,
412
+ padding=(1, 2),
413
+ )
414
+
415
+
416
+ def _render_model_details(
417
+ console: Any, name: str, profile: ModelProfile, pointer_map: dict[str, str]
418
+ ) -> None:
419
+ console.print(_build_model_details_panel(name, profile, pointer_map))
420
+
421
+
422
+ def _default_selected_model(config: Any, preferred: Optional[str] = None) -> Optional[str]:
423
+ if preferred and preferred in config.model_profiles:
424
+ return preferred
425
+ main_pointer = getattr(config.model_pointers, "main", "")
426
+ if main_pointer in config.model_profiles:
427
+ return main_pointer
428
+ if config.model_profiles:
429
+ return next(iter(config.model_profiles))
430
+ return None
431
+
432
+
433
+ def _build_models_list_panel(
434
+ config: Any, selected_name: Optional[str], pointer_map: dict[str, str]
435
+ ) -> Panel:
436
+ table = Table(show_header=False, box=None, padding=(0, 1), expand=True)
437
+ table.add_column("#", width=3, no_wrap=True, style="dim")
438
+ table.add_column("Sel", width=2, no_wrap=True)
439
+ table.add_column("Name", no_wrap=True)
440
+ table.add_column("Ptr", style="magenta", no_wrap=True)
441
+ table.add_column("Model", style="dim")
442
+
443
+ for idx, (name, profile) in enumerate(config.model_profiles.items(), start=1):
444
+ selected = name == selected_name
445
+ marker_text = Text(">", style="bold yellow") if selected else Text(" ", style="dim")
446
+ index_text = Text(str(idx), style="dim")
447
+ name_text = Text(name, style="bold cyan" if selected else "cyan")
448
+ markers = _pointer_markers(pointer_map, name)
449
+ pointer_label = ",".join(markers) if markers else "-"
450
+ pointer_text = Text(pointer_label, style="magenta" if markers else "dim")
451
+ model_label = f"{profile.provider.value} • {profile.model}"
452
+ model_text = Text(model_label, style="dim")
453
+ table.add_row(index_text, marker_text, name_text, pointer_text, model_text)
454
+
455
+ if not config.model_profiles:
456
+ table.add_row(
457
+ Text(" ", style="dim"),
458
+ Text(" ", style="dim"),
459
+ Text("No models configured", style="dim"),
460
+ "",
461
+ "",
462
+ )
463
+
464
+ return Panel(table, title="Models", box=box.ROUNDED, padding=(1, 2))
465
+
466
+
467
+ def _build_models_header_panel(config: Any, selected_name: Optional[str]) -> Panel:
468
+ pointer_map = config.model_pointers.model_dump()
469
+ pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
470
+ selected_label = selected_name or "-"
471
+
472
+ header = Text()
473
+ header.append("Models ", style="bold")
474
+ header.append(str(len(config.model_profiles)), style="cyan")
475
+ header.append(" Selected: ", style="dim")
476
+ header.append(selected_label, style="bold cyan")
477
+ header.append(" Pointers: ", style="dim")
478
+ header.append(pointer_labels, style="magenta")
479
+
480
+ return Panel(header, box=box.ROUNDED, padding=(0, 1))
481
+
482
+
483
+ def _build_models_footer_panel() -> Panel:
484
+ footer = Text(
485
+ "↑/↓ move A add E edit D delete M set main K set quick Q exit R refresh",
486
+ style="dim",
487
+ )
488
+ return Panel(footer, box=box.ROUNDED, padding=(0, 1))
489
+
490
+
491
+ def _render_models_dashboard(console: Any, config: Any, selected_name: Optional[str]) -> None:
492
+ pointer_map = config.model_pointers.model_dump()
493
+ layout = Layout()
494
+ layout.split_column(
495
+ Layout(_build_models_header_panel(config, selected_name), name="header", size=3),
496
+ Layout(name="body", ratio=1),
497
+ Layout(_build_models_footer_panel(), name="footer", size=3),
498
+ )
499
+
500
+ list_panel = _build_models_list_panel(config, selected_name, pointer_map)
501
+ if selected_name and selected_name in config.model_profiles:
502
+ details_panel = _build_model_details_panel(
503
+ selected_name, config.model_profiles[selected_name], pointer_map
504
+ )
505
+ else:
506
+ details_panel = Panel("Select a model to view details.", box=box.ROUNDED, padding=(1, 2))
507
+
508
+ layout["body"].split_row(
509
+ Layout(list_panel, name="left", ratio=2),
510
+ Layout(details_panel, name="right", ratio=3),
511
+ )
512
+
513
+ console.print(layout)
514
+
515
+
516
+ def _confirm_action(console: Any, prompt_text: str, default: bool = False) -> bool:
517
+ suffix = "[Y/n]" if default else "[y/N]"
518
+ raw = console.input(f"{prompt_text} {suffix}: ").strip().lower()
519
+ if not raw:
520
+ return default
521
+ return raw in ("y", "yes")
522
+
523
+
524
+ def _prompt_models_command(console: Any) -> str:
525
+ try:
526
+ from prompt_toolkit import prompt as pt_prompt
527
+ from prompt_toolkit.key_binding import KeyBindings
528
+ except (ImportError, OSError, RuntimeError):
529
+ return console.input("Command (a/e/d/m/k/q/r or model #/name): ").strip()
530
+
531
+ key_bindings = KeyBindings()
532
+
533
+ def _exit_with(value: str) -> None:
534
+ def _handler(event: Any) -> None: # noqa: ANN001
535
+ event.app.exit(result=value)
536
+
537
+ return _handler # type: ignore[return-value]
538
+
539
+ for key in ("a", "A"):
540
+ key_bindings.add(key, eager=True)(_exit_with("a"))
541
+ for key in ("e", "E"):
542
+ key_bindings.add(key, eager=True)(_exit_with("e"))
543
+ for key in ("d", "D"):
544
+ key_bindings.add(key, eager=True)(_exit_with("d"))
545
+ for key in ("m", "M"):
546
+ key_bindings.add(key, eager=True)(_exit_with("m"))
547
+ for key in ("k", "K"):
548
+ key_bindings.add(key, eager=True)(_exit_with("k"))
549
+ for key in ("q", "Q", "escape"):
550
+ key_bindings.add(key, eager=True)(_exit_with("q"))
551
+ for key in ("r", "R"):
552
+ key_bindings.add(key, eager=True)(_exit_with("r"))
553
+
554
+ key_bindings.add("up", eager=True)(_exit_with("__up"))
555
+ key_bindings.add("down", eager=True)(_exit_with("__down"))
556
+ key_bindings.add("pageup", eager=True)(_exit_with("__page_up"))
557
+ key_bindings.add("pagedown", eager=True)(_exit_with("__page_down"))
558
+
559
+ @key_bindings.add("enter")
560
+ def _enter(event: Any) -> None: # noqa: ANN001
561
+ text = event.current_buffer.text
562
+ event.app.exit(result=text)
563
+
564
+ @key_bindings.add("c-c", eager=True)
565
+ def _ctrl_c(event: Any) -> None: # noqa: ANN001
566
+ event.app.exit(result="q")
567
+
568
+ return pt_prompt("Command: ", key_bindings=key_bindings)
569
+
570
+
571
+ def _resolve_model_selection(
572
+ raw: str, config: Any, selected_name: Optional[str]
573
+ ) -> Optional[str]:
574
+ if not raw:
575
+ return selected_name
576
+ raw = raw.strip()
577
+ if raw.isdigit():
578
+ idx = int(raw)
579
+ if idx <= 0:
580
+ return selected_name
581
+ names = list(config.model_profiles.keys())
582
+ if 1 <= idx <= len(names):
583
+ return names[idx - 1]
584
+ return selected_name
585
+ if raw in config.model_profiles:
586
+ return raw
587
+ # Case-insensitive match
588
+ lower_map = {name.lower(): name for name in config.model_profiles}
589
+ return lower_map.get(raw.lower(), selected_name)
590
+
591
+
592
+ def _handle_models_rich_tui(ui: Any) -> bool:
593
+ console = ui.console
594
+ if not sys.stdin.isatty():
595
+ console.print("[yellow]Interactive UI requires a TTY. Showing plain list instead.[/yellow]")
596
+ _render_models_plain(console, get_global_config())
597
+ return True
598
+
599
+ selected_name: Optional[str] = None
600
+
601
+ while True:
602
+ config = get_global_config()
603
+ selected_name = _default_selected_model(config, selected_name)
604
+ console.print()
605
+ _render_models_dashboard(console, config, selected_name)
606
+
607
+ if not config.model_profiles:
608
+ if _confirm_action(console, "No models configured. Add one now?", default=True):
609
+ profile_name = console.input("Profile name: ").strip()
610
+ if not profile_name:
611
+ console.print("[red]Model profile name is required.[/red]")
612
+ continue
613
+ existing_profile = config.model_profiles.get(profile_name)
614
+ if existing_profile:
615
+ if not _confirm_action(
616
+ console, f"Profile '{profile_name}' exists. Overwrite?"
617
+ ):
618
+ continue
619
+ profile, set_as_main = _collect_add_profile_input(
620
+ console, config, existing_profile, get_profile_for_pointer("main")
621
+ )
622
+ if not profile:
623
+ continue
624
+ try:
625
+ add_model_profile(
626
+ profile_name,
627
+ profile,
628
+ overwrite=bool(existing_profile),
629
+ set_as_main=set_as_main,
630
+ )
631
+ except (OSError, IOError, ValueError, TypeError, PermissionError) as exc:
632
+ console.print(f"[red]Failed to save model: {escape(str(exc))}[/red]")
633
+ continue
634
+ marker = " (main)" if set_as_main else ""
635
+ console.print(f"[green]✓ Model '{escape(profile_name)}' saved{marker}[/green]")
636
+ continue
637
+ return True
638
+
639
+ command = _prompt_models_command(console).strip().lower()
640
+ if command in ("__up", "__down", "__page_up", "__page_down"):
641
+ names = list(config.model_profiles.keys())
642
+ if not names:
643
+ continue
644
+ current_index = names.index(selected_name) if selected_name in names else 0
645
+ if command == "__up":
646
+ current_index = max(0, current_index - 1)
647
+ elif command == "__down":
648
+ current_index = min(len(names) - 1, current_index + 1)
649
+ elif command == "__page_up":
650
+ current_index = max(0, current_index - 5)
651
+ elif command == "__page_down":
652
+ current_index = min(len(names) - 1, current_index + 5)
653
+ selected_name = names[current_index]
654
+ continue
655
+ if command in ("", "r", "refresh"):
656
+ continue
657
+ if command in ("q", "quit", "exit"):
658
+ return True
659
+ if command in ("a", "add"):
660
+ profile_name = console.input("Profile name: ").strip()
661
+ if not profile_name:
662
+ console.print("[red]Model profile name is required.[/red]")
663
+ continue
664
+ existing_profile = config.model_profiles.get(profile_name)
665
+ if existing_profile:
666
+ if not _confirm_action(console, f"Profile '{profile_name}' exists. Overwrite?"):
667
+ continue
668
+ profile, set_as_main = _collect_add_profile_input(
669
+ console, config, existing_profile, get_profile_for_pointer("main")
670
+ )
671
+ if not profile:
672
+ continue
673
+ try:
674
+ add_model_profile(
675
+ profile_name,
676
+ profile,
677
+ overwrite=bool(existing_profile),
678
+ set_as_main=set_as_main,
679
+ )
680
+ except (OSError, IOError, ValueError, TypeError, PermissionError) as exc:
681
+ console.print(f"[red]Failed to save model: {escape(str(exc))}[/red]")
682
+ continue
683
+ marker = " (main)" if set_as_main else ""
684
+ console.print(f"[green]✓ Model '{escape(profile_name)}' saved{marker}[/green]")
685
+ selected_name = profile_name
686
+ continue
687
+
688
+ if command in ("e", "edit", "d", "delete", "del", "remove", "m", "main", "k", "quick"):
689
+ if not selected_name:
690
+ console.print("[yellow]No model selected.[/yellow]")
691
+ continue
692
+ model_name = selected_name
693
+ else:
694
+ model_name = _resolve_model_selection(command, config, selected_name)
695
+ if not model_name:
696
+ console.print("[yellow]Unknown model or command.[/yellow]")
697
+ continue
698
+ if model_name != selected_name:
699
+ selected_name = model_name
700
+ continue
701
+
702
+ profile = config.model_profiles.get(model_name or "")
703
+ if not profile:
704
+ console.print(f"[yellow]Model '{escape(model_name or '')}' not found.[/yellow]")
705
+ continue
706
+
707
+ if command in ("e", "edit"):
708
+ updated_profile = _collect_edit_profile_input(console, profile)
709
+ if not updated_profile:
710
+ continue
711
+ try:
712
+ add_model_profile(
713
+ model_name,
714
+ updated_profile,
715
+ overwrite=True,
716
+ set_as_main=False,
717
+ )
718
+ except (OSError, IOError, ValueError, TypeError, PermissionError) as exc:
719
+ console.print(f"[red]Failed to update model: {escape(str(exc))}[/red]")
720
+ continue
721
+ console.print(f"[green]✓ Model '{escape(model_name)}' updated[/green]")
722
+ continue
723
+
724
+ if command in ("m", "main", "k", "quick"):
725
+ pointer = "main" if command in ("m", "main") else "quick"
726
+ try:
727
+ set_model_pointer(pointer, model_name)
728
+ console.print(
729
+ f"[green]✓ Pointer '{escape(pointer)}' set to '{escape(model_name)}'[/green]"
730
+ )
731
+ except (ValueError, KeyError, OSError, IOError, PermissionError) as exc:
732
+ console.print(f"[red]{escape(str(exc))}[/red]")
733
+ continue
734
+
735
+ if command in ("d", "delete", "del", "remove"):
736
+ if not _confirm_action(console, f"Delete model '{model_name}'?"):
737
+ continue
738
+ try:
739
+ delete_model_profile(model_name)
740
+ console.print(f"[green]✓ Deleted model '{escape(model_name)}'[/green]")
741
+ selected_name = None
742
+ except KeyError as exc:
743
+ console.print(f"[yellow]{escape(str(exc))}[/yellow]")
744
+ except (OSError, IOError, PermissionError) as exc:
745
+ console.print(f"[red]Failed to delete model: {escape(str(exc))}[/red]")
746
+ continue
747
+
748
+ return True
749
+
750
+
751
+ def _handle_models_tui(ui: Any) -> bool:
752
+ console = ui.console
753
+ if not sys.stdin.isatty():
754
+ console.print("[yellow]Interactive UI requires a TTY. Showing plain list instead.[/yellow]")
755
+ _render_models_plain(console, get_global_config())
756
+ return True
757
+
758
+ try:
759
+ from ripperdoc.cli.ui.models_tui import run_models_tui
760
+ except (ImportError, ModuleNotFoundError) as exc:
761
+ console.print(
762
+ f"[yellow]Textual UI not available ({escape(str(exc))}). Falling back to Rich UI.[/yellow]"
763
+ )
764
+ return _handle_models_rich_tui(ui)
765
+
766
+ try:
767
+ return bool(run_models_tui())
768
+ except Exception as exc: # noqa: BLE001 - fail safe in interactive UI
769
+ console.print(f"[red]Textual UI failed: {escape(str(exc))}[/red]")
770
+ return _handle_models_rich_tui(ui)
771
+
772
+
22
773
  def _handle(ui: Any, trimmed_arg: str) -> bool:
23
774
  console = ui.console
24
775
  tokens = trimmed_arg.split()
@@ -26,11 +777,13 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
26
777
  config = get_global_config()
27
778
  logger.info(
28
779
  "[models_cmd] Handling /models command",
29
- extra={"subcommand": subcmd or "list", "session_id": getattr(ui, "session_id", None)},
780
+ extra={"subcommand": subcmd or "tui", "session_id": getattr(ui, "session_id", None)},
30
781
  )
31
782
 
32
783
  def print_models_usage() -> None:
33
- console.print("[bold]/models[/bold] — list configured models")
784
+ console.print("[bold]/models[/bold] — open interactive models UI")
785
+ console.print("[bold]/models tui[/bold] — open interactive models UI")
786
+ console.print("[bold]/models list[/bold] — list configured models (plain)")
34
787
  console.print("[bold]/models add <name>[/bold] — add or update a model profile")
35
788
  console.print("[bold]/models edit <name>[/bold] — edit an existing model profile")
36
789
  console.print("[bold]/models delete <name>[/bold] — delete a model profile")
@@ -39,30 +792,17 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
39
792
  "[bold]/models use <pointer> <name>[/bold] — set a specific pointer (main/quick)"
40
793
  )
41
794
 
42
- def parse_int(prompt_text: str, default_value: Optional[int]) -> Optional[int]:
43
- raw = console.input(prompt_text).strip()
44
- if not raw:
45
- return default_value
46
- try:
47
- return int(raw)
48
- except ValueError:
49
- console.print("[yellow]Invalid number, keeping previous value.[/yellow]")
50
- return default_value
51
-
52
- def parse_float(prompt_text: str, default_value: float) -> float:
53
- raw = console.input(prompt_text).strip()
54
- if not raw:
55
- return default_value
56
- try:
57
- return float(raw)
58
- except ValueError:
59
- console.print("[yellow]Invalid number, keeping previous value.[/yellow]")
60
- return default_value
61
-
62
795
  if subcmd in ("help", "-h", "--help"):
63
796
  print_models_usage()
64
797
  return True
65
798
 
799
+ if subcmd in ("", "tui", "ui"):
800
+ return _handle_models_tui(ui)
801
+
802
+ if subcmd in ("list", "ls"):
803
+ _render_models_plain(console, config)
804
+ return True
805
+
66
806
  if subcmd in ("add", "create"):
67
807
  profile_name = tokens[1] if len(tokens) > 1 else console.input("Profile name: ").strip()
68
808
  if not profile_name:
@@ -82,124 +822,12 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
82
822
  return True
83
823
  overwrite = True
84
824
 
85
- current_profile = get_profile_for_pointer("main")
86
- default_provider = (
87
- (current_profile.provider.value) if current_profile else ProviderType.ANTHROPIC.value
825
+ profile, set_as_main = _collect_add_profile_input(
826
+ console, config, existing_profile, get_profile_for_pointer("main")
88
827
  )
89
- provider_input = (
90
- console.input(
91
- f"Protocol ({', '.join(p.value for p in ProviderType)}) [{default_provider}]: "
92
- )
93
- .strip()
94
- .lower()
95
- or default_provider
96
- )
97
- try:
98
- provider = ProviderType(provider_input)
99
- except ValueError:
100
- console.print(f"[red]Invalid provider: {escape(provider_input)}[/red]")
101
- print_models_usage()
102
- return True
103
-
104
- default_model = (
105
- existing_profile.model
106
- if existing_profile
107
- else (current_profile.model if current_profile else "")
108
- )
109
- model_prompt = f"Model name to send{f' [{default_model}]' if default_model else ''}: "
110
- model_name = console.input(model_prompt).strip() or default_model
111
- if not model_name:
112
- console.print("[red]Model name is required.[/red]")
828
+ if not profile:
113
829
  return True
114
830
 
115
- api_key_input = prompt_secret("API key (leave blank to keep unset)").strip()
116
- api_key = api_key_input or (existing_profile.api_key if existing_profile else None)
117
-
118
- auth_token = existing_profile.auth_token if existing_profile else None
119
- if provider == ProviderType.ANTHROPIC:
120
- auth_token_input = prompt_secret(
121
- "Auth token (Anthropic only, leave blank to keep unset)"
122
- ).strip()
123
- auth_token = auth_token_input or auth_token
124
- else:
125
- auth_token = None
126
-
127
- api_base_default = existing_profile.api_base if existing_profile else ""
128
- api_base = (
129
- console.input(
130
- f"API base (optional){f' [{api_base_default}]' if api_base_default else ''}: "
131
- ).strip()
132
- or api_base_default
133
- or None
134
- )
135
-
136
- max_tokens_default = existing_profile.max_tokens if existing_profile else 4096
137
- max_tokens = (
138
- parse_int(
139
- f"Max output tokens [{max_tokens_default}]: ",
140
- max_tokens_default,
141
- )
142
- or max_tokens_default
143
- )
144
-
145
- temp_default = existing_profile.temperature if existing_profile else 0.7
146
- temperature = parse_float(
147
- f"Temperature [{temp_default}]: ",
148
- temp_default,
149
- )
150
-
151
- context_window_default = existing_profile.context_window if existing_profile else None
152
- context_prompt = "Context window tokens (optional"
153
- if context_window_default:
154
- context_prompt += f", current {context_window_default}"
155
- context_prompt += "): "
156
- context_window = parse_int(context_prompt, context_window_default)
157
-
158
- # Vision support prompt
159
- supports_vision_default = existing_profile.supports_vision if existing_profile else None
160
- supports_vision = None
161
- vision_default_display = (
162
- "auto"
163
- if supports_vision_default is None
164
- else ("yes" if supports_vision_default else "no")
165
- )
166
- supports_vision_input = (
167
- console.input(f"Supports vision (images)? [{vision_default_display}] (Y/n/auto): ")
168
- .strip()
169
- .lower()
170
- )
171
- if supports_vision_input in ("y", "yes"):
172
- supports_vision = True
173
- elif supports_vision_input in ("n", "no"):
174
- supports_vision = False
175
- elif supports_vision_input in ("auto", ""):
176
- supports_vision = None
177
- else:
178
- supports_vision = supports_vision_default
179
-
180
- default_set_main = (
181
- not config.model_profiles
182
- or getattr(config.model_pointers, "main", "") not in config.model_profiles
183
- )
184
- set_main_input = (
185
- console.input(f"Set as main model? ({'Y' if default_set_main else 'y'}/N): ")
186
- .strip()
187
- .lower()
188
- )
189
- set_as_main = set_main_input in ("y", "yes") if set_main_input else default_set_main
190
-
191
- profile = ModelProfile(
192
- provider=provider,
193
- model=model_name,
194
- api_key=api_key,
195
- api_base=api_base,
196
- max_tokens=max_tokens,
197
- temperature=temperature,
198
- context_window=context_window,
199
- auth_token=auth_token,
200
- supports_vision=supports_vision,
201
- )
202
-
203
831
  try:
204
832
  add_model_profile(
205
833
  profile_name,
@@ -228,114 +856,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
228
856
  console.print("[red]Model profile not found.[/red]")
229
857
  print_models_usage()
230
858
  return True
231
-
232
- provider_default = existing_profile.provider.value
233
- provider_input = (
234
- console.input(
235
- f"Protocol ({', '.join(p.value for p in ProviderType)}) [{provider_default}]: "
236
- )
237
- .strip()
238
- .lower()
239
- or provider_default
240
- )
241
- try:
242
- provider = ProviderType(provider_input)
243
- except ValueError:
244
- console.print(f"[red]Invalid provider: {escape(provider_input)}[/red]")
859
+ updated_profile = _collect_edit_profile_input(console, existing_profile)
860
+ if not updated_profile:
245
861
  return True
246
862
 
247
- model_name = (
248
- console.input(f"Model name to send [{existing_profile.model}]: ").strip()
249
- or existing_profile.model
250
- )
251
-
252
- api_key_label = "[set]" if existing_profile.api_key else "[not set]"
253
- api_key_prompt = f"API key {api_key_label} (Enter=keep, '-'=clear)"
254
- api_key_input = prompt_secret(api_key_prompt).strip()
255
- if api_key_input == "-":
256
- api_key = None
257
- elif api_key_input:
258
- api_key = api_key_input
259
- else:
260
- api_key = existing_profile.api_key
261
-
262
- auth_token = existing_profile.auth_token
263
- if (
264
- provider == ProviderType.ANTHROPIC
265
- or existing_profile.provider == ProviderType.ANTHROPIC
266
- ):
267
- auth_label = "[set]" if auth_token else "[not set]"
268
- auth_prompt = f"Auth token (Anthropic only) {auth_label} (Enter=keep, '-'=clear)"
269
- auth_token_input = prompt_secret(auth_prompt).strip()
270
- if auth_token_input == "-":
271
- auth_token = None
272
- elif auth_token_input:
273
- auth_token = auth_token_input
274
- else:
275
- auth_token = None
276
-
277
- api_base = (
278
- console.input(f"API base (optional) [{existing_profile.api_base or ''}]: ").strip()
279
- or existing_profile.api_base
280
- )
281
- if api_base == "":
282
- api_base = None
283
-
284
- max_tokens = (
285
- parse_int(
286
- f"Max output tokens [{existing_profile.max_tokens}]: ",
287
- existing_profile.max_tokens,
288
- )
289
- or existing_profile.max_tokens
290
- )
291
-
292
- temperature = parse_float(
293
- f"Temperature [{existing_profile.temperature}]: ",
294
- existing_profile.temperature,
295
- )
296
-
297
- context_window = parse_int(
298
- f"Context window tokens [{existing_profile.context_window or 'unset'}]: ",
299
- existing_profile.context_window,
300
- )
301
-
302
- # Vision support prompt
303
- vision_default_display = (
304
- "auto"
305
- if existing_profile.supports_vision is None
306
- else ("yes" if existing_profile.supports_vision else "no")
307
- )
308
- supports_vision_input = (
309
- console.input(
310
- f"Supports vision (images)? [{vision_default_display}] (Y/n/auto/C=clear): "
311
- )
312
- .strip()
313
- .lower()
314
- )
315
- supports_vision = None
316
- if supports_vision_input in ("y", "yes"):
317
- supports_vision = True
318
- elif supports_vision_input in ("n", "no"):
319
- supports_vision = False
320
- elif supports_vision_input in ("c", "clear", "-"):
321
- supports_vision = None
322
- elif supports_vision_input in ("auto", ""):
323
- supports_vision = existing_profile.supports_vision
324
- else:
325
- supports_vision = existing_profile.supports_vision
326
-
327
- updated_profile = ModelProfile(
328
- provider=provider,
329
- model=model_name,
330
- api_key=api_key,
331
- api_base=api_base,
332
- max_tokens=max_tokens,
333
- temperature=temperature,
334
- context_window=context_window,
335
- auth_token=auth_token,
336
- supports_vision=supports_vision,
337
- )
338
-
339
863
  try:
340
864
  add_model_profile(
341
865
  profile_name,
@@ -433,46 +957,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
433
957
  return True
434
958
 
435
959
  print_models_usage()
436
- pointer_map = config.model_pointers.model_dump()
437
- if not config.model_profiles:
438
- console.print(" • No models configured")
439
- return True
440
-
441
- console.print("\n[bold]Configured Models:[/bold]")
442
- for name, profile in config.model_profiles.items():
443
- markers = [ptr for ptr, value in pointer_map.items() if value == name]
444
- marker_text = f" ({', '.join(markers)})" if markers else ""
445
- console.print(f" • {escape(name)}{marker_text}", markup=False)
446
- console.print(f" protocol: {profile.provider.value}", markup=False)
447
- console.print(f" model: {profile.model}", markup=False)
448
- if profile.api_base:
449
- console.print(f" api_base: {profile.api_base}", markup=False)
450
- if profile.context_window:
451
- console.print(f" context: {profile.context_window} tokens", markup=False)
452
- console.print(
453
- f" max_tokens: {profile.max_tokens}, temperature: {profile.temperature}",
454
- markup=False,
455
- )
456
- console.print(f" api_key: {'***' if profile.api_key else 'Not set'}", markup=False)
457
- if profile.provider == ProviderType.ANTHROPIC:
458
- console.print(
459
- f" auth_token: {'***' if getattr(profile, 'auth_token', None) else 'Not set'}",
460
- markup=False,
461
- )
462
- if profile.openai_tool_mode:
463
- console.print(f" openai_tool_mode: {profile.openai_tool_mode}", markup=False)
464
- if profile.thinking_mode:
465
- console.print(f" thinking_mode: {profile.thinking_mode}", markup=False)
466
- # Display vision support
467
- if profile.supports_vision is None:
468
- vision_display = "auto-detect"
469
- elif profile.supports_vision:
470
- vision_display = "yes"
471
- else:
472
- vision_display = "no"
473
- console.print(f" supports_vision: {vision_display}", markup=False)
474
- pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
475
- console.print(f"[dim]Pointers: {escape(pointer_labels)}[/dim]")
960
+ _render_models_plain(console, config)
476
961
  return True
477
962
 
478
963