ripperdoc 0.2.6__py3-none-any.whl → 0.2.8__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 +5 -0
- ripperdoc/cli/commands/__init__.py +71 -6
- ripperdoc/cli/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +636 -0
- ripperdoc/cli/commands/permissions_cmd.py +36 -34
- ripperdoc/cli/commands/resume_cmd.py +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +276 -0
- ripperdoc/cli/ui/helpers.py +100 -3
- ripperdoc/cli/ui/interrupt_handler.py +175 -0
- ripperdoc/cli/ui/message_display.py +249 -0
- ripperdoc/cli/ui/panels.py +63 -0
- ripperdoc/cli/ui/rich_ui.py +233 -648
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- ripperdoc/core/custom_commands.py +411 -0
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +303 -0
- ripperdoc/core/hooks/events.py +540 -0
- ripperdoc/core/hooks/executor.py +498 -0
- ripperdoc/core/hooks/integration.py +353 -0
- ripperdoc/core/hooks/manager.py +720 -0
- ripperdoc/core/providers/anthropic.py +476 -69
- ripperdoc/core/query.py +61 -4
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +5 -5
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +2 -2
- ripperdoc/tools/multi_edit_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +476 -0
- ripperdoc/utils/message_compaction.py +109 -154
- ripperdoc/utils/message_formatting.py +216 -0
- ripperdoc/utils/messages.py +31 -9
- ripperdoc/utils/path_ignore.py +3 -4
- ripperdoc/utils/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, List, Literal
|
|
6
|
+
from typing import Any, Dict, List, Literal
|
|
7
7
|
|
|
8
8
|
from rich.markup import escape
|
|
9
9
|
from rich.panel import Panel
|
|
10
10
|
from rich.table import Table
|
|
11
11
|
|
|
12
12
|
from ripperdoc.core.config import (
|
|
13
|
-
|
|
13
|
+
GlobalConfig,
|
|
14
|
+
ProjectConfig,
|
|
15
|
+
ProjectLocalConfig,
|
|
14
16
|
get_global_config,
|
|
15
17
|
get_project_config,
|
|
16
18
|
get_project_local_config,
|
|
@@ -55,14 +57,14 @@ def _get_rules_for_scope(
|
|
|
55
57
|
) -> tuple[List[str], List[str]]:
|
|
56
58
|
"""Return (allow_rules, deny_rules) for a given scope."""
|
|
57
59
|
if scope == "user":
|
|
58
|
-
|
|
59
|
-
return list(
|
|
60
|
+
user_config: GlobalConfig = get_global_config()
|
|
61
|
+
return list(user_config.user_allow_rules), list(user_config.user_deny_rules)
|
|
60
62
|
elif scope == "project":
|
|
61
|
-
|
|
62
|
-
return list(
|
|
63
|
+
project_config: ProjectConfig = get_project_config(project_path)
|
|
64
|
+
return list(project_config.bash_allow_rules), list(project_config.bash_deny_rules)
|
|
63
65
|
else: # local
|
|
64
|
-
|
|
65
|
-
return list(
|
|
66
|
+
local_config: ProjectLocalConfig = get_project_local_config(project_path)
|
|
67
|
+
return list(local_config.local_allow_rules), list(local_config.local_deny_rules)
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def _add_rule(
|
|
@@ -73,26 +75,26 @@ def _add_rule(
|
|
|
73
75
|
) -> bool:
|
|
74
76
|
"""Add a rule to the specified scope. Returns True if added, False if already exists."""
|
|
75
77
|
if scope == "user":
|
|
76
|
-
|
|
77
|
-
rules =
|
|
78
|
+
user_config: GlobalConfig = get_global_config()
|
|
79
|
+
rules = user_config.user_allow_rules if rule_type == "allow" else user_config.user_deny_rules
|
|
78
80
|
if rule in rules:
|
|
79
81
|
return False
|
|
80
82
|
rules.append(rule)
|
|
81
|
-
save_global_config(
|
|
83
|
+
save_global_config(user_config)
|
|
82
84
|
elif scope == "project":
|
|
83
|
-
|
|
84
|
-
rules =
|
|
85
|
+
project_config: ProjectConfig = get_project_config(project_path)
|
|
86
|
+
rules = project_config.bash_allow_rules if rule_type == "allow" else project_config.bash_deny_rules
|
|
85
87
|
if rule in rules:
|
|
86
88
|
return False
|
|
87
89
|
rules.append(rule)
|
|
88
|
-
save_project_config(
|
|
90
|
+
save_project_config(project_config, project_path)
|
|
89
91
|
else: # local
|
|
90
|
-
|
|
91
|
-
rules =
|
|
92
|
+
local_config: ProjectLocalConfig = get_project_local_config(project_path)
|
|
93
|
+
rules = local_config.local_allow_rules if rule_type == "allow" else local_config.local_deny_rules
|
|
92
94
|
if rule in rules:
|
|
93
95
|
return False
|
|
94
96
|
rules.append(rule)
|
|
95
|
-
save_project_local_config(
|
|
97
|
+
save_project_local_config(local_config, project_path)
|
|
96
98
|
return True
|
|
97
99
|
|
|
98
100
|
|
|
@@ -104,26 +106,26 @@ def _remove_rule(
|
|
|
104
106
|
) -> bool:
|
|
105
107
|
"""Remove a rule from the specified scope. Returns True if removed, False if not found."""
|
|
106
108
|
if scope == "user":
|
|
107
|
-
|
|
108
|
-
rules =
|
|
109
|
+
user_config: GlobalConfig = get_global_config()
|
|
110
|
+
rules = user_config.user_allow_rules if rule_type == "allow" else user_config.user_deny_rules
|
|
109
111
|
if rule not in rules:
|
|
110
112
|
return False
|
|
111
113
|
rules.remove(rule)
|
|
112
|
-
save_global_config(
|
|
114
|
+
save_global_config(user_config)
|
|
113
115
|
elif scope == "project":
|
|
114
|
-
|
|
115
|
-
rules =
|
|
116
|
+
project_config: ProjectConfig = get_project_config(project_path)
|
|
117
|
+
rules = project_config.bash_allow_rules if rule_type == "allow" else project_config.bash_deny_rules
|
|
116
118
|
if rule not in rules:
|
|
117
119
|
return False
|
|
118
120
|
rules.remove(rule)
|
|
119
|
-
save_project_config(
|
|
121
|
+
save_project_config(project_config, project_path)
|
|
120
122
|
else: # local
|
|
121
|
-
|
|
122
|
-
rules =
|
|
123
|
+
local_config: ProjectLocalConfig = get_project_local_config(project_path)
|
|
124
|
+
rules = local_config.local_allow_rules if rule_type == "allow" else local_config.local_deny_rules
|
|
123
125
|
if rule not in rules:
|
|
124
126
|
return False
|
|
125
127
|
rules.remove(rule)
|
|
126
|
-
save_project_local_config(
|
|
128
|
+
save_project_local_config(local_config, project_path)
|
|
127
129
|
return True
|
|
128
130
|
|
|
129
131
|
|
|
@@ -204,7 +206,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
204
206
|
|
|
205
207
|
# Parse command
|
|
206
208
|
action = args[0].lower()
|
|
207
|
-
scope_aliases = {
|
|
209
|
+
scope_aliases: Dict[str, ScopeType] = {
|
|
208
210
|
"user": "user",
|
|
209
211
|
"global": "user",
|
|
210
212
|
"project": "project",
|
|
@@ -215,8 +217,8 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
215
217
|
|
|
216
218
|
# Single scope display
|
|
217
219
|
if action in scope_aliases:
|
|
218
|
-
|
|
219
|
-
_render_scope_rules(ui.console,
|
|
220
|
+
display_scope: ScopeType = scope_aliases[action]
|
|
221
|
+
_render_scope_rules(ui.console, display_scope, project_path)
|
|
220
222
|
return True
|
|
221
223
|
|
|
222
224
|
# Add rule
|
|
@@ -231,14 +233,14 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
231
233
|
ui.console.print(f"[red]Unknown scope: {escape(scope_arg)}[/red]")
|
|
232
234
|
ui.console.print("[dim]Available scopes: user, project, local[/dim]")
|
|
233
235
|
return True
|
|
234
|
-
scope: ScopeType = scope_aliases[scope_arg]
|
|
236
|
+
scope: ScopeType = scope_aliases[scope_arg]
|
|
235
237
|
|
|
236
238
|
rule_type_arg = args[2].lower()
|
|
237
239
|
if rule_type_arg not in ("allow", "deny"):
|
|
238
240
|
ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
|
|
239
241
|
ui.console.print("[dim]Available types: allow, deny[/dim]")
|
|
240
242
|
return True
|
|
241
|
-
rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore
|
|
243
|
+
rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore[assignment]
|
|
242
244
|
|
|
243
245
|
rule = " ".join(args[3:])
|
|
244
246
|
if _add_rule(scope, rule_type, rule, project_path):
|
|
@@ -271,13 +273,13 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
271
273
|
ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
|
|
272
274
|
ui.console.print("[dim]Available types: allow, deny[/dim]")
|
|
273
275
|
return True
|
|
274
|
-
|
|
276
|
+
remove_rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore[assignment]
|
|
275
277
|
|
|
276
278
|
rule = " ".join(args[3:])
|
|
277
|
-
if _remove_rule(scope,
|
|
279
|
+
if _remove_rule(scope, remove_rule_type, rule, project_path):
|
|
278
280
|
ui.console.print(
|
|
279
281
|
Panel(
|
|
280
|
-
f"Removed [{'green' if
|
|
282
|
+
f"Removed [{'green' if remove_rule_type == 'allow' else 'red'}]{remove_rule_type}[/] rule from {scope}:\n{escape(rule)}",
|
|
281
283
|
title="/permissions",
|
|
282
284
|
)
|
|
283
285
|
)
|
|
@@ -12,6 +12,9 @@ from ripperdoc.utils.session_history import (
|
|
|
12
12
|
|
|
13
13
|
from .base import SlashCommand
|
|
14
14
|
|
|
15
|
+
# Number of sessions to display per page
|
|
16
|
+
PAGE_SIZE = 20
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
def _format_time(dt: datetime) -> str:
|
|
17
20
|
return dt.strftime("%Y-%m-%d %H:%M")
|
|
@@ -23,48 +26,79 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
|
|
|
23
26
|
ui.console.print("[yellow]No saved sessions found for this project.[/yellow]")
|
|
24
27
|
return None
|
|
25
28
|
|
|
26
|
-
# If
|
|
29
|
+
# If arg is provided, treat it as session id prefix match
|
|
27
30
|
if arg.strip():
|
|
28
|
-
if arg.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
match = next((s for s in sessions if s.session_id.startswith(arg.strip())), None)
|
|
32
|
+
if match:
|
|
33
|
+
return match
|
|
34
|
+
ui.console.print(f"[red]No session found matching '{escape(arg)}'.[/red]")
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Pagination settings
|
|
38
|
+
current_page = 0
|
|
39
|
+
total_pages = (len(sessions) + PAGE_SIZE - 1) // PAGE_SIZE
|
|
40
|
+
|
|
41
|
+
while True:
|
|
42
|
+
start_idx = current_page * PAGE_SIZE
|
|
43
|
+
end_idx = min(start_idx + PAGE_SIZE, len(sessions))
|
|
44
|
+
page_sessions = sessions[start_idx:end_idx]
|
|
45
|
+
|
|
46
|
+
ui.console.print(f"\n[bold]Saved sessions (Page {current_page + 1}/{total_pages}):[/bold]")
|
|
47
|
+
for idx, summary in enumerate(page_sessions, start=start_idx):
|
|
32
48
|
ui.console.print(
|
|
33
|
-
f"[
|
|
34
|
-
f"
|
|
49
|
+
f" [{idx}] {summary.session_id} "
|
|
50
|
+
f"({summary.message_count} messages, "
|
|
51
|
+
f"{_format_time(summary.created_at)} → {_format_time(summary.updated_at)}) "
|
|
52
|
+
f"{escape(summary.last_prompt)}",
|
|
53
|
+
markup=False,
|
|
35
54
|
)
|
|
36
|
-
else:
|
|
37
|
-
# Treat arg as session id if it matches.
|
|
38
|
-
match = next((s for s in sessions if s.session_id.startswith(arg.strip())), None)
|
|
39
|
-
if match:
|
|
40
|
-
return match
|
|
41
|
-
ui.console.print(f"[red]No session found matching '{escape(arg)}'.[/red]")
|
|
42
|
-
return None
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
markup=False,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
choice_text = ui.console.input("\nSelect a session index (Enter to cancel): ").strip()
|
|
55
|
-
if not choice_text:
|
|
56
|
-
return None
|
|
57
|
-
if not choice_text.isdigit():
|
|
58
|
-
ui.console.print("[red]Please enter a number.[/red]")
|
|
59
|
-
return None
|
|
56
|
+
# Show navigation hints
|
|
57
|
+
nav_hints = []
|
|
58
|
+
if current_page > 0:
|
|
59
|
+
nav_hints.append("'p' for previous page")
|
|
60
|
+
if current_page < total_pages - 1:
|
|
61
|
+
nav_hints.append("'n' for next page")
|
|
62
|
+
nav_hints.append("Enter to cancel")
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
prompt = "\nSelect session index"
|
|
65
|
+
if nav_hints:
|
|
66
|
+
prompt += f" ({', '.join(nav_hints)})"
|
|
67
|
+
prompt += ": "
|
|
68
|
+
|
|
69
|
+
choice_text = ui.console.input(prompt).strip().lower()
|
|
70
|
+
|
|
71
|
+
if not choice_text:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Handle pagination commands
|
|
75
|
+
if choice_text == 'n':
|
|
76
|
+
if current_page < total_pages - 1:
|
|
77
|
+
current_page += 1
|
|
78
|
+
continue
|
|
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("[red]Please enter a session index number, 'n' for next page, or 'p' for previous page.[/red]")
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
idx = int(choice_text)
|
|
96
|
+
if idx < 0 or idx >= len(sessions):
|
|
97
|
+
ui.console.print(
|
|
98
|
+
f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
return sessions[idx]
|
|
68
102
|
|
|
69
103
|
|
|
70
104
|
def _handle(ui: Any, arg: str) -> bool:
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""File mention completer for @ symbol completion.
|
|
2
|
+
|
|
3
|
+
This module provides file path completion when users type @ followed by a filename.
|
|
4
|
+
Supports recursive search across the entire project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Iterable, List, Set
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
11
|
+
|
|
12
|
+
from ripperdoc.utils.path_ignore import should_skip_path, IgnoreFilter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileMentionCompleter(Completer):
|
|
16
|
+
"""Autocomplete for file paths when typing @.
|
|
17
|
+
|
|
18
|
+
Supports recursive search - typing 'cli' will match 'ripperdoc/cli/cli.py'
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, project_path: Path, ignore_filter: IgnoreFilter):
|
|
22
|
+
"""Initialize the file mention completer.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
project_path: Root path of the project
|
|
26
|
+
ignore_filter: Pre-built ignore filter for filtering files
|
|
27
|
+
"""
|
|
28
|
+
self.project_path = project_path
|
|
29
|
+
self.ignore_filter = ignore_filter
|
|
30
|
+
|
|
31
|
+
def _collect_files_recursive(self, root_dir: Path, max_depth: int = 5) -> List[Path]:
|
|
32
|
+
"""Recursively collect all files from root_dir, respecting ignore rules.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
root_dir: Directory to search from
|
|
36
|
+
max_depth: Maximum directory depth to search
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of file paths relative to project root
|
|
40
|
+
"""
|
|
41
|
+
files = []
|
|
42
|
+
|
|
43
|
+
def _walk(current_dir: Path, depth: int) -> None:
|
|
44
|
+
if depth > max_depth:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
for item in current_dir.iterdir():
|
|
49
|
+
# Use the project's ignore filter to skip files
|
|
50
|
+
if should_skip_path(
|
|
51
|
+
item,
|
|
52
|
+
self.project_path,
|
|
53
|
+
ignore_filter=self.ignore_filter,
|
|
54
|
+
skip_hidden=True,
|
|
55
|
+
):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if item.is_file():
|
|
59
|
+
files.append(item)
|
|
60
|
+
elif item.is_dir():
|
|
61
|
+
# Recurse into subdirectory
|
|
62
|
+
_walk(item, depth + 1)
|
|
63
|
+
except (OSError, PermissionError):
|
|
64
|
+
# Skip directories we can't read
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
_walk(root_dir, 0)
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
|
|
71
|
+
"""Get completion suggestions for the current input.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
document: The current document/input
|
|
75
|
+
complete_event: Completion event
|
|
76
|
+
|
|
77
|
+
Yields:
|
|
78
|
+
Completion objects with file paths
|
|
79
|
+
"""
|
|
80
|
+
text = document.text_before_cursor
|
|
81
|
+
|
|
82
|
+
# Find the last @ symbol in the text
|
|
83
|
+
at_pos = text.rfind("@")
|
|
84
|
+
if at_pos == -1:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Extract the query after the @ symbol
|
|
88
|
+
query = text[at_pos + 1:].strip()
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
matches = []
|
|
92
|
+
seen: Set[str] = set()
|
|
93
|
+
|
|
94
|
+
def _add_match(display_path: str, item: Path, meta: str, score: int) -> None:
|
|
95
|
+
if display_path in seen:
|
|
96
|
+
return
|
|
97
|
+
seen.add(display_path)
|
|
98
|
+
matches.append((display_path, item, meta, score))
|
|
99
|
+
|
|
100
|
+
# If query contains path separator, do directory-based search
|
|
101
|
+
if "/" in query or "\\" in query:
|
|
102
|
+
# User is typing a specific path
|
|
103
|
+
query_path = Path(query.replace("\\", "/"))
|
|
104
|
+
|
|
105
|
+
if query.endswith(("/", "\\")):
|
|
106
|
+
# Show contents of this directory
|
|
107
|
+
search_dir = self.project_path / query_path
|
|
108
|
+
if search_dir.exists() and search_dir.is_dir():
|
|
109
|
+
for item in sorted(search_dir.iterdir()):
|
|
110
|
+
if should_skip_path(
|
|
111
|
+
item,
|
|
112
|
+
self.project_path,
|
|
113
|
+
ignore_filter=self.ignore_filter,
|
|
114
|
+
skip_hidden=True,
|
|
115
|
+
):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
rel_path = item.relative_to(self.project_path)
|
|
120
|
+
display_path = str(rel_path)
|
|
121
|
+
if item.is_dir():
|
|
122
|
+
display_path += "/"
|
|
123
|
+
|
|
124
|
+
# Right side: show type only
|
|
125
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
126
|
+
|
|
127
|
+
_add_match(display_path, item, meta, 0)
|
|
128
|
+
except ValueError:
|
|
129
|
+
continue
|
|
130
|
+
else:
|
|
131
|
+
# Match files in the parent directory
|
|
132
|
+
parent_dir = self.project_path / query_path.parent
|
|
133
|
+
pattern = f"{query_path.name}*"
|
|
134
|
+
|
|
135
|
+
if parent_dir.exists() and parent_dir.is_dir():
|
|
136
|
+
for item in sorted(parent_dir.iterdir()):
|
|
137
|
+
if should_skip_path(
|
|
138
|
+
item,
|
|
139
|
+
self.project_path,
|
|
140
|
+
ignore_filter=self.ignore_filter,
|
|
141
|
+
skip_hidden=True,
|
|
142
|
+
):
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
import fnmatch
|
|
146
|
+
if fnmatch.fnmatch(item.name.lower(), pattern.lower()):
|
|
147
|
+
try:
|
|
148
|
+
rel_path = item.relative_to(self.project_path)
|
|
149
|
+
display_path = str(rel_path)
|
|
150
|
+
if item.is_dir():
|
|
151
|
+
display_path += "/"
|
|
152
|
+
|
|
153
|
+
# Right side: show type only
|
|
154
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
155
|
+
|
|
156
|
+
_add_match(display_path, item, meta, 0)
|
|
157
|
+
except ValueError:
|
|
158
|
+
continue
|
|
159
|
+
else:
|
|
160
|
+
# Recursive search: match query against filename anywhere in project
|
|
161
|
+
if not query:
|
|
162
|
+
# No query: show top-level items only
|
|
163
|
+
for item in sorted(self.project_path.iterdir()):
|
|
164
|
+
if should_skip_path(
|
|
165
|
+
item,
|
|
166
|
+
self.project_path,
|
|
167
|
+
ignore_filter=self.ignore_filter,
|
|
168
|
+
skip_hidden=True,
|
|
169
|
+
):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
rel_path = item.relative_to(self.project_path)
|
|
174
|
+
display_path = str(rel_path)
|
|
175
|
+
if item.is_dir():
|
|
176
|
+
display_path += "/"
|
|
177
|
+
|
|
178
|
+
# Right side: show type only
|
|
179
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
180
|
+
_add_match(display_path, item, meta, 0)
|
|
181
|
+
except ValueError:
|
|
182
|
+
continue
|
|
183
|
+
else:
|
|
184
|
+
# First, suggest top-level entries that match the prefix to support step-by-step navigation
|
|
185
|
+
query_lower = query.lower()
|
|
186
|
+
for item in sorted(self.project_path.iterdir()):
|
|
187
|
+
if should_skip_path(
|
|
188
|
+
item,
|
|
189
|
+
self.project_path,
|
|
190
|
+
ignore_filter=self.ignore_filter,
|
|
191
|
+
skip_hidden=True,
|
|
192
|
+
):
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
name_lower = item.name.lower()
|
|
196
|
+
if query_lower in name_lower:
|
|
197
|
+
score = 500
|
|
198
|
+
if name_lower.startswith(query_lower):
|
|
199
|
+
score += 50
|
|
200
|
+
if name_lower == query_lower:
|
|
201
|
+
score += 100
|
|
202
|
+
|
|
203
|
+
rel_path = item.relative_to(self.project_path)
|
|
204
|
+
display_path = str(rel_path)
|
|
205
|
+
if item.is_dir():
|
|
206
|
+
display_path += "/"
|
|
207
|
+
|
|
208
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
209
|
+
_add_match(display_path, item, meta, score)
|
|
210
|
+
|
|
211
|
+
# If the query exactly matches a directory, also surface its children for quicker drilling
|
|
212
|
+
dir_candidate = self.project_path / query
|
|
213
|
+
if dir_candidate.exists() and dir_candidate.is_dir():
|
|
214
|
+
for item in sorted(dir_candidate.iterdir()):
|
|
215
|
+
if should_skip_path(
|
|
216
|
+
item,
|
|
217
|
+
self.project_path,
|
|
218
|
+
ignore_filter=self.ignore_filter,
|
|
219
|
+
skip_hidden=True,
|
|
220
|
+
):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
rel_path = item.relative_to(self.project_path)
|
|
225
|
+
display_path = str(rel_path)
|
|
226
|
+
if item.is_dir():
|
|
227
|
+
display_path += "/"
|
|
228
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
229
|
+
_add_match(display_path, item, meta, 400)
|
|
230
|
+
except ValueError:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Recursively search for files matching the query
|
|
234
|
+
all_files = self._collect_files_recursive(self.project_path)
|
|
235
|
+
|
|
236
|
+
for file_path in all_files:
|
|
237
|
+
try:
|
|
238
|
+
rel_path = file_path.relative_to(self.project_path)
|
|
239
|
+
file_name = file_path.name
|
|
240
|
+
|
|
241
|
+
# Match against filename
|
|
242
|
+
if query_lower in file_name.lower():
|
|
243
|
+
# Calculate relevance score (prefer exact matches and shorter names)
|
|
244
|
+
score = 0
|
|
245
|
+
if file_name.lower().startswith(query_lower):
|
|
246
|
+
score += 100 # Prefix match is highly relevant
|
|
247
|
+
if file_name.lower() == query_lower:
|
|
248
|
+
score += 200 # Exact match is most relevant
|
|
249
|
+
score -= len(str(rel_path)) # Prefer shorter paths
|
|
250
|
+
|
|
251
|
+
display_path = str(rel_path)
|
|
252
|
+
|
|
253
|
+
# Right side: show type only
|
|
254
|
+
meta = "📄 file"
|
|
255
|
+
|
|
256
|
+
_add_match(display_path, file_path, meta, score)
|
|
257
|
+
except ValueError:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Sort matches by score (descending) and then by path
|
|
261
|
+
matches.sort(key=lambda x: (-x[3], x[0]))
|
|
262
|
+
|
|
263
|
+
# Limit results to prevent overwhelming the user
|
|
264
|
+
matches = matches[:50]
|
|
265
|
+
|
|
266
|
+
for display_path, item, meta, score in matches:
|
|
267
|
+
yield Completion(
|
|
268
|
+
display_path,
|
|
269
|
+
start_position=-(len(query) + 1), # +1 to include the @ symbol
|
|
270
|
+
display=display_path,
|
|
271
|
+
display_meta=meta,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
except (OSError, ValueError, RuntimeError):
|
|
275
|
+
# Silently ignore errors during completion
|
|
276
|
+
pass
|
ripperdoc/cli/ui/helpers.py
CHANGED
|
@@ -1,10 +1,107 @@
|
|
|
1
|
-
"""Shared helper functions for the Rich UI."""
|
|
1
|
+
"""Shared helper functions and constants for the Rich UI."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import random
|
|
4
|
+
from typing import List, Optional
|
|
4
5
|
|
|
5
6
|
from ripperdoc.core.config import get_current_model_profile, get_global_config, ModelProfile
|
|
6
7
|
|
|
7
8
|
|
|
9
|
+
# Fun words to display while the AI is "thinking"
|
|
10
|
+
THINKING_WORDS: List[str] = [
|
|
11
|
+
"Accomplishing",
|
|
12
|
+
"Actioning",
|
|
13
|
+
"Actualizing",
|
|
14
|
+
"Baking",
|
|
15
|
+
"Booping",
|
|
16
|
+
"Brewing",
|
|
17
|
+
"Calculating",
|
|
18
|
+
"Cerebrating",
|
|
19
|
+
"Channelling",
|
|
20
|
+
"Churning",
|
|
21
|
+
"Coalescing",
|
|
22
|
+
"Cogitating",
|
|
23
|
+
"Computing",
|
|
24
|
+
"Combobulating",
|
|
25
|
+
"Concocting",
|
|
26
|
+
"Conjuring",
|
|
27
|
+
"Considering",
|
|
28
|
+
"Contemplating",
|
|
29
|
+
"Cooking",
|
|
30
|
+
"Crafting",
|
|
31
|
+
"Creating",
|
|
32
|
+
"Crunching",
|
|
33
|
+
"Deciphering",
|
|
34
|
+
"Deliberating",
|
|
35
|
+
"Determining",
|
|
36
|
+
"Discombobulating",
|
|
37
|
+
"Divining",
|
|
38
|
+
"Doing",
|
|
39
|
+
"Effecting",
|
|
40
|
+
"Elucidating",
|
|
41
|
+
"Enchanting",
|
|
42
|
+
"Envisioning",
|
|
43
|
+
"Finagling",
|
|
44
|
+
"Flibbertigibbeting",
|
|
45
|
+
"Forging",
|
|
46
|
+
"Forming",
|
|
47
|
+
"Frolicking",
|
|
48
|
+
"Generating",
|
|
49
|
+
"Germinating",
|
|
50
|
+
"Hatching",
|
|
51
|
+
"Herding",
|
|
52
|
+
"Honking",
|
|
53
|
+
"Ideating",
|
|
54
|
+
"Imagining",
|
|
55
|
+
"Incubating",
|
|
56
|
+
"Inferring",
|
|
57
|
+
"Manifesting",
|
|
58
|
+
"Marinating",
|
|
59
|
+
"Meandering",
|
|
60
|
+
"Moseying",
|
|
61
|
+
"Mulling",
|
|
62
|
+
"Mustering",
|
|
63
|
+
"Musing",
|
|
64
|
+
"Noodling",
|
|
65
|
+
"Percolating",
|
|
66
|
+
"Perusing",
|
|
67
|
+
"Philosophising",
|
|
68
|
+
"Pontificating",
|
|
69
|
+
"Pondering",
|
|
70
|
+
"Processing",
|
|
71
|
+
"Puttering",
|
|
72
|
+
"Puzzling",
|
|
73
|
+
"Reticulating",
|
|
74
|
+
"Ruminating",
|
|
75
|
+
"Scheming",
|
|
76
|
+
"Schlepping",
|
|
77
|
+
"Shimmying",
|
|
78
|
+
"Simmering",
|
|
79
|
+
"Smooshing",
|
|
80
|
+
"Spelunking",
|
|
81
|
+
"Spinning",
|
|
82
|
+
"Stewing",
|
|
83
|
+
"Sussing",
|
|
84
|
+
"Synthesizing",
|
|
85
|
+
"Thinking",
|
|
86
|
+
"Tinkering",
|
|
87
|
+
"Transmuting",
|
|
88
|
+
"Unfurling",
|
|
89
|
+
"Unravelling",
|
|
90
|
+
"Vibing",
|
|
91
|
+
"Wandering",
|
|
92
|
+
"Whirring",
|
|
93
|
+
"Wibbling",
|
|
94
|
+
"Wizarding",
|
|
95
|
+
"Working",
|
|
96
|
+
"Wrangling",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_random_thinking_word() -> str:
|
|
101
|
+
"""Return a random thinking word for spinner display."""
|
|
102
|
+
return random.choice(THINKING_WORDS)
|
|
103
|
+
|
|
104
|
+
|
|
8
105
|
def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
|
|
9
106
|
"""Return the configured ModelProfile for a logical pointer or default."""
|
|
10
107
|
profile = get_current_model_profile(pointer)
|
|
@@ -19,4 +116,4 @@ def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
|
|
|
19
116
|
return None
|
|
20
117
|
|
|
21
118
|
|
|
22
|
-
__all__ = ["get_profile_for_pointer"]
|
|
119
|
+
__all__ = ["get_profile_for_pointer", "THINKING_WORDS", "get_random_thinking_word"]
|