ripperdoc 0.2.7__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/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 +1 -1
- ripperdoc/cli/ui/file_mention_completer.py +62 -7
- ripperdoc/cli/ui/interrupt_handler.py +1 -1
- ripperdoc/cli/ui/message_display.py +1 -1
- ripperdoc/cli/ui/panels.py +13 -10
- ripperdoc/cli/ui/rich_ui.py +92 -24
- 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/tools/bash_tool.py +4 -4
- ripperdoc/tools/file_read_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +3 -3
- ripperdoc/utils/path_ignore.py +3 -4
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +31 -23
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.7.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
|
)
|
|
@@ -61,7 +61,7 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
|
|
|
61
61
|
nav_hints.append("'n' for next page")
|
|
62
62
|
nav_hints.append("Enter to cancel")
|
|
63
63
|
|
|
64
|
-
prompt =
|
|
64
|
+
prompt = "\nSelect session index"
|
|
65
65
|
if nav_hints:
|
|
66
66
|
prompt += f" ({', '.join(nav_hints)})"
|
|
67
67
|
prompt += ": "
|
|
@@ -5,7 +5,7 @@ Supports recursive search across the entire project.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any, Iterable, List
|
|
8
|
+
from typing import Any, Iterable, List, Set
|
|
9
9
|
|
|
10
10
|
from prompt_toolkit.completion import Completer, Completion
|
|
11
11
|
|
|
@@ -40,7 +40,7 @@ class FileMentionCompleter(Completer):
|
|
|
40
40
|
"""
|
|
41
41
|
files = []
|
|
42
42
|
|
|
43
|
-
def _walk(current_dir: Path, depth: int):
|
|
43
|
+
def _walk(current_dir: Path, depth: int) -> None:
|
|
44
44
|
if depth > max_depth:
|
|
45
45
|
return
|
|
46
46
|
|
|
@@ -89,6 +89,13 @@ class FileMentionCompleter(Completer):
|
|
|
89
89
|
|
|
90
90
|
try:
|
|
91
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))
|
|
92
99
|
|
|
93
100
|
# If query contains path separator, do directory-based search
|
|
94
101
|
if "/" in query or "\\" in query:
|
|
@@ -117,7 +124,7 @@ class FileMentionCompleter(Completer):
|
|
|
117
124
|
# Right side: show type only
|
|
118
125
|
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
119
126
|
|
|
120
|
-
|
|
127
|
+
_add_match(display_path, item, meta, 0)
|
|
121
128
|
except ValueError:
|
|
122
129
|
continue
|
|
123
130
|
else:
|
|
@@ -146,7 +153,7 @@ class FileMentionCompleter(Completer):
|
|
|
146
153
|
# Right side: show type only
|
|
147
154
|
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
148
155
|
|
|
149
|
-
|
|
156
|
+
_add_match(display_path, item, meta, 0)
|
|
150
157
|
except ValueError:
|
|
151
158
|
continue
|
|
152
159
|
else:
|
|
@@ -170,13 +177,61 @@ class FileMentionCompleter(Completer):
|
|
|
170
177
|
|
|
171
178
|
# Right side: show type only
|
|
172
179
|
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
173
|
-
|
|
180
|
+
_add_match(display_path, item, meta, 0)
|
|
174
181
|
except ValueError:
|
|
175
182
|
continue
|
|
176
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
|
+
|
|
177
233
|
# Recursively search for files matching the query
|
|
178
234
|
all_files = self._collect_files_recursive(self.project_path)
|
|
179
|
-
query_lower = query.lower()
|
|
180
235
|
|
|
181
236
|
for file_path in all_files:
|
|
182
237
|
try:
|
|
@@ -198,7 +253,7 @@ class FileMentionCompleter(Completer):
|
|
|
198
253
|
# Right side: show type only
|
|
199
254
|
meta = "📄 file"
|
|
200
255
|
|
|
201
|
-
|
|
256
|
+
_add_match(display_path, file_path, meta, score)
|
|
202
257
|
except ValueError:
|
|
203
258
|
continue
|
|
204
259
|
|
|
@@ -20,7 +20,7 @@ INTERRUPT_KEYS: Set[str] = {'\x1b', '\x03'} # ESC, Ctrl+C
|
|
|
20
20
|
class InterruptHandler:
|
|
21
21
|
"""Handles keyboard interrupt detection during async operations."""
|
|
22
22
|
|
|
23
|
-
def __init__(self):
|
|
23
|
+
def __init__(self) -> None:
|
|
24
24
|
"""Initialize the interrupt handler."""
|
|
25
25
|
self._query_interrupted: bool = False
|
|
26
26
|
self._esc_listener_active: bool = False
|
|
@@ -6,7 +6,7 @@ This module handles rendering conversation messages to the terminal, including:
|
|
|
6
6
|
- Reasoning block rendering
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from typing import Any, Callable,
|
|
9
|
+
from typing import Any, Callable, List, Optional, Tuple, Union
|
|
10
10
|
|
|
11
11
|
from rich.console import Console
|
|
12
12
|
from rich.markdown import Markdown
|
ripperdoc/cli/ui/panels.py
CHANGED
|
@@ -12,6 +12,7 @@ from rich.text import Text
|
|
|
12
12
|
from rich import box
|
|
13
13
|
|
|
14
14
|
from ripperdoc import __version__
|
|
15
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def create_welcome_panel() -> Panel:
|
|
@@ -35,16 +36,18 @@ You can read files, edit code, run commands, and help with various programming t
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
def create_status_bar() -> Text:
|
|
38
|
-
"""Create a status bar
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
"""Create a status bar with current model information."""
|
|
40
|
+
profile = get_profile_for_pointer("main")
|
|
41
|
+
model_name = profile.model if profile else "Not configured"
|
|
42
|
+
|
|
43
|
+
status_text = Text()
|
|
44
|
+
status_text.append("Ripperdoc", style="bold cyan")
|
|
45
|
+
status_text.append(" • ")
|
|
46
|
+
status_text.append(model_name, style="dim")
|
|
47
|
+
status_text.append(" • ")
|
|
48
|
+
status_text.append("Ready", style="green")
|
|
49
|
+
|
|
50
|
+
return status_text
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def print_shortcuts(console: Console) -> None:
|
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -4,37 +4,37 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
-
import contextlib
|
|
8
7
|
import json
|
|
9
|
-
import os
|
|
10
8
|
import sys
|
|
11
9
|
import uuid
|
|
12
|
-
import re
|
|
13
10
|
from typing import List, Dict, Any, Optional, Union, Iterable
|
|
14
11
|
from pathlib import Path
|
|
15
12
|
|
|
16
13
|
from rich.console import Console
|
|
17
|
-
from rich.markdown import Markdown
|
|
18
14
|
from rich.markup import escape
|
|
19
15
|
|
|
20
16
|
from prompt_toolkit import PromptSession
|
|
21
17
|
from prompt_toolkit.completion import Completer, Completion, merge_completers
|
|
22
|
-
from prompt_toolkit.shortcuts.prompt import CompleteStyle
|
|
23
18
|
from prompt_toolkit.history import InMemoryHistory
|
|
24
19
|
from prompt_toolkit.key_binding import KeyBindings
|
|
25
|
-
from prompt_toolkit.
|
|
20
|
+
from prompt_toolkit.shortcuts.prompt import CompleteStyle
|
|
26
21
|
|
|
27
22
|
from ripperdoc.core.config import get_global_config, provider_protocol
|
|
28
23
|
from ripperdoc.core.default_tools import get_default_tools
|
|
29
24
|
from ripperdoc.core.query import query, QueryContext
|
|
30
25
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
31
26
|
from ripperdoc.core.skills import build_skill_summary, load_all_skills
|
|
27
|
+
from ripperdoc.core.hooks.manager import hook_manager
|
|
32
28
|
from ripperdoc.cli.commands import (
|
|
33
29
|
get_slash_command,
|
|
30
|
+
get_custom_command,
|
|
34
31
|
list_slash_commands,
|
|
32
|
+
list_custom_commands,
|
|
35
33
|
slash_command_completions,
|
|
34
|
+
expand_command_content,
|
|
35
|
+
CustomCommandDefinition,
|
|
36
36
|
)
|
|
37
|
-
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
37
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
38
38
|
from ripperdoc.core.permissions import make_permission_checker
|
|
39
39
|
from ripperdoc.cli.ui.spinner import Spinner
|
|
40
40
|
from ripperdoc.cli.ui.thinking_spinner import ThinkingSpinner
|
|
@@ -72,7 +72,6 @@ from ripperdoc.utils.messages import (
|
|
|
72
72
|
AssistantMessage,
|
|
73
73
|
ProgressMessage,
|
|
74
74
|
create_user_message,
|
|
75
|
-
create_assistant_message,
|
|
76
75
|
)
|
|
77
76
|
from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
78
77
|
from ripperdoc.utils.path_ignore import build_ignore_filter
|
|
@@ -113,7 +112,7 @@ class RichUI:
|
|
|
113
112
|
self._current_tool: Optional[str] = None
|
|
114
113
|
self._should_exit: bool = False
|
|
115
114
|
self.command_list = list_slash_commands()
|
|
116
|
-
self.
|
|
115
|
+
self._custom_command_list = list_custom_commands()
|
|
117
116
|
self._prompt_session: Optional[PromptSession] = None
|
|
118
117
|
self.project_path = Path.cwd()
|
|
119
118
|
# Track a stable session identifier for the current UI run.
|
|
@@ -162,6 +161,17 @@ class RichUI:
|
|
|
162
161
|
extra={"session_id": self.session_id},
|
|
163
162
|
)
|
|
164
163
|
|
|
164
|
+
# Initialize hook manager with project context
|
|
165
|
+
hook_manager.set_project_dir(self.project_path)
|
|
166
|
+
hook_manager.set_session_id(self.session_id)
|
|
167
|
+
logger.debug(
|
|
168
|
+
"[ui] Initialized hook manager",
|
|
169
|
+
extra={
|
|
170
|
+
"session_id": self.session_id,
|
|
171
|
+
"project_path": str(self.project_path),
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
165
175
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
166
176
|
# Properties for backward compatibility with interrupt handler
|
|
167
177
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -452,7 +462,7 @@ class RichUI:
|
|
|
452
462
|
"tokens_saved": result.tokens_saved,
|
|
453
463
|
},
|
|
454
464
|
)
|
|
455
|
-
return result.messages
|
|
465
|
+
return result.messages
|
|
456
466
|
elif isinstance(result, CompactionError):
|
|
457
467
|
logger.warning(
|
|
458
468
|
"[ui] Auto-compaction failed: %s",
|
|
@@ -789,8 +799,9 @@ class RichUI:
|
|
|
789
799
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
790
800
|
return self._run_async(coro)
|
|
791
801
|
|
|
792
|
-
def handle_slash_command(self, user_input: str) -> bool:
|
|
793
|
-
"""Handle slash commands. Returns True if
|
|
802
|
+
def handle_slash_command(self, user_input: str) -> bool | str:
|
|
803
|
+
"""Handle slash commands. Returns True if handled as built-in, False if not a command,
|
|
804
|
+
or a string if it's a custom command that should be sent to the AI."""
|
|
794
805
|
|
|
795
806
|
if not user_input.startswith("/"):
|
|
796
807
|
return False
|
|
@@ -802,12 +813,32 @@ class RichUI:
|
|
|
802
813
|
|
|
803
814
|
command_name = parts[0].lower()
|
|
804
815
|
trimmed_arg = " ".join(parts[1:]).strip()
|
|
816
|
+
|
|
817
|
+
# First, try built-in commands
|
|
805
818
|
command = get_slash_command(command_name)
|
|
806
|
-
if command is None:
|
|
807
|
-
|
|
808
|
-
|
|
819
|
+
if command is not None:
|
|
820
|
+
return command.handler(self, trimmed_arg)
|
|
821
|
+
|
|
822
|
+
# Then, try custom commands
|
|
823
|
+
custom_cmd = get_custom_command(command_name, self.project_path)
|
|
824
|
+
if custom_cmd is not None:
|
|
825
|
+
# Expand the custom command content
|
|
826
|
+
expanded_content = expand_command_content(
|
|
827
|
+
custom_cmd, trimmed_arg, self.project_path
|
|
828
|
+
)
|
|
809
829
|
|
|
810
|
-
|
|
830
|
+
# Show a hint that this is from a custom command
|
|
831
|
+
self.console.print(
|
|
832
|
+
f"[dim]Running custom command: /{command_name}[/dim]"
|
|
833
|
+
)
|
|
834
|
+
if custom_cmd.argument_hint and trimmed_arg:
|
|
835
|
+
self.console.print(f"[dim]Arguments: {trimmed_arg}[/dim]")
|
|
836
|
+
|
|
837
|
+
# Return the expanded content to be processed as a query
|
|
838
|
+
return expanded_content
|
|
839
|
+
|
|
840
|
+
self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
|
|
841
|
+
return True
|
|
811
842
|
|
|
812
843
|
def get_prompt_session(self) -> PromptSession:
|
|
813
844
|
"""Create (or return) the prompt session with command completion."""
|
|
@@ -815,35 +846,68 @@ class RichUI:
|
|
|
815
846
|
return self._prompt_session
|
|
816
847
|
|
|
817
848
|
class SlashCommandCompleter(Completer):
|
|
818
|
-
"""Autocomplete for slash commands."""
|
|
849
|
+
"""Autocomplete for slash commands including custom commands."""
|
|
819
850
|
|
|
820
|
-
def __init__(self,
|
|
821
|
-
self.
|
|
851
|
+
def __init__(self, project_path: Path):
|
|
852
|
+
self.project_path = project_path
|
|
822
853
|
|
|
823
854
|
def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
|
|
824
855
|
text = document.text_before_cursor
|
|
825
856
|
if not text.startswith("/"):
|
|
826
857
|
return
|
|
827
858
|
query = text[1:]
|
|
828
|
-
|
|
859
|
+
# Get completions including custom commands
|
|
860
|
+
completions = slash_command_completions(self.project_path)
|
|
861
|
+
for name, cmd in completions:
|
|
829
862
|
if name.startswith(query):
|
|
863
|
+
# Handle both SlashCommand and CustomCommandDefinition
|
|
864
|
+
description = cmd.description
|
|
865
|
+
# Add hint for custom commands
|
|
866
|
+
if isinstance(cmd, CustomCommandDefinition):
|
|
867
|
+
hint = cmd.argument_hint or ""
|
|
868
|
+
display = f"{name} {hint}".strip() if hint else name
|
|
869
|
+
display_meta = f"[custom] {description}"
|
|
870
|
+
else:
|
|
871
|
+
display = name
|
|
872
|
+
display_meta = description
|
|
830
873
|
yield Completion(
|
|
831
874
|
name,
|
|
832
875
|
start_position=-len(query),
|
|
833
|
-
display=
|
|
834
|
-
display_meta=
|
|
876
|
+
display=display,
|
|
877
|
+
display_meta=display_meta,
|
|
835
878
|
)
|
|
836
879
|
|
|
837
880
|
# Merge both completers
|
|
838
|
-
slash_completer = SlashCommandCompleter(self.
|
|
881
|
+
slash_completer = SlashCommandCompleter(self.project_path)
|
|
839
882
|
file_completer = FileMentionCompleter(self.project_path, self._ignore_filter)
|
|
840
883
|
combined_completer = merge_completers([slash_completer, file_completer])
|
|
841
884
|
|
|
885
|
+
key_bindings = KeyBindings()
|
|
886
|
+
|
|
887
|
+
@key_bindings.add("enter")
|
|
888
|
+
def _(event: Any) -> None:
|
|
889
|
+
"""Accept completion if menu is open; otherwise submit line."""
|
|
890
|
+
buf = event.current_buffer
|
|
891
|
+
if buf.complete_state and buf.complete_state.current_completion:
|
|
892
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
893
|
+
return
|
|
894
|
+
buf.validate_and_handle()
|
|
895
|
+
|
|
896
|
+
@key_bindings.add("tab")
|
|
897
|
+
def _(event: Any) -> None:
|
|
898
|
+
"""Use Tab to accept the highlighted completion when visible."""
|
|
899
|
+
buf = event.current_buffer
|
|
900
|
+
if buf.complete_state and buf.complete_state.current_completion:
|
|
901
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
902
|
+
else:
|
|
903
|
+
buf.start_completion(select_first=True)
|
|
904
|
+
|
|
842
905
|
self._prompt_session = PromptSession(
|
|
843
906
|
completer=combined_completer,
|
|
844
907
|
complete_style=CompleteStyle.COLUMN,
|
|
845
908
|
complete_while_typing=True,
|
|
846
909
|
history=InMemoryHistory(),
|
|
910
|
+
key_bindings=key_bindings,
|
|
847
911
|
)
|
|
848
912
|
return self._prompt_session
|
|
849
913
|
|
|
@@ -888,7 +952,11 @@ class RichUI:
|
|
|
888
952
|
handled = self.handle_slash_command(user_input)
|
|
889
953
|
if self._should_exit:
|
|
890
954
|
break
|
|
891
|
-
|
|
955
|
+
# If handled is a string, it's expanded custom command content
|
|
956
|
+
if isinstance(handled, str):
|
|
957
|
+
# Process the expanded custom command as a query
|
|
958
|
+
user_input = handled
|
|
959
|
+
elif handled:
|
|
892
960
|
console.print() # spacing
|
|
893
961
|
continue
|
|
894
962
|
|