ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 +33 -115
- ripperdoc/cli/commands/__init__.py +70 -6
- ripperdoc/cli/commands/agents_cmd.py +6 -3
- ripperdoc/cli/commands/clear_cmd.py +1 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +610 -0
- ripperdoc/cli/commands/models_cmd.py +26 -9
- ripperdoc/cli/commands/permissions_cmd.py +57 -37
- ripperdoc/cli/commands/resume_cmd.py +6 -4
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +64 -8
- ripperdoc/cli/ui/interrupt_handler.py +3 -4
- ripperdoc/cli/ui/message_display.py +5 -3
- ripperdoc/cli/ui/panels.py +13 -10
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +196 -77
- ripperdoc/cli/ui/spinner.py +25 -1
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +215 -0
- ripperdoc/core/agents.py +9 -3
- ripperdoc/core/config.py +49 -12
- ripperdoc/core/custom_commands.py +412 -0
- ripperdoc/core/default_tools.py +11 -2
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +301 -0
- ripperdoc/core/hooks/events.py +535 -0
- ripperdoc/core/hooks/executor.py +496 -0
- ripperdoc/core/hooks/integration.py +344 -0
- ripperdoc/core/hooks/manager.py +745 -0
- ripperdoc/core/permissions.py +40 -8
- ripperdoc/core/providers/anthropic.py +548 -68
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +60 -5
- ripperdoc/core/query.py +140 -39
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +9 -5
- ripperdoc/sdk/client.py +2 -2
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +2 -1
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +30 -20
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +8 -4
- ripperdoc/tools/file_read_tool.py +9 -5
- ripperdoc/tools/file_write_tool.py +9 -5
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +5 -4
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +11 -7
- ripperdoc/utils/file_watch.py +8 -2
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +11 -7
- ripperdoc/utils/messages.py +105 -66
- ripperdoc/utils/path_ignore.py +38 -12
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
- ripperdoc-0.2.9.dist-info/RECORD +123 -0
- ripperdoc-0.2.7.dist-info/RECORD +0 -113
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
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
|
|
@@ -97,15 +96,16 @@ class RichUI:
|
|
|
97
96
|
|
|
98
97
|
def __init__(
|
|
99
98
|
self,
|
|
100
|
-
|
|
99
|
+
yolo_mode: bool = False,
|
|
101
100
|
verbose: bool = False,
|
|
101
|
+
show_full_thinking: Optional[bool] = None,
|
|
102
102
|
session_id: Optional[str] = None,
|
|
103
103
|
log_file_path: Optional[Path] = None,
|
|
104
104
|
):
|
|
105
105
|
self._loop = asyncio.new_event_loop()
|
|
106
106
|
asyncio.set_event_loop(self._loop)
|
|
107
107
|
self.console = console
|
|
108
|
-
self.
|
|
108
|
+
self.yolo_mode = yolo_mode
|
|
109
109
|
self.verbose = verbose
|
|
110
110
|
self.conversation_messages: List[ConversationMessage] = []
|
|
111
111
|
self._saved_conversation: Optional[List[ConversationMessage]] = None
|
|
@@ -113,7 +113,7 @@ class RichUI:
|
|
|
113
113
|
self._current_tool: Optional[str] = None
|
|
114
114
|
self._should_exit: bool = False
|
|
115
115
|
self.command_list = list_slash_commands()
|
|
116
|
-
self.
|
|
116
|
+
self._custom_command_list = list_custom_commands()
|
|
117
117
|
self._prompt_session: Optional[PromptSession] = None
|
|
118
118
|
self.project_path = Path.cwd()
|
|
119
119
|
# Track a stable session identifier for the current UI run.
|
|
@@ -129,16 +129,17 @@ class RichUI:
|
|
|
129
129
|
"session_id": self.session_id,
|
|
130
130
|
"project_path": str(self.project_path),
|
|
131
131
|
"log_file": str(self.log_file_path),
|
|
132
|
-
"
|
|
132
|
+
"yolo_mode": self.yolo_mode,
|
|
133
133
|
"verbose": self.verbose,
|
|
134
134
|
},
|
|
135
135
|
)
|
|
136
136
|
self._session_history = SessionHistory(self.project_path, self.session_id)
|
|
137
137
|
self._permission_checker = (
|
|
138
|
-
make_permission_checker(self.project_path,
|
|
138
|
+
None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
|
|
139
139
|
)
|
|
140
140
|
# Build ignore filter for file completion
|
|
141
141
|
from ripperdoc.utils.path_ignore import get_project_ignore_patterns
|
|
142
|
+
|
|
142
143
|
project_patterns = get_project_ignore_patterns()
|
|
143
144
|
self._ignore_filter = build_ignore_filter(
|
|
144
145
|
self.project_path,
|
|
@@ -147,8 +148,17 @@ class RichUI:
|
|
|
147
148
|
include_gitignore=True,
|
|
148
149
|
)
|
|
149
150
|
|
|
151
|
+
# Get global config for display preferences
|
|
152
|
+
config = get_global_config()
|
|
153
|
+
if show_full_thinking is None:
|
|
154
|
+
self.show_full_thinking = config.show_full_thinking
|
|
155
|
+
else:
|
|
156
|
+
self.show_full_thinking = show_full_thinking
|
|
157
|
+
|
|
150
158
|
# Initialize component handlers
|
|
151
|
-
self._message_display = MessageDisplay(
|
|
159
|
+
self._message_display = MessageDisplay(
|
|
160
|
+
self.console, self.verbose, self.show_full_thinking
|
|
161
|
+
)
|
|
152
162
|
self._interrupt_handler = InterruptHandler()
|
|
153
163
|
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
154
164
|
|
|
@@ -158,10 +168,22 @@ class RichUI:
|
|
|
158
168
|
except (OSError, RuntimeError, ConnectionError) as exc:
|
|
159
169
|
logger.warning(
|
|
160
170
|
"[ui] Failed to initialize MCP runtime at startup: %s: %s",
|
|
161
|
-
type(exc).__name__,
|
|
171
|
+
type(exc).__name__,
|
|
172
|
+
exc,
|
|
162
173
|
extra={"session_id": self.session_id},
|
|
163
174
|
)
|
|
164
175
|
|
|
176
|
+
# Initialize hook manager with project context
|
|
177
|
+
hook_manager.set_project_dir(self.project_path)
|
|
178
|
+
hook_manager.set_session_id(self.session_id)
|
|
179
|
+
logger.debug(
|
|
180
|
+
"[ui] Initialized hook manager",
|
|
181
|
+
extra={
|
|
182
|
+
"session_id": self.session_id,
|
|
183
|
+
"project_path": str(self.project_path),
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
165
187
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
166
188
|
# Properties for backward compatibility with interrupt handler
|
|
167
189
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -205,7 +227,8 @@ class RichUI:
|
|
|
205
227
|
# Logging failures should never interrupt the UI flow
|
|
206
228
|
logger.warning(
|
|
207
229
|
"[ui] Failed to append message to session history: %s: %s",
|
|
208
|
-
type(exc).__name__,
|
|
230
|
+
type(exc).__name__,
|
|
231
|
+
exc,
|
|
209
232
|
extra={"session_id": self.session_id},
|
|
210
233
|
)
|
|
211
234
|
|
|
@@ -219,7 +242,8 @@ class RichUI:
|
|
|
219
242
|
except (AttributeError, TypeError, ValueError) as exc:
|
|
220
243
|
logger.warning(
|
|
221
244
|
"[ui] Failed to append prompt history: %s: %s",
|
|
222
|
-
type(exc).__name__,
|
|
245
|
+
type(exc).__name__,
|
|
246
|
+
exc,
|
|
223
247
|
extra={"session_id": self.session_id},
|
|
224
248
|
)
|
|
225
249
|
|
|
@@ -452,7 +476,7 @@ class RichUI:
|
|
|
452
476
|
"tokens_saved": result.tokens_saved,
|
|
453
477
|
},
|
|
454
478
|
)
|
|
455
|
-
return result.messages
|
|
479
|
+
return result.messages
|
|
456
480
|
elif isinstance(result, CompactionError):
|
|
457
481
|
logger.warning(
|
|
458
482
|
"[ui] Auto-compaction failed: %s",
|
|
@@ -466,29 +490,36 @@ class RichUI:
|
|
|
466
490
|
self,
|
|
467
491
|
message: AssistantMessage,
|
|
468
492
|
tool_registry: Dict[str, Dict[str, Any]],
|
|
493
|
+
spinner: Optional[ThinkingSpinner] = None,
|
|
469
494
|
) -> Optional[str]:
|
|
470
495
|
"""Handle an assistant message from the query stream.
|
|
471
496
|
|
|
472
497
|
Returns:
|
|
473
498
|
The last tool name if a tool_use block was processed, None otherwise.
|
|
474
499
|
"""
|
|
500
|
+
# Factory to create pause context - spinner.paused() if spinner exists, else no-op
|
|
501
|
+
from contextlib import nullcontext
|
|
502
|
+
|
|
503
|
+
pause = lambda: spinner.paused() if spinner else nullcontext() # noqa: E731
|
|
504
|
+
|
|
475
505
|
meta = getattr(getattr(message, "message", None), "metadata", {}) or {}
|
|
476
506
|
reasoning_payload = (
|
|
477
|
-
meta.get("reasoning_content")
|
|
478
|
-
or meta.get("reasoning")
|
|
479
|
-
or meta.get("reasoning_details")
|
|
507
|
+
meta.get("reasoning_content") or meta.get("reasoning") or meta.get("reasoning_details")
|
|
480
508
|
)
|
|
481
509
|
if reasoning_payload:
|
|
482
|
-
|
|
510
|
+
with pause():
|
|
511
|
+
self._print_reasoning(reasoning_payload)
|
|
483
512
|
|
|
484
513
|
last_tool_name: Optional[str] = None
|
|
485
514
|
|
|
486
515
|
if isinstance(message.message.content, str):
|
|
487
|
-
|
|
516
|
+
with pause():
|
|
517
|
+
self.display_message("Ripperdoc", message.message.content)
|
|
488
518
|
elif isinstance(message.message.content, list):
|
|
489
519
|
for block in message.message.content:
|
|
490
520
|
if hasattr(block, "type") and block.type == "text" and block.text:
|
|
491
|
-
|
|
521
|
+
with pause():
|
|
522
|
+
self.display_message("Ripperdoc", block.text)
|
|
492
523
|
elif hasattr(block, "type") and block.type == "tool_use":
|
|
493
524
|
tool_name = getattr(block, "name", "unknown tool")
|
|
494
525
|
tool_args = getattr(block, "input", {})
|
|
@@ -502,9 +533,10 @@ class RichUI:
|
|
|
502
533
|
}
|
|
503
534
|
|
|
504
535
|
if tool_name == "Task":
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
536
|
+
with pause():
|
|
537
|
+
self.display_message(
|
|
538
|
+
tool_name, "", is_tool=True, tool_type="call", tool_args=tool_args
|
|
539
|
+
)
|
|
508
540
|
if tool_use_id:
|
|
509
541
|
tool_registry[tool_use_id]["printed"] = True
|
|
510
542
|
|
|
@@ -517,11 +549,17 @@ class RichUI:
|
|
|
517
549
|
message: UserMessage,
|
|
518
550
|
tool_registry: Dict[str, Dict[str, Any]],
|
|
519
551
|
last_tool_name: Optional[str],
|
|
552
|
+
spinner: Optional[ThinkingSpinner] = None,
|
|
520
553
|
) -> None:
|
|
521
554
|
"""Handle a user message containing tool results."""
|
|
522
555
|
if not isinstance(message.message.content, list):
|
|
523
556
|
return
|
|
524
557
|
|
|
558
|
+
# Factory to create pause context - spinner.paused() if spinner exists, else no-op
|
|
559
|
+
from contextlib import nullcontext
|
|
560
|
+
|
|
561
|
+
pause = lambda: spinner.paused() if spinner else nullcontext() # noqa: E731
|
|
562
|
+
|
|
525
563
|
for block in message.message.content:
|
|
526
564
|
if not (hasattr(block, "type") and block.type == "tool_result" and block.text):
|
|
527
565
|
continue
|
|
@@ -535,25 +573,27 @@ class RichUI:
|
|
|
535
573
|
if entry:
|
|
536
574
|
tool_name = entry.get("name", tool_name)
|
|
537
575
|
if not entry.get("printed"):
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
576
|
+
with pause():
|
|
577
|
+
self.display_message(
|
|
578
|
+
tool_name,
|
|
579
|
+
"",
|
|
580
|
+
is_tool=True,
|
|
581
|
+
tool_type="call",
|
|
582
|
+
tool_args=entry.get("args", {}),
|
|
583
|
+
)
|
|
545
584
|
entry["printed"] = True
|
|
546
585
|
elif last_tool_name:
|
|
547
586
|
tool_name = last_tool_name
|
|
548
587
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
588
|
+
with pause():
|
|
589
|
+
self.display_message(
|
|
590
|
+
tool_name,
|
|
591
|
+
block.text,
|
|
592
|
+
is_tool=True,
|
|
593
|
+
tool_type="result",
|
|
594
|
+
tool_data=tool_data,
|
|
595
|
+
tool_error=is_error,
|
|
596
|
+
)
|
|
557
597
|
|
|
558
598
|
def _handle_progress_message(
|
|
559
599
|
self,
|
|
@@ -567,14 +607,17 @@ class RichUI:
|
|
|
567
607
|
Updated output token estimate.
|
|
568
608
|
"""
|
|
569
609
|
if self.verbose:
|
|
570
|
-
|
|
610
|
+
with spinner.paused():
|
|
611
|
+
self.display_message("System", f"Progress: {message.content}", is_tool=True)
|
|
571
612
|
elif message.content and isinstance(message.content, str):
|
|
572
613
|
if message.content.startswith("Subagent: "):
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
614
|
+
with spinner.paused():
|
|
615
|
+
self.display_message(
|
|
616
|
+
"Subagent", message.content[len("Subagent: ") :], is_tool=True
|
|
617
|
+
)
|
|
576
618
|
elif message.content.startswith("Subagent"):
|
|
577
|
-
|
|
619
|
+
with spinner.paused():
|
|
620
|
+
self.display_message("Subagent", message.content, is_tool=True)
|
|
578
621
|
|
|
579
622
|
if message.tool_use_id == "stream":
|
|
580
623
|
delta_tokens = estimate_tokens(message.content)
|
|
@@ -590,7 +633,7 @@ class RichUI:
|
|
|
590
633
|
# Initialize or reset query context
|
|
591
634
|
if not self.query_context:
|
|
592
635
|
self.query_context = QueryContext(
|
|
593
|
-
tools=self.get_default_tools(),
|
|
636
|
+
tools=self.get_default_tools(), yolo_mode=self.yolo_mode, verbose=self.verbose
|
|
594
637
|
)
|
|
595
638
|
else:
|
|
596
639
|
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
@@ -674,7 +717,8 @@ class RichUI:
|
|
|
674
717
|
except (RuntimeError, ValueError, OSError) as exc:
|
|
675
718
|
logger.debug(
|
|
676
719
|
"[ui] Failed to restart spinner after permission check: %s: %s",
|
|
677
|
-
type(exc).__name__,
|
|
720
|
+
type(exc).__name__,
|
|
721
|
+
exc,
|
|
678
722
|
)
|
|
679
723
|
|
|
680
724
|
# Process query stream
|
|
@@ -692,12 +736,14 @@ class RichUI:
|
|
|
692
736
|
permission_checker, # type: ignore[arg-type]
|
|
693
737
|
):
|
|
694
738
|
if message.type == "assistant" and isinstance(message, AssistantMessage):
|
|
695
|
-
result = self._handle_assistant_message(message, tool_registry)
|
|
739
|
+
result = self._handle_assistant_message(message, tool_registry, spinner)
|
|
696
740
|
if result:
|
|
697
741
|
last_tool_name = result
|
|
698
742
|
|
|
699
743
|
elif message.type == "user" and isinstance(message, UserMessage):
|
|
700
|
-
self._handle_tool_result_message(
|
|
744
|
+
self._handle_tool_result_message(
|
|
745
|
+
message, tool_registry, last_tool_name, spinner
|
|
746
|
+
)
|
|
701
747
|
|
|
702
748
|
elif message.type == "progress" and isinstance(message, ProgressMessage):
|
|
703
749
|
output_token_est = self._handle_progress_message(
|
|
@@ -713,7 +759,8 @@ class RichUI:
|
|
|
713
759
|
except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
|
|
714
760
|
logger.warning(
|
|
715
761
|
"[ui] Error while processing streamed query response: %s: %s",
|
|
716
|
-
type(e).__name__,
|
|
762
|
+
type(e).__name__,
|
|
763
|
+
e,
|
|
717
764
|
extra={"session_id": self.session_id},
|
|
718
765
|
)
|
|
719
766
|
self.display_message("System", f"Error: {str(e)}", is_tool=True)
|
|
@@ -723,7 +770,8 @@ class RichUI:
|
|
|
723
770
|
except (RuntimeError, ValueError, OSError) as exc:
|
|
724
771
|
logger.warning(
|
|
725
772
|
"[ui] Failed to stop spinner: %s: %s",
|
|
726
|
-
type(exc).__name__,
|
|
773
|
+
type(exc).__name__,
|
|
774
|
+
exc,
|
|
727
775
|
extra={"session_id": self.session_id},
|
|
728
776
|
)
|
|
729
777
|
|
|
@@ -743,7 +791,8 @@ class RichUI:
|
|
|
743
791
|
except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as exc:
|
|
744
792
|
logger.warning(
|
|
745
793
|
"[ui] Error during query processing: %s: %s",
|
|
746
|
-
type(exc).__name__,
|
|
794
|
+
type(exc).__name__,
|
|
795
|
+
exc,
|
|
747
796
|
extra={"session_id": self.session_id},
|
|
748
797
|
)
|
|
749
798
|
self.display_message("System", f"Error: {str(exc)}", is_tool=True)
|
|
@@ -789,8 +838,9 @@ class RichUI:
|
|
|
789
838
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
790
839
|
return self._run_async(coro)
|
|
791
840
|
|
|
792
|
-
def handle_slash_command(self, user_input: str) -> bool:
|
|
793
|
-
"""Handle slash commands. Returns True if
|
|
841
|
+
def handle_slash_command(self, user_input: str) -> bool | str:
|
|
842
|
+
"""Handle slash commands. Returns True if handled as built-in, False if not a command,
|
|
843
|
+
or a string if it's a custom command that should be sent to the AI."""
|
|
794
844
|
|
|
795
845
|
if not user_input.startswith("/"):
|
|
796
846
|
return False
|
|
@@ -802,12 +852,28 @@ class RichUI:
|
|
|
802
852
|
|
|
803
853
|
command_name = parts[0].lower()
|
|
804
854
|
trimmed_arg = " ".join(parts[1:]).strip()
|
|
855
|
+
|
|
856
|
+
# First, try built-in commands
|
|
805
857
|
command = get_slash_command(command_name)
|
|
806
|
-
if command is None:
|
|
807
|
-
|
|
808
|
-
|
|
858
|
+
if command is not None:
|
|
859
|
+
return command.handler(self, trimmed_arg)
|
|
860
|
+
|
|
861
|
+
# Then, try custom commands
|
|
862
|
+
custom_cmd = get_custom_command(command_name, self.project_path)
|
|
863
|
+
if custom_cmd is not None:
|
|
864
|
+
# Expand the custom command content
|
|
865
|
+
expanded_content = expand_command_content(custom_cmd, trimmed_arg, self.project_path)
|
|
866
|
+
|
|
867
|
+
# Show a hint that this is from a custom command
|
|
868
|
+
self.console.print(f"[dim]Running custom command: /{command_name}[/dim]")
|
|
869
|
+
if custom_cmd.argument_hint and trimmed_arg:
|
|
870
|
+
self.console.print(f"[dim]Arguments: {trimmed_arg}[/dim]")
|
|
871
|
+
|
|
872
|
+
# Return the expanded content to be processed as a query
|
|
873
|
+
return expanded_content
|
|
809
874
|
|
|
810
|
-
|
|
875
|
+
self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
|
|
876
|
+
return True
|
|
811
877
|
|
|
812
878
|
def get_prompt_session(self) -> PromptSession:
|
|
813
879
|
"""Create (or return) the prompt session with command completion."""
|
|
@@ -815,35 +881,68 @@ class RichUI:
|
|
|
815
881
|
return self._prompt_session
|
|
816
882
|
|
|
817
883
|
class SlashCommandCompleter(Completer):
|
|
818
|
-
"""Autocomplete for slash commands."""
|
|
884
|
+
"""Autocomplete for slash commands including custom commands."""
|
|
819
885
|
|
|
820
|
-
def __init__(self,
|
|
821
|
-
self.
|
|
886
|
+
def __init__(self, project_path: Path):
|
|
887
|
+
self.project_path = project_path
|
|
822
888
|
|
|
823
889
|
def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
|
|
824
890
|
text = document.text_before_cursor
|
|
825
891
|
if not text.startswith("/"):
|
|
826
892
|
return
|
|
827
893
|
query = text[1:]
|
|
828
|
-
|
|
894
|
+
# Get completions including custom commands
|
|
895
|
+
completions = slash_command_completions(self.project_path)
|
|
896
|
+
for name, cmd in completions:
|
|
829
897
|
if name.startswith(query):
|
|
898
|
+
# Handle both SlashCommand and CustomCommandDefinition
|
|
899
|
+
description = cmd.description
|
|
900
|
+
# Add hint for custom commands
|
|
901
|
+
if isinstance(cmd, CustomCommandDefinition):
|
|
902
|
+
hint = cmd.argument_hint or ""
|
|
903
|
+
display = f"{name} {hint}".strip() if hint else name
|
|
904
|
+
display_meta = f"[custom] {description}"
|
|
905
|
+
else:
|
|
906
|
+
display = name
|
|
907
|
+
display_meta = description
|
|
830
908
|
yield Completion(
|
|
831
909
|
name,
|
|
832
910
|
start_position=-len(query),
|
|
833
|
-
display=
|
|
834
|
-
display_meta=
|
|
911
|
+
display=display,
|
|
912
|
+
display_meta=display_meta,
|
|
835
913
|
)
|
|
836
914
|
|
|
837
915
|
# Merge both completers
|
|
838
|
-
slash_completer = SlashCommandCompleter(self.
|
|
916
|
+
slash_completer = SlashCommandCompleter(self.project_path)
|
|
839
917
|
file_completer = FileMentionCompleter(self.project_path, self._ignore_filter)
|
|
840
918
|
combined_completer = merge_completers([slash_completer, file_completer])
|
|
841
919
|
|
|
920
|
+
key_bindings = KeyBindings()
|
|
921
|
+
|
|
922
|
+
@key_bindings.add("enter")
|
|
923
|
+
def _(event: Any) -> None:
|
|
924
|
+
"""Accept completion if menu is open; otherwise submit line."""
|
|
925
|
+
buf = event.current_buffer
|
|
926
|
+
if buf.complete_state and buf.complete_state.current_completion:
|
|
927
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
928
|
+
return
|
|
929
|
+
buf.validate_and_handle()
|
|
930
|
+
|
|
931
|
+
@key_bindings.add("tab")
|
|
932
|
+
def _(event: Any) -> None:
|
|
933
|
+
"""Use Tab to accept the highlighted completion when visible."""
|
|
934
|
+
buf = event.current_buffer
|
|
935
|
+
if buf.complete_state and buf.complete_state.current_completion:
|
|
936
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
937
|
+
else:
|
|
938
|
+
buf.start_completion(select_first=True)
|
|
939
|
+
|
|
842
940
|
self._prompt_session = PromptSession(
|
|
843
941
|
completer=combined_completer,
|
|
844
942
|
complete_style=CompleteStyle.COLUMN,
|
|
845
943
|
complete_while_typing=True,
|
|
846
944
|
history=InMemoryHistory(),
|
|
945
|
+
key_bindings=key_bindings,
|
|
847
946
|
)
|
|
848
947
|
return self._prompt_session
|
|
849
948
|
|
|
@@ -857,7 +956,9 @@ class RichUI:
|
|
|
857
956
|
# Display status
|
|
858
957
|
console.print(create_status_bar())
|
|
859
958
|
console.print()
|
|
860
|
-
console.print(
|
|
959
|
+
console.print(
|
|
960
|
+
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. Press ESC to interrupt a running query.[/dim]\n"
|
|
961
|
+
)
|
|
861
962
|
|
|
862
963
|
session = self.get_prompt_session()
|
|
863
964
|
logger.info(
|
|
@@ -888,7 +989,11 @@ class RichUI:
|
|
|
888
989
|
handled = self.handle_slash_command(user_input)
|
|
889
990
|
if self._should_exit:
|
|
890
991
|
break
|
|
891
|
-
|
|
992
|
+
# If handled is a string, it's expanded custom command content
|
|
993
|
+
if isinstance(handled, str):
|
|
994
|
+
# Process the expanded custom command as a query
|
|
995
|
+
user_input = handled
|
|
996
|
+
elif handled:
|
|
892
997
|
console.print() # spacing
|
|
893
998
|
continue
|
|
894
999
|
|
|
@@ -904,7 +1009,9 @@ class RichUI:
|
|
|
904
1009
|
interrupted = self._run_async_with_esc_interrupt(self.process_query(user_input))
|
|
905
1010
|
|
|
906
1011
|
if interrupted:
|
|
907
|
-
console.print(
|
|
1012
|
+
console.print(
|
|
1013
|
+
"\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
|
|
1014
|
+
)
|
|
908
1015
|
logger.info(
|
|
909
1016
|
"[ui] Query interrupted by ESC key",
|
|
910
1017
|
extra={"session_id": self.session_id},
|
|
@@ -923,11 +1030,19 @@ class RichUI:
|
|
|
923
1030
|
except EOFError:
|
|
924
1031
|
console.print("\n[yellow]Goodbye![/yellow]")
|
|
925
1032
|
break
|
|
926
|
-
except (
|
|
1033
|
+
except (
|
|
1034
|
+
OSError,
|
|
1035
|
+
ConnectionError,
|
|
1036
|
+
RuntimeError,
|
|
1037
|
+
ValueError,
|
|
1038
|
+
KeyError,
|
|
1039
|
+
TypeError,
|
|
1040
|
+
) as e:
|
|
927
1041
|
console.print(f"[red]Error: {escape(str(e))}[/]")
|
|
928
1042
|
logger.warning(
|
|
929
1043
|
"[ui] Error in interactive loop: %s: %s",
|
|
930
|
-
type(e).__name__,
|
|
1044
|
+
type(e).__name__,
|
|
1045
|
+
e,
|
|
931
1046
|
extra={"session_id": self.session_id},
|
|
932
1047
|
)
|
|
933
1048
|
if self.verbose:
|
|
@@ -961,7 +1076,8 @@ class RichUI:
|
|
|
961
1076
|
# pragma: no cover - defensive shutdown
|
|
962
1077
|
logger.warning(
|
|
963
1078
|
"[ui] Failed to shut down MCP runtime cleanly: %s: %s",
|
|
964
|
-
type(exc).__name__,
|
|
1079
|
+
type(exc).__name__,
|
|
1080
|
+
exc,
|
|
965
1081
|
extra={"session_id": self.session_id},
|
|
966
1082
|
)
|
|
967
1083
|
finally:
|
|
@@ -1014,6 +1130,7 @@ class RichUI:
|
|
|
1014
1130
|
)
|
|
1015
1131
|
except Exception as exc:
|
|
1016
1132
|
import traceback
|
|
1133
|
+
|
|
1017
1134
|
self.console.print(f"[red]Error during compaction: {escape(str(exc))}[/red]")
|
|
1018
1135
|
self.console.print(f"[dim red]{traceback.format_exc()}[/dim red]")
|
|
1019
1136
|
return
|
|
@@ -1042,15 +1159,16 @@ def check_onboarding_rich() -> bool:
|
|
|
1042
1159
|
if config.has_completed_onboarding:
|
|
1043
1160
|
return True
|
|
1044
1161
|
|
|
1045
|
-
# Use
|
|
1046
|
-
from ripperdoc.cli.
|
|
1162
|
+
# Use the wizard onboarding
|
|
1163
|
+
from ripperdoc.cli.ui.wizard import check_onboarding
|
|
1047
1164
|
|
|
1048
1165
|
return check_onboarding()
|
|
1049
1166
|
|
|
1050
1167
|
|
|
1051
1168
|
def main_rich(
|
|
1052
|
-
|
|
1169
|
+
yolo_mode: bool = False,
|
|
1053
1170
|
verbose: bool = False,
|
|
1171
|
+
show_full_thinking: Optional[bool] = None,
|
|
1054
1172
|
session_id: Optional[str] = None,
|
|
1055
1173
|
log_file_path: Optional[Path] = None,
|
|
1056
1174
|
) -> None:
|
|
@@ -1062,8 +1180,9 @@ def main_rich(
|
|
|
1062
1180
|
|
|
1063
1181
|
# Run the Rich UI
|
|
1064
1182
|
ui = RichUI(
|
|
1065
|
-
|
|
1183
|
+
yolo_mode=yolo_mode,
|
|
1066
1184
|
verbose=verbose,
|
|
1185
|
+
show_full_thinking=show_full_thinking,
|
|
1067
1186
|
session_id=session_id,
|
|
1068
1187
|
log_file_path=log_file_path,
|
|
1069
1188
|
)
|
ripperdoc/cli/ui/spinner.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
from
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from typing import Any, Generator, Literal, Optional
|
|
3
|
+
|
|
2
4
|
from rich.console import Console
|
|
3
5
|
from rich.markup import escape
|
|
4
6
|
from rich.status import Status
|
|
@@ -47,3 +49,25 @@ class Spinner:
|
|
|
47
49
|
self.stop()
|
|
48
50
|
# Do not suppress exceptions
|
|
49
51
|
return False
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_running(self) -> bool:
|
|
55
|
+
"""Check if spinner is currently running."""
|
|
56
|
+
return self._status is not None
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def paused(self) -> Generator[None, None, None]:
|
|
60
|
+
"""Context manager to temporarily pause the spinner for clean output.
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
with spinner.paused():
|
|
64
|
+
console.print("Some output")
|
|
65
|
+
"""
|
|
66
|
+
was_running = self.is_running
|
|
67
|
+
if was_running:
|
|
68
|
+
self.stop()
|
|
69
|
+
try:
|
|
70
|
+
yield
|
|
71
|
+
finally:
|
|
72
|
+
if was_running:
|
|
73
|
+
self.start()
|
|
@@ -155,7 +155,10 @@ class BashResultRenderer(ToolResultRenderer):
|
|
|
155
155
|
"""Render Bash tool results."""
|
|
156
156
|
|
|
157
157
|
def __init__(
|
|
158
|
-
self,
|
|
158
|
+
self,
|
|
159
|
+
console: Console,
|
|
160
|
+
verbose: bool = False,
|
|
161
|
+
parse_fallback: Optional[BashOutputParser] = None,
|
|
159
162
|
):
|
|
160
163
|
super().__init__(console, verbose)
|
|
161
164
|
self._parse_fallback = parse_fallback
|
|
@@ -254,7 +257,10 @@ class ToolResultRendererRegistry:
|
|
|
254
257
|
"""Registry that selects the appropriate renderer for a tool result."""
|
|
255
258
|
|
|
256
259
|
def __init__(
|
|
257
|
-
self,
|
|
260
|
+
self,
|
|
261
|
+
console: Console,
|
|
262
|
+
verbose: bool = False,
|
|
263
|
+
parse_bash_fallback: Optional[BashOutputParser] = None,
|
|
258
264
|
):
|
|
259
265
|
self.console = console
|
|
260
266
|
self.verbose = verbose
|