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.
Files changed (44) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +5 -0
  3. ripperdoc/cli/commands/__init__.py +71 -6
  4. ripperdoc/cli/commands/clear_cmd.py +1 -0
  5. ripperdoc/cli/commands/exit_cmd.py +1 -1
  6. ripperdoc/cli/commands/help_cmd.py +11 -1
  7. ripperdoc/cli/commands/hooks_cmd.py +636 -0
  8. ripperdoc/cli/commands/permissions_cmd.py +36 -34
  9. ripperdoc/cli/commands/resume_cmd.py +71 -37
  10. ripperdoc/cli/ui/file_mention_completer.py +276 -0
  11. ripperdoc/cli/ui/helpers.py +100 -3
  12. ripperdoc/cli/ui/interrupt_handler.py +175 -0
  13. ripperdoc/cli/ui/message_display.py +249 -0
  14. ripperdoc/cli/ui/panels.py +63 -0
  15. ripperdoc/cli/ui/rich_ui.py +233 -648
  16. ripperdoc/cli/ui/tool_renderers.py +2 -2
  17. ripperdoc/core/agents.py +4 -4
  18. ripperdoc/core/custom_commands.py +411 -0
  19. ripperdoc/core/hooks/__init__.py +99 -0
  20. ripperdoc/core/hooks/config.py +303 -0
  21. ripperdoc/core/hooks/events.py +540 -0
  22. ripperdoc/core/hooks/executor.py +498 -0
  23. ripperdoc/core/hooks/integration.py +353 -0
  24. ripperdoc/core/hooks/manager.py +720 -0
  25. ripperdoc/core/providers/anthropic.py +476 -69
  26. ripperdoc/core/query.py +61 -4
  27. ripperdoc/core/query_utils.py +1 -1
  28. ripperdoc/core/tool.py +1 -1
  29. ripperdoc/tools/bash_tool.py +5 -5
  30. ripperdoc/tools/file_edit_tool.py +2 -2
  31. ripperdoc/tools/file_read_tool.py +2 -2
  32. ripperdoc/tools/multi_edit_tool.py +1 -1
  33. ripperdoc/utils/conversation_compaction.py +476 -0
  34. ripperdoc/utils/message_compaction.py +109 -154
  35. ripperdoc/utils/message_formatting.py +216 -0
  36. ripperdoc/utils/messages.py +31 -9
  37. ripperdoc/utils/path_ignore.py +3 -4
  38. ripperdoc/utils/session_history.py +19 -7
  39. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
  40. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
  41. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
  42. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
  43. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
  44. {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
- config_manager,
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
- config = get_global_config()
59
- return list(config.user_allow_rules), list(config.user_deny_rules)
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
- config = get_project_config(project_path)
62
- return list(config.bash_allow_rules), list(config.bash_deny_rules)
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
- config = get_project_local_config(project_path)
65
- return list(config.local_allow_rules), list(config.local_deny_rules)
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
- config = get_global_config()
77
- rules = config.user_allow_rules if rule_type == "allow" else config.user_deny_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(config)
83
+ save_global_config(user_config)
82
84
  elif scope == "project":
83
- config = get_project_config(project_path)
84
- rules = config.bash_allow_rules if rule_type == "allow" else config.bash_deny_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(config, project_path)
90
+ save_project_config(project_config, project_path)
89
91
  else: # local
90
- config = get_project_local_config(project_path)
91
- rules = config.local_allow_rules if rule_type == "allow" else config.local_deny_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(config, project_path)
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
- config = get_global_config()
108
- rules = config.user_allow_rules if rule_type == "allow" else config.user_deny_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(config)
114
+ save_global_config(user_config)
113
115
  elif scope == "project":
114
- config = get_project_config(project_path)
115
- rules = config.bash_allow_rules if rule_type == "allow" else config.bash_deny_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(config, project_path)
121
+ save_project_config(project_config, project_path)
120
122
  else: # local
121
- config = get_project_local_config(project_path)
122
- rules = config.local_allow_rules if rule_type == "allow" else config.local_deny_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(config, project_path)
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
- scope = scope_aliases[action]
219
- _render_scope_rules(ui.console, scope, project_path) # type: ignore
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] # type: ignore
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
- rule_type = rule_type_arg # type: ignore
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, rule_type, rule, project_path):
279
+ if _remove_rule(scope, remove_rule_type, rule, project_path):
278
280
  ui.console.print(
279
281
  Panel(
280
- f"Removed [{'green' if rule_type == 'allow' else 'red'}]{rule_type}[/] rule from {scope}:\n{escape(rule)}",
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 a numeric arg is provided, try to resolve immediately.
29
+ # If arg is provided, treat it as session id prefix match
27
30
  if arg.strip():
28
- if arg.isdigit():
29
- idx = int(arg)
30
- if 0 <= idx < len(sessions):
31
- return sessions[idx]
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"[red]Invalid session index {escape(str(idx))}. "
34
- f"Choose 0-{len(sessions) - 1}.[/red]"
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
- ui.console.print("\n[bold]Saved sessions:[/bold]")
45
- for idx, summary in enumerate(sessions):
46
- ui.console.print(
47
- f" [{idx}] {summary.session_id} "
48
- f"({summary.message_count} messages, "
49
- f"{_format_time(summary.created_at)} {_format_time(summary.updated_at)}) "
50
- f"{escape(summary.first_prompt)}",
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
- idx = int(choice_text)
62
- if idx < 0 or idx >= len(sessions):
63
- ui.console.print(
64
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
65
- )
66
- return None
67
- return sessions[idx]
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
@@ -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
- from typing import Optional
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"]