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.
- 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/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 +9 -11
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +25 -70
- 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.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, Dict, List, Literal
|
|
7
8
|
|
|
@@ -52,53 +53,72 @@ def _get_scope_info(scope: ScopeType, project_path: Path) -> tuple[str, str]:
|
|
|
52
53
|
return "Local (private)", str(project_path / ".ripperdoc" / "config.local.json")
|
|
53
54
|
|
|
54
55
|
|
|
55
|
-
def _get_rules_for_scope(
|
|
56
|
-
|
|
56
|
+
def _get_rules_for_scope(
|
|
57
|
+
scope: ScopeType, project_path: Path
|
|
58
|
+
) -> tuple[List[str], List[str], List[str]]:
|
|
59
|
+
"""Return (allow_rules, deny_rules, ask_rules) for a given scope."""
|
|
57
60
|
if scope == "user":
|
|
58
61
|
user_config: GlobalConfig = get_global_config()
|
|
59
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
list(user_config.user_allow_rules),
|
|
64
|
+
list(user_config.user_deny_rules),
|
|
65
|
+
list(user_config.user_ask_rules),
|
|
66
|
+
)
|
|
60
67
|
elif scope == "project":
|
|
61
68
|
project_config: ProjectConfig = get_project_config(project_path)
|
|
62
|
-
return
|
|
69
|
+
return (
|
|
70
|
+
list(project_config.bash_allow_rules),
|
|
71
|
+
list(project_config.bash_deny_rules),
|
|
72
|
+
list(project_config.bash_ask_rules),
|
|
73
|
+
)
|
|
63
74
|
else: # local
|
|
64
75
|
local_config: ProjectLocalConfig = get_project_local_config(project_path)
|
|
65
|
-
return
|
|
76
|
+
return (
|
|
77
|
+
list(local_config.local_allow_rules),
|
|
78
|
+
list(local_config.local_deny_rules),
|
|
79
|
+
list(local_config.local_ask_rules),
|
|
80
|
+
)
|
|
66
81
|
|
|
67
82
|
|
|
68
83
|
def _add_rule(
|
|
69
84
|
scope: ScopeType,
|
|
70
|
-
rule_type: Literal["allow", "deny"],
|
|
85
|
+
rule_type: Literal["allow", "deny", "ask"],
|
|
71
86
|
rule: str,
|
|
72
87
|
project_path: Path,
|
|
73
88
|
) -> bool:
|
|
74
89
|
"""Add a rule to the specified scope. Returns True if added, False if already exists."""
|
|
75
90
|
if scope == "user":
|
|
76
91
|
user_config: GlobalConfig = get_global_config()
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
if rule_type == "allow":
|
|
93
|
+
rules = user_config.user_allow_rules
|
|
94
|
+
elif rule_type == "deny":
|
|
95
|
+
rules = user_config.user_deny_rules
|
|
96
|
+
else:
|
|
97
|
+
rules = user_config.user_ask_rules
|
|
80
98
|
if rule in rules:
|
|
81
99
|
return False
|
|
82
100
|
rules.append(rule)
|
|
83
101
|
save_global_config(user_config)
|
|
84
102
|
elif scope == "project":
|
|
85
103
|
project_config: ProjectConfig = get_project_config(project_path)
|
|
86
|
-
|
|
87
|
-
project_config.bash_allow_rules
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
if rule_type == "allow":
|
|
105
|
+
rules = project_config.bash_allow_rules
|
|
106
|
+
elif rule_type == "deny":
|
|
107
|
+
rules = project_config.bash_deny_rules
|
|
108
|
+
else:
|
|
109
|
+
rules = project_config.bash_ask_rules
|
|
91
110
|
if rule in rules:
|
|
92
111
|
return False
|
|
93
112
|
rules.append(rule)
|
|
94
113
|
save_project_config(project_config, project_path)
|
|
95
114
|
else: # local
|
|
96
115
|
local_config: ProjectLocalConfig = get_project_local_config(project_path)
|
|
97
|
-
|
|
98
|
-
local_config.local_allow_rules
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
if rule_type == "allow":
|
|
117
|
+
rules = local_config.local_allow_rules
|
|
118
|
+
elif rule_type == "deny":
|
|
119
|
+
rules = local_config.local_deny_rules
|
|
120
|
+
else:
|
|
121
|
+
rules = local_config.local_ask_rules
|
|
102
122
|
if rule in rules:
|
|
103
123
|
return False
|
|
104
124
|
rules.append(rule)
|
|
@@ -108,38 +128,43 @@ def _add_rule(
|
|
|
108
128
|
|
|
109
129
|
def _remove_rule(
|
|
110
130
|
scope: ScopeType,
|
|
111
|
-
rule_type: Literal["allow", "deny"],
|
|
131
|
+
rule_type: Literal["allow", "deny", "ask"],
|
|
112
132
|
rule: str,
|
|
113
133
|
project_path: Path,
|
|
114
134
|
) -> bool:
|
|
115
135
|
"""Remove a rule from the specified scope. Returns True if removed, False if not found."""
|
|
116
136
|
if scope == "user":
|
|
117
137
|
user_config: GlobalConfig = get_global_config()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
if rule_type == "allow":
|
|
139
|
+
rules = user_config.user_allow_rules
|
|
140
|
+
elif rule_type == "deny":
|
|
141
|
+
rules = user_config.user_deny_rules
|
|
142
|
+
else:
|
|
143
|
+
rules = user_config.user_ask_rules
|
|
121
144
|
if rule not in rules:
|
|
122
145
|
return False
|
|
123
146
|
rules.remove(rule)
|
|
124
147
|
save_global_config(user_config)
|
|
125
148
|
elif scope == "project":
|
|
126
149
|
project_config: ProjectConfig = get_project_config(project_path)
|
|
127
|
-
|
|
128
|
-
project_config.bash_allow_rules
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
if rule_type == "allow":
|
|
151
|
+
rules = project_config.bash_allow_rules
|
|
152
|
+
elif rule_type == "deny":
|
|
153
|
+
rules = project_config.bash_deny_rules
|
|
154
|
+
else:
|
|
155
|
+
rules = project_config.bash_ask_rules
|
|
132
156
|
if rule not in rules:
|
|
133
157
|
return False
|
|
134
158
|
rules.remove(rule)
|
|
135
159
|
save_project_config(project_config, project_path)
|
|
136
160
|
else: # local
|
|
137
161
|
local_config: ProjectLocalConfig = get_project_local_config(project_path)
|
|
138
|
-
|
|
139
|
-
local_config.local_allow_rules
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
if rule_type == "allow":
|
|
163
|
+
rules = local_config.local_allow_rules
|
|
164
|
+
elif rule_type == "deny":
|
|
165
|
+
rules = local_config.local_deny_rules
|
|
166
|
+
else:
|
|
167
|
+
rules = local_config.local_ask_rules
|
|
143
168
|
if rule not in rules:
|
|
144
169
|
return False
|
|
145
170
|
rules.remove(rule)
|
|
@@ -157,12 +182,16 @@ def _render_all_rules(console: Any, project_path: Path) -> None:
|
|
|
157
182
|
has_rules = False
|
|
158
183
|
|
|
159
184
|
for scope in ("user", "project", "local"):
|
|
160
|
-
allow_rules, deny_rules = _get_rules_for_scope(scope, project_path) # type: ignore
|
|
185
|
+
allow_rules, deny_rules, ask_rules = _get_rules_for_scope(scope, project_path) # type: ignore
|
|
161
186
|
|
|
162
187
|
for rule in allow_rules:
|
|
163
188
|
table.add_row(scope, "[green]allow[/green]", escape(rule))
|
|
164
189
|
has_rules = True
|
|
165
190
|
|
|
191
|
+
for rule in ask_rules:
|
|
192
|
+
table.add_row(scope, "[yellow]ask[/yellow]", escape(rule))
|
|
193
|
+
has_rules = True
|
|
194
|
+
|
|
166
195
|
for rule in deny_rules:
|
|
167
196
|
table.add_row(scope, "[red]deny[/red]", escape(rule))
|
|
168
197
|
has_rules = True
|
|
@@ -182,7 +211,7 @@ def _render_all_rules(console: Any, project_path: Path) -> None:
|
|
|
182
211
|
def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> None:
|
|
183
212
|
"""Display rules for a specific scope."""
|
|
184
213
|
heading, config_path = _get_scope_info(scope, project_path)
|
|
185
|
-
allow_rules, deny_rules = _get_rules_for_scope(scope, project_path)
|
|
214
|
+
allow_rules, deny_rules, ask_rules = _get_rules_for_scope(scope, project_path)
|
|
186
215
|
|
|
187
216
|
table = Table(title=f"{heading} Permission Rules", show_header=True, header_style="bold cyan")
|
|
188
217
|
table.add_column("Type", style="dim")
|
|
@@ -193,6 +222,10 @@ def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> N
|
|
|
193
222
|
table.add_row("[green]allow[/green]", escape(rule))
|
|
194
223
|
has_rules = True
|
|
195
224
|
|
|
225
|
+
for rule in ask_rules:
|
|
226
|
+
table.add_row("[yellow]ask[/yellow]", escape(rule))
|
|
227
|
+
has_rules = True
|
|
228
|
+
|
|
196
229
|
for rule in deny_rules:
|
|
197
230
|
table.add_row("[red]deny[/red]", escape(rule))
|
|
198
231
|
has_rules = True
|
|
@@ -205,22 +238,37 @@ def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> N
|
|
|
205
238
|
console.print(f"[dim]Config file: {escape(config_path)}[/dim]")
|
|
206
239
|
|
|
207
240
|
|
|
241
|
+
def _handle_permissions_tui(ui: Any) -> bool:
|
|
242
|
+
project_path = getattr(ui, "project_path", Path.cwd())
|
|
243
|
+
console = ui.console
|
|
244
|
+
if not sys.stdin.isatty():
|
|
245
|
+
console.print("[yellow]Interactive UI requires a TTY. Showing plain list instead.[/yellow]")
|
|
246
|
+
_render_all_rules(console, project_path)
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
from ripperdoc.cli.ui.permissions_tui import run_permissions_tui
|
|
251
|
+
except (ImportError, ModuleNotFoundError) as exc:
|
|
252
|
+
console.print(
|
|
253
|
+
f"[yellow]Textual UI not available ({escape(str(exc))}). Showing plain list.[/yellow]"
|
|
254
|
+
)
|
|
255
|
+
_render_all_rules(console, project_path)
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
return bool(run_permissions_tui(project_path))
|
|
260
|
+
except Exception as exc: # noqa: BLE001 - fail safe in interactive UI
|
|
261
|
+
console.print(f"[red]Textual UI failed: {escape(str(exc))}[/red]")
|
|
262
|
+
_render_all_rules(console, project_path)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
|
|
208
266
|
def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
209
267
|
project_path = getattr(ui, "project_path", Path.cwd())
|
|
210
268
|
args = trimmed_arg.strip().split()
|
|
211
269
|
|
|
212
|
-
# No args: show all rules
|
|
213
270
|
if not args:
|
|
214
|
-
|
|
215
|
-
ui.console.print()
|
|
216
|
-
ui.console.print("[dim]Usage:[/dim]")
|
|
217
|
-
ui.console.print("[dim] /permissions - Show all rules[/dim]")
|
|
218
|
-
ui.console.print("[dim] /permissions <scope> - Show rules for scope[/dim]")
|
|
219
|
-
ui.console.print("[dim] /permissions add <scope> <type> <rule> - Add a rule[/dim]")
|
|
220
|
-
ui.console.print("[dim] /permissions remove <scope> <type> <rule> - Remove a rule[/dim]")
|
|
221
|
-
ui.console.print("[dim]Scopes: user, project, local[/dim]")
|
|
222
|
-
ui.console.print("[dim]Types: allow, deny[/dim]")
|
|
223
|
-
return True
|
|
271
|
+
return _handle_permissions_tui(ui)
|
|
224
272
|
|
|
225
273
|
# Parse command
|
|
226
274
|
action = args[0].lower()
|
|
@@ -233,6 +281,13 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
233
281
|
"private": "local",
|
|
234
282
|
}
|
|
235
283
|
|
|
284
|
+
if action in ("tui", "ui"):
|
|
285
|
+
return _handle_permissions_tui(ui)
|
|
286
|
+
|
|
287
|
+
if action in ("list", "ls"):
|
|
288
|
+
_render_all_rules(ui.console, project_path)
|
|
289
|
+
return True
|
|
290
|
+
|
|
236
291
|
# Single scope display
|
|
237
292
|
if action in scope_aliases:
|
|
238
293
|
display_scope: ScopeType = scope_aliases[action]
|
|
@@ -254,11 +309,11 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
254
309
|
scope: ScopeType = scope_aliases[scope_arg]
|
|
255
310
|
|
|
256
311
|
rule_type_arg = args[2].lower()
|
|
257
|
-
if rule_type_arg not in ("allow", "deny"):
|
|
312
|
+
if rule_type_arg not in ("allow", "deny", "ask"):
|
|
258
313
|
ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
|
|
259
|
-
ui.console.print("[dim]Available types: allow, deny[/dim]")
|
|
314
|
+
ui.console.print("[dim]Available types: allow, ask, deny[/dim]")
|
|
260
315
|
return True
|
|
261
|
-
rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore[assignment]
|
|
316
|
+
rule_type: Literal["allow", "deny", "ask"] = rule_type_arg # type: ignore[assignment]
|
|
262
317
|
|
|
263
318
|
rule = " ".join(args[3:])
|
|
264
319
|
if _add_rule(scope, rule_type, rule, project_path):
|
|
@@ -287,11 +342,11 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
287
342
|
scope = scope_aliases[scope_arg] # type: ignore
|
|
288
343
|
|
|
289
344
|
rule_type_arg = args[2].lower()
|
|
290
|
-
if rule_type_arg not in ("allow", "deny"):
|
|
345
|
+
if rule_type_arg not in ("allow", "deny", "ask"):
|
|
291
346
|
ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
|
|
292
|
-
ui.console.print("[dim]Available types: allow, deny[/dim]")
|
|
347
|
+
ui.console.print("[dim]Available types: allow, ask, deny[/dim]")
|
|
293
348
|
return True
|
|
294
|
-
remove_rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore[assignment]
|
|
349
|
+
remove_rule_type: Literal["allow", "deny", "ask"] = rule_type_arg # type: ignore[assignment]
|
|
295
350
|
|
|
296
351
|
rule = " ".join(args[3:])
|
|
297
352
|
if _remove_rule(scope, remove_rule_type, rule, project_path):
|
|
@@ -10,16 +10,26 @@ from ripperdoc.utils.session_history import (
|
|
|
10
10
|
load_session_messages,
|
|
11
11
|
)
|
|
12
12
|
|
|
13
|
+
from ripperdoc.cli.ui.choice import ChoiceOption, prompt_choice, theme_style
|
|
14
|
+
|
|
13
15
|
from .base import SlashCommand
|
|
14
16
|
|
|
15
17
|
# Number of sessions to display per page
|
|
16
|
-
PAGE_SIZE =
|
|
18
|
+
PAGE_SIZE = 10
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def _format_time(dt: datetime) -> str:
|
|
20
22
|
return dt.strftime("%Y-%m-%d %H:%M")
|
|
21
23
|
|
|
22
24
|
|
|
25
|
+
def _truncate_prompt(prompt: str, max_length: int = 50) -> str:
|
|
26
|
+
"""Truncate prompt text for display."""
|
|
27
|
+
prompt = prompt.strip()
|
|
28
|
+
if len(prompt) > max_length:
|
|
29
|
+
return prompt[:max_length - 3] + "..."
|
|
30
|
+
return prompt
|
|
31
|
+
|
|
32
|
+
|
|
23
33
|
def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
|
|
24
34
|
sessions = list_session_summaries(ui.project_path)
|
|
25
35
|
if not sessions:
|
|
@@ -43,64 +53,64 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
|
|
|
43
53
|
end_idx = min(start_idx + PAGE_SIZE, len(sessions))
|
|
44
54
|
page_sessions = sessions[start_idx:end_idx]
|
|
45
55
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
f"{
|
|
52
|
-
f"{
|
|
53
|
-
|
|
56
|
+
# Build choice options
|
|
57
|
+
options = []
|
|
58
|
+
for summary in page_sessions:
|
|
59
|
+
prompt_preview = _truncate_prompt(summary.last_prompt)
|
|
60
|
+
label = (
|
|
61
|
+
f"<info>{summary.session_id[:8]}...</info> "
|
|
62
|
+
f"({summary.message_count} msgs) "
|
|
63
|
+
f"<dim>{_format_time(summary.created_at)}</dim> "
|
|
64
|
+
f"<dim>{escape(prompt_preview)}</dim>"
|
|
54
65
|
)
|
|
66
|
+
options.append(ChoiceOption(summary.session_id, label))
|
|
55
67
|
|
|
56
|
-
#
|
|
57
|
-
|
|
68
|
+
# Add navigation options
|
|
69
|
+
nav_options = []
|
|
58
70
|
if current_page > 0:
|
|
59
|
-
|
|
71
|
+
nav_options.append(ChoiceOption(
|
|
72
|
+
"__prev__",
|
|
73
|
+
"<dim>←</dim> <dim>Previous page</dim>"
|
|
74
|
+
))
|
|
60
75
|
if current_page < total_pages - 1:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
nav_options.append(ChoiceOption(
|
|
77
|
+
"__next__",
|
|
78
|
+
"<dim>→</dim> <dim>Next page</dim>"
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
# Build the message
|
|
82
|
+
message = f"<question>Select a session to resume</question> <dim>(Page {current_page + 1}/{total_pages})</dim>"
|
|
83
|
+
|
|
84
|
+
# Combine options: sessions first, then navigation
|
|
85
|
+
all_options = options + nav_options
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = prompt_choice(
|
|
89
|
+
message=message,
|
|
90
|
+
options=all_options,
|
|
91
|
+
title="Resume Session",
|
|
92
|
+
allow_esc=True,
|
|
93
|
+
esc_value="__cancel__",
|
|
94
|
+
style=theme_style(),
|
|
95
|
+
)
|
|
96
|
+
except (EOFError, KeyboardInterrupt):
|
|
72
97
|
return None
|
|
73
98
|
|
|
74
|
-
# Handle
|
|
75
|
-
if
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
else:
|
|
80
|
-
ui.console.print("[yellow]Already at the last page.[/yellow]")
|
|
81
|
-
continue
|
|
82
|
-
elif choice_text == "p":
|
|
83
|
-
if current_page > 0:
|
|
84
|
-
current_page -= 1
|
|
85
|
-
continue
|
|
86
|
-
else:
|
|
87
|
-
ui.console.print("[yellow]Already at the first page.[/yellow]")
|
|
88
|
-
continue
|
|
89
|
-
|
|
90
|
-
# Handle session selection
|
|
91
|
-
if not choice_text.isdigit():
|
|
92
|
-
ui.console.print(
|
|
93
|
-
"[red]Please enter a session index number, 'n' for next page, or 'p' for previous page.[/red]"
|
|
94
|
-
)
|
|
99
|
+
# Handle the result
|
|
100
|
+
if result == "__cancel__":
|
|
101
|
+
return None
|
|
102
|
+
elif result == "__next__":
|
|
103
|
+
current_page += 1
|
|
95
104
|
continue
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if idx < 0 or idx >= len(sessions):
|
|
99
|
-
ui.console.print(
|
|
100
|
-
f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
|
|
101
|
-
)
|
|
105
|
+
elif result == "__prev__":
|
|
106
|
+
current_page -= 1
|
|
102
107
|
continue
|
|
103
|
-
|
|
108
|
+
else:
|
|
109
|
+
# Find and return the selected session
|
|
110
|
+
for s in sessions:
|
|
111
|
+
if s.session_id == result:
|
|
112
|
+
return s
|
|
113
|
+
return None
|
|
104
114
|
|
|
105
115
|
|
|
106
116
|
def _handle(ui: Any, arg: str) -> bool:
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
from rich.markup import escape
|
|
9
9
|
from rich.table import Table
|
|
10
10
|
|
|
11
|
+
from ripperdoc.cli.ui.choice import ChoiceOption, prompt_choice, theme_style
|
|
11
12
|
from ripperdoc.core.config import get_global_config, save_global_config
|
|
12
13
|
from ripperdoc.core.theme import (
|
|
13
14
|
BUILTIN_THEMES,
|
|
@@ -111,8 +112,37 @@ def _handle(ui: Any, arg: str) -> bool:
|
|
|
111
112
|
arg = arg.strip().lower()
|
|
112
113
|
|
|
113
114
|
if not arg:
|
|
115
|
+
# Show current theme
|
|
114
116
|
_show_current_theme(ui)
|
|
115
|
-
|
|
117
|
+
|
|
118
|
+
# Use choice component to select theme
|
|
119
|
+
manager = get_theme_manager()
|
|
120
|
+
current_name = manager.current.name
|
|
121
|
+
|
|
122
|
+
options = [
|
|
123
|
+
ChoiceOption(
|
|
124
|
+
name,
|
|
125
|
+
f"<info>{theme.display_name}</info>",
|
|
126
|
+
theme.description,
|
|
127
|
+
is_default=(name == current_name),
|
|
128
|
+
)
|
|
129
|
+
for name, theme in BUILTIN_THEMES.items()
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
selected = prompt_choice(
|
|
133
|
+
message="<question>Choose a theme:</question>",
|
|
134
|
+
options=options,
|
|
135
|
+
title="Available Themes",
|
|
136
|
+
allow_esc=True,
|
|
137
|
+
esc_value=current_name, # ESC keeps current theme
|
|
138
|
+
style=theme_style(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if selected and selected != current_name:
|
|
142
|
+
_switch_theme(ui, selected)
|
|
143
|
+
elif selected == current_name:
|
|
144
|
+
ui.console.print(f"[dim]Theme remains {manager.current.display_name}[/dim]")
|
|
145
|
+
|
|
116
146
|
return True
|
|
117
147
|
|
|
118
148
|
parts = arg.split()
|