ripperdoc 0.2.9__py3-none-any.whl → 0.2.10__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 +235 -14
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +132 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/models_cmd.py +3 -3
- ripperdoc/cli/commands/resume_cmd.py +4 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/rich_ui.py +295 -24
- ripperdoc/cli/ui/spinner.py +30 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/wizard.py +6 -8
- ripperdoc/core/agents.py +10 -3
- ripperdoc/core/config.py +3 -6
- ripperdoc/core/default_tools.py +90 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/permissions.py +78 -4
- ripperdoc/core/providers/openai.py +29 -19
- ripperdoc/core/query.py +192 -31
- ripperdoc/core/tool.py +9 -4
- ripperdoc/sdk/client.py +77 -2
- ripperdoc/tools/background_shell.py +305 -134
- ripperdoc/tools/bash_tool.py +42 -13
- ripperdoc/tools/file_edit_tool.py +159 -50
- ripperdoc/tools/file_read_tool.py +20 -0
- ripperdoc/tools/file_write_tool.py +7 -8
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/task_tool.py +514 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +206 -3
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/message_formatting.py +5 -2
- ripperdoc/utils/messages.py +21 -1
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_stats.py +293 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -6,6 +6,7 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
|
|
|
6
6
|
import asyncio
|
|
7
7
|
import json
|
|
8
8
|
import sys
|
|
9
|
+
import time
|
|
9
10
|
import uuid
|
|
10
11
|
from typing import List, Dict, Any, Optional, Union, Iterable
|
|
11
12
|
from pathlib import Path
|
|
@@ -25,6 +26,7 @@ from ripperdoc.core.query import query, QueryContext
|
|
|
25
26
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
26
27
|
from ripperdoc.core.skills import build_skill_summary, load_all_skills
|
|
27
28
|
from ripperdoc.core.hooks.manager import hook_manager
|
|
29
|
+
from ripperdoc.core.hooks.llm_callback import build_hook_llm_callback
|
|
28
30
|
from ripperdoc.cli.commands import (
|
|
29
31
|
get_slash_command,
|
|
30
32
|
get_custom_command,
|
|
@@ -64,6 +66,8 @@ from ripperdoc.utils.mcp import (
|
|
|
64
66
|
load_mcp_servers_async,
|
|
65
67
|
shutdown_mcp_runtime,
|
|
66
68
|
)
|
|
69
|
+
from ripperdoc.utils.lsp import shutdown_lsp_manager
|
|
70
|
+
from ripperdoc.tools.background_shell import shutdown_background_shell
|
|
67
71
|
from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
|
|
68
72
|
from ripperdoc.utils.session_history import SessionHistory
|
|
69
73
|
from ripperdoc.utils.memory import build_memory_instructions
|
|
@@ -86,11 +90,6 @@ console = Console()
|
|
|
86
90
|
logger = get_logger()
|
|
87
91
|
|
|
88
92
|
|
|
89
|
-
# Legacy aliases for backward compatibility with tests
|
|
90
|
-
_extract_tool_ids_from_message = extract_tool_ids_from_message
|
|
91
|
-
_get_complete_tool_pairs_tail = get_complete_tool_pairs_tail
|
|
92
|
-
|
|
93
|
-
|
|
94
93
|
class RichUI:
|
|
95
94
|
"""Rich-based UI for Ripperdoc."""
|
|
96
95
|
|
|
@@ -101,17 +100,27 @@ class RichUI:
|
|
|
101
100
|
show_full_thinking: Optional[bool] = None,
|
|
102
101
|
session_id: Optional[str] = None,
|
|
103
102
|
log_file_path: Optional[Path] = None,
|
|
103
|
+
allowed_tools: Optional[List[str]] = None,
|
|
104
|
+
custom_system_prompt: Optional[str] = None,
|
|
105
|
+
append_system_prompt: Optional[str] = None,
|
|
106
|
+
model: Optional[str] = None,
|
|
107
|
+
resume_messages: Optional[List[Any]] = None,
|
|
104
108
|
):
|
|
105
109
|
self._loop = asyncio.new_event_loop()
|
|
106
110
|
asyncio.set_event_loop(self._loop)
|
|
107
111
|
self.console = console
|
|
108
112
|
self.yolo_mode = yolo_mode
|
|
109
113
|
self.verbose = verbose
|
|
114
|
+
self.allowed_tools = allowed_tools
|
|
115
|
+
self.custom_system_prompt = custom_system_prompt
|
|
116
|
+
self.append_system_prompt = append_system_prompt
|
|
117
|
+
self.model = model or "main"
|
|
110
118
|
self.conversation_messages: List[ConversationMessage] = []
|
|
111
119
|
self._saved_conversation: Optional[List[ConversationMessage]] = None
|
|
112
120
|
self.query_context: Optional[QueryContext] = None
|
|
113
121
|
self._current_tool: Optional[str] = None
|
|
114
122
|
self._should_exit: bool = False
|
|
123
|
+
self._last_ctrl_c_time: float = 0.0 # Track Ctrl+C timing for double-press exit
|
|
115
124
|
self.command_list = list_slash_commands()
|
|
116
125
|
self._custom_command_list = list_custom_commands()
|
|
117
126
|
self._prompt_session: Optional[PromptSession] = None
|
|
@@ -134,6 +143,11 @@ class RichUI:
|
|
|
134
143
|
},
|
|
135
144
|
)
|
|
136
145
|
self._session_history = SessionHistory(self.project_path, self.session_id)
|
|
146
|
+
self._session_hook_contexts: List[str] = []
|
|
147
|
+
self._session_start_time = time.time()
|
|
148
|
+
self._session_end_sent = False
|
|
149
|
+
self._exit_reason: Optional[str] = None
|
|
150
|
+
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
137
151
|
self._permission_checker = (
|
|
138
152
|
None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
|
|
139
153
|
)
|
|
@@ -176,6 +190,7 @@ class RichUI:
|
|
|
176
190
|
# Initialize hook manager with project context
|
|
177
191
|
hook_manager.set_project_dir(self.project_path)
|
|
178
192
|
hook_manager.set_session_id(self.session_id)
|
|
193
|
+
hook_manager.set_llm_callback(build_hook_llm_callback())
|
|
179
194
|
logger.debug(
|
|
180
195
|
"[ui] Initialized hook manager",
|
|
181
196
|
extra={
|
|
@@ -183,6 +198,18 @@ class RichUI:
|
|
|
183
198
|
"project_path": str(self.project_path),
|
|
184
199
|
},
|
|
185
200
|
)
|
|
201
|
+
self._run_session_start("startup")
|
|
202
|
+
|
|
203
|
+
# Handle resume_messages if provided (for --continue)
|
|
204
|
+
if resume_messages:
|
|
205
|
+
self.conversation_messages = list(resume_messages)
|
|
206
|
+
logger.info(
|
|
207
|
+
"[ui] Resumed conversation with messages",
|
|
208
|
+
extra={
|
|
209
|
+
"session_id": self.session_id,
|
|
210
|
+
"message_count": len(resume_messages),
|
|
211
|
+
},
|
|
212
|
+
)
|
|
186
213
|
|
|
187
214
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
188
215
|
# Properties for backward compatibility with interrupt handler
|
|
@@ -218,6 +245,69 @@ class RichUI:
|
|
|
218
245
|
},
|
|
219
246
|
)
|
|
220
247
|
self._session_history = SessionHistory(self.project_path, session_id)
|
|
248
|
+
hook_manager.set_session_id(self.session_id)
|
|
249
|
+
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
250
|
+
|
|
251
|
+
def _collect_hook_contexts(self, hook_result: Any) -> List[str]:
|
|
252
|
+
contexts: List[str] = []
|
|
253
|
+
system_message = getattr(hook_result, "system_message", None)
|
|
254
|
+
additional_context = getattr(hook_result, "additional_context", None)
|
|
255
|
+
if system_message:
|
|
256
|
+
contexts.append(str(system_message))
|
|
257
|
+
if additional_context:
|
|
258
|
+
contexts.append(str(additional_context))
|
|
259
|
+
return contexts
|
|
260
|
+
|
|
261
|
+
def _set_session_hook_contexts(self, hook_result: Any) -> None:
|
|
262
|
+
self._session_hook_contexts = self._collect_hook_contexts(hook_result)
|
|
263
|
+
self._session_start_time = time.time()
|
|
264
|
+
self._session_end_sent = False
|
|
265
|
+
|
|
266
|
+
def _run_session_start(self, source: str) -> None:
|
|
267
|
+
try:
|
|
268
|
+
result = self._run_async(hook_manager.run_session_start_async(source))
|
|
269
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
|
|
270
|
+
logger.warning(
|
|
271
|
+
"[ui] SessionStart hook failed: %s: %s",
|
|
272
|
+
type(exc).__name__,
|
|
273
|
+
exc,
|
|
274
|
+
extra={"session_id": self.session_id, "source": source},
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
self._set_session_hook_contexts(result)
|
|
278
|
+
|
|
279
|
+
async def _run_session_start_async(self, source: str) -> None:
|
|
280
|
+
try:
|
|
281
|
+
result = await hook_manager.run_session_start_async(source)
|
|
282
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
|
|
283
|
+
logger.warning(
|
|
284
|
+
"[ui] SessionStart hook failed: %s: %s",
|
|
285
|
+
type(exc).__name__,
|
|
286
|
+
exc,
|
|
287
|
+
extra={"session_id": self.session_id, "source": source},
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
self._set_session_hook_contexts(result)
|
|
291
|
+
|
|
292
|
+
def _run_session_end(self, reason: str) -> None:
|
|
293
|
+
if self._session_end_sent:
|
|
294
|
+
return
|
|
295
|
+
duration = max(time.time() - self._session_start_time, 0.0)
|
|
296
|
+
message_count = len(self.conversation_messages)
|
|
297
|
+
try:
|
|
298
|
+
self._run_async(
|
|
299
|
+
hook_manager.run_session_end_async(
|
|
300
|
+
reason, duration_seconds=duration, message_count=message_count
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
|
|
304
|
+
logger.warning(
|
|
305
|
+
"[ui] SessionEnd hook failed: %s: %s",
|
|
306
|
+
type(exc).__name__,
|
|
307
|
+
exc,
|
|
308
|
+
extra={"session_id": self.session_id, "reason": reason},
|
|
309
|
+
)
|
|
310
|
+
self._session_end_sent = True
|
|
221
311
|
|
|
222
312
|
def _log_message(self, message: Any) -> None:
|
|
223
313
|
"""Best-effort persistence of a message to the session log."""
|
|
@@ -278,8 +368,8 @@ class RichUI:
|
|
|
278
368
|
self.display_message("Ripperdoc", text)
|
|
279
369
|
|
|
280
370
|
def get_default_tools(self) -> list:
|
|
281
|
-
"""Get the default set of tools."""
|
|
282
|
-
return get_default_tools()
|
|
371
|
+
"""Get the default set of tools, filtered by allowed_tools if specified."""
|
|
372
|
+
return get_default_tools(allowed_tools=self.allowed_tools)
|
|
283
373
|
|
|
284
374
|
def display_message(
|
|
285
375
|
self,
|
|
@@ -334,7 +424,9 @@ class RichUI:
|
|
|
334
424
|
def _print_reasoning(self, reasoning: Any) -> None:
|
|
335
425
|
self._message_display.print_reasoning(reasoning)
|
|
336
426
|
|
|
337
|
-
async def _prepare_query_context(
|
|
427
|
+
async def _prepare_query_context(
|
|
428
|
+
self, user_input: str, hook_instructions: Optional[List[str]] = None
|
|
429
|
+
) -> tuple[str, Dict[str, str]]:
|
|
338
430
|
"""Load MCP servers, skills, and build system prompt.
|
|
339
431
|
|
|
340
432
|
Returns:
|
|
@@ -380,14 +472,32 @@ class RichUI:
|
|
|
380
472
|
memory_instructions = build_memory_instructions()
|
|
381
473
|
if memory_instructions:
|
|
382
474
|
additional_instructions.append(memory_instructions)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
475
|
+
if self._session_hook_contexts:
|
|
476
|
+
additional_instructions.extend(self._session_hook_contexts)
|
|
477
|
+
if hook_instructions:
|
|
478
|
+
additional_instructions.extend([text for text in hook_instructions if text])
|
|
479
|
+
|
|
480
|
+
# Build system prompt based on options:
|
|
481
|
+
# - custom_system_prompt: replaces the default entirely
|
|
482
|
+
# - append_system_prompt: appends to the default system prompt
|
|
483
|
+
if self.custom_system_prompt:
|
|
484
|
+
# Complete replacement
|
|
485
|
+
system_prompt = self.custom_system_prompt
|
|
486
|
+
# Still append if both are provided
|
|
487
|
+
if self.append_system_prompt:
|
|
488
|
+
system_prompt = f"{system_prompt}\n\n{self.append_system_prompt}"
|
|
489
|
+
else:
|
|
490
|
+
# Build default with optional append
|
|
491
|
+
all_instructions = list(additional_instructions) if additional_instructions else []
|
|
492
|
+
if self.append_system_prompt:
|
|
493
|
+
all_instructions.append(self.append_system_prompt)
|
|
494
|
+
system_prompt = build_system_prompt(
|
|
495
|
+
self.query_context.tools if self.query_context else [],
|
|
496
|
+
user_input,
|
|
497
|
+
context,
|
|
498
|
+
additional_instructions=all_instructions or None,
|
|
499
|
+
mcp_instructions=mcp_instructions,
|
|
500
|
+
)
|
|
391
501
|
|
|
392
502
|
return system_prompt, context
|
|
393
503
|
|
|
@@ -452,10 +562,33 @@ class RichUI:
|
|
|
452
562
|
if usage_status.should_auto_compact:
|
|
453
563
|
original_messages = list(messages)
|
|
454
564
|
spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
|
|
565
|
+
hook_instructions = ""
|
|
566
|
+
try:
|
|
567
|
+
hook_result = await hook_manager.run_pre_compact_async(
|
|
568
|
+
trigger="auto", custom_instructions=""
|
|
569
|
+
)
|
|
570
|
+
if hook_result.should_block or not hook_result.should_continue:
|
|
571
|
+
reason = (
|
|
572
|
+
hook_result.block_reason
|
|
573
|
+
or hook_result.stop_reason
|
|
574
|
+
or "Compaction blocked by hook."
|
|
575
|
+
)
|
|
576
|
+
console.print(f"[yellow]{escape(str(reason))}[/yellow]")
|
|
577
|
+
return messages
|
|
578
|
+
hook_contexts = self._collect_hook_contexts(hook_result)
|
|
579
|
+
if hook_contexts:
|
|
580
|
+
hook_instructions = "\n\n".join(hook_contexts)
|
|
581
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
|
|
582
|
+
logger.warning(
|
|
583
|
+
"[ui] PreCompact hook failed: %s: %s",
|
|
584
|
+
type(exc).__name__,
|
|
585
|
+
exc,
|
|
586
|
+
extra={"session_id": self.session_id},
|
|
587
|
+
)
|
|
455
588
|
try:
|
|
456
589
|
spinner.start()
|
|
457
590
|
result = await compact_conversation(
|
|
458
|
-
messages, custom_instructions=
|
|
591
|
+
messages, custom_instructions=hook_instructions, protocol=protocol
|
|
459
592
|
)
|
|
460
593
|
finally:
|
|
461
594
|
spinner.stop()
|
|
@@ -476,6 +609,7 @@ class RichUI:
|
|
|
476
609
|
"tokens_saved": result.tokens_saved,
|
|
477
610
|
},
|
|
478
611
|
)
|
|
612
|
+
await self._run_session_start_async("compact")
|
|
479
613
|
return result.messages
|
|
480
614
|
elif isinstance(result, CompactionError):
|
|
481
615
|
logger.warning(
|
|
@@ -633,12 +767,16 @@ class RichUI:
|
|
|
633
767
|
# Initialize or reset query context
|
|
634
768
|
if not self.query_context:
|
|
635
769
|
self.query_context = QueryContext(
|
|
636
|
-
tools=self.get_default_tools(),
|
|
770
|
+
tools=self.get_default_tools(),
|
|
771
|
+
yolo_mode=self.yolo_mode,
|
|
772
|
+
verbose=self.verbose,
|
|
773
|
+
model=self.model,
|
|
637
774
|
)
|
|
638
775
|
else:
|
|
639
776
|
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
640
777
|
if abort_controller is not None:
|
|
641
778
|
abort_controller.clear()
|
|
779
|
+
self.query_context.stop_hook_active = False
|
|
642
780
|
|
|
643
781
|
logger.info(
|
|
644
782
|
"[ui] Starting query processing",
|
|
@@ -650,8 +788,21 @@ class RichUI:
|
|
|
650
788
|
)
|
|
651
789
|
|
|
652
790
|
try:
|
|
791
|
+
hook_result = await hook_manager.run_user_prompt_submit_async(user_input)
|
|
792
|
+
if hook_result.should_block or not hook_result.should_continue:
|
|
793
|
+
reason = (
|
|
794
|
+
hook_result.block_reason
|
|
795
|
+
or hook_result.stop_reason
|
|
796
|
+
or "Prompt blocked by hook."
|
|
797
|
+
)
|
|
798
|
+
self.console.print(f"[red]{escape(str(reason))}[/red]")
|
|
799
|
+
return
|
|
800
|
+
hook_instructions = self._collect_hook_contexts(hook_result)
|
|
801
|
+
|
|
653
802
|
# Prepare context and system prompt
|
|
654
|
-
system_prompt, context = await self._prepare_query_context(
|
|
803
|
+
system_prompt, context = await self._prepare_query_context(
|
|
804
|
+
user_input, hook_instructions
|
|
805
|
+
)
|
|
655
806
|
|
|
656
807
|
# Create and log user message
|
|
657
808
|
user_message = create_user_message(user_input)
|
|
@@ -886,7 +1037,7 @@ class RichUI:
|
|
|
886
1037
|
def __init__(self, project_path: Path):
|
|
887
1038
|
self.project_path = project_path
|
|
888
1039
|
|
|
889
|
-
def get_completions(self, document: Any,
|
|
1040
|
+
def get_completions(self, document: Any, _complete_event: Any) -> Iterable[Completion]:
|
|
890
1041
|
text = document.text_before_cursor
|
|
891
1042
|
if not text.startswith("/"):
|
|
892
1043
|
return
|
|
@@ -937,12 +1088,57 @@ class RichUI:
|
|
|
937
1088
|
else:
|
|
938
1089
|
buf.start_completion(select_first=True)
|
|
939
1090
|
|
|
1091
|
+
@key_bindings.add("escape", "enter")
|
|
1092
|
+
def _(event: Any) -> None:
|
|
1093
|
+
"""Insert newline on Alt+Enter."""
|
|
1094
|
+
event.current_buffer.insert_text("\n")
|
|
1095
|
+
|
|
1096
|
+
# Capture self for use in Ctrl+C handler closure
|
|
1097
|
+
ui_instance = self
|
|
1098
|
+
|
|
1099
|
+
@key_bindings.add("c-c")
|
|
1100
|
+
def _(event: Any) -> None:
|
|
1101
|
+
"""Handle Ctrl+C: first press clears input, second press exits."""
|
|
1102
|
+
import time as time_module
|
|
1103
|
+
|
|
1104
|
+
buf = event.current_buffer
|
|
1105
|
+
current_text = buf.text
|
|
1106
|
+
current_time = time_module.time()
|
|
1107
|
+
|
|
1108
|
+
# Check if this is a double Ctrl+C (within 1.5 seconds)
|
|
1109
|
+
if current_time - ui_instance._last_ctrl_c_time < 1.5:
|
|
1110
|
+
# Double Ctrl+C - exit
|
|
1111
|
+
buf.reset()
|
|
1112
|
+
raise KeyboardInterrupt()
|
|
1113
|
+
|
|
1114
|
+
# First Ctrl+C - save to history and clear
|
|
1115
|
+
ui_instance._last_ctrl_c_time = current_time
|
|
1116
|
+
|
|
1117
|
+
if current_text.strip():
|
|
1118
|
+
# Save current input to history before clearing
|
|
1119
|
+
try:
|
|
1120
|
+
event.app.current_buffer.history.append_string(current_text)
|
|
1121
|
+
except (AttributeError, TypeError, ValueError):
|
|
1122
|
+
pass
|
|
1123
|
+
|
|
1124
|
+
# Print hint message in clean terminal context, then clear buffer
|
|
1125
|
+
from prompt_toolkit.application import run_in_terminal
|
|
1126
|
+
|
|
1127
|
+
def _print_hint() -> None:
|
|
1128
|
+
print("\n\033[2mPress Ctrl+C again to exit, or continue typing.\033[0m")
|
|
1129
|
+
|
|
1130
|
+
run_in_terminal(_print_hint)
|
|
1131
|
+
|
|
1132
|
+
# Clear the buffer after printing
|
|
1133
|
+
buf.reset()
|
|
1134
|
+
|
|
940
1135
|
self._prompt_session = PromptSession(
|
|
941
1136
|
completer=combined_completer,
|
|
942
1137
|
complete_style=CompleteStyle.COLUMN,
|
|
943
1138
|
complete_while_typing=True,
|
|
944
1139
|
history=InMemoryHistory(),
|
|
945
1140
|
key_bindings=key_bindings,
|
|
1141
|
+
multiline=True,
|
|
946
1142
|
)
|
|
947
1143
|
return self._prompt_session
|
|
948
1144
|
|
|
@@ -957,7 +1153,8 @@ class RichUI:
|
|
|
957
1153
|
console.print(create_status_bar())
|
|
958
1154
|
console.print()
|
|
959
1155
|
console.print(
|
|
960
|
-
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files.
|
|
1156
|
+
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
|
|
1157
|
+
"Press Alt+Enter for newline. Press ESC to interrupt. Press Ctrl+C twice to exit.[/dim]\n"
|
|
961
1158
|
)
|
|
962
1159
|
|
|
963
1160
|
session = self.get_prompt_session()
|
|
@@ -966,6 +1163,7 @@ class RichUI:
|
|
|
966
1163
|
extra={"session_id": self.session_id, "log_file": str(self.log_file_path)},
|
|
967
1164
|
)
|
|
968
1165
|
|
|
1166
|
+
exit_reason = "other"
|
|
969
1167
|
try:
|
|
970
1168
|
while not self._should_exit:
|
|
971
1169
|
try:
|
|
@@ -988,6 +1186,7 @@ class RichUI:
|
|
|
988
1186
|
)
|
|
989
1187
|
handled = self.handle_slash_command(user_input)
|
|
990
1188
|
if self._should_exit:
|
|
1189
|
+
exit_reason = self._exit_reason or "other"
|
|
991
1190
|
break
|
|
992
1191
|
# If handled is a string, it's expanded custom command content
|
|
993
1192
|
if isinstance(handled, str):
|
|
@@ -1020,15 +1219,31 @@ class RichUI:
|
|
|
1020
1219
|
console.print() # Add spacing between interactions
|
|
1021
1220
|
|
|
1022
1221
|
except KeyboardInterrupt:
|
|
1222
|
+
# Handle Ctrl+C: first press during query aborts it,
|
|
1223
|
+
# double press exits the CLI
|
|
1224
|
+
current_time = time.time()
|
|
1225
|
+
|
|
1023
1226
|
# Signal abort to cancel running queries
|
|
1024
1227
|
if self.query_context:
|
|
1025
1228
|
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
1026
1229
|
if abort_controller is not None:
|
|
1027
1230
|
abort_controller.set()
|
|
1028
|
-
|
|
1029
|
-
|
|
1231
|
+
|
|
1232
|
+
# Check if this is a double Ctrl+C (within 1.5 seconds)
|
|
1233
|
+
if current_time - self._last_ctrl_c_time < 1.5:
|
|
1234
|
+
console.print("\n[yellow]Goodbye![/yellow]")
|
|
1235
|
+
exit_reason = "prompt_input_exit"
|
|
1236
|
+
break
|
|
1237
|
+
|
|
1238
|
+
# First Ctrl+C - just abort the query and continue
|
|
1239
|
+
self._last_ctrl_c_time = current_time
|
|
1240
|
+
console.print(
|
|
1241
|
+
"\n[dim]Query interrupted. Press Ctrl+C again to exit.[/dim]"
|
|
1242
|
+
)
|
|
1243
|
+
continue
|
|
1030
1244
|
except EOFError:
|
|
1031
1245
|
console.print("\n[yellow]Goodbye![/yellow]")
|
|
1246
|
+
exit_reason = "prompt_input_exit"
|
|
1032
1247
|
break
|
|
1033
1248
|
except (
|
|
1034
1249
|
OSError,
|
|
@@ -1056,6 +1271,8 @@ class RichUI:
|
|
|
1056
1271
|
if abort_controller is not None:
|
|
1057
1272
|
abort_controller.set()
|
|
1058
1273
|
|
|
1274
|
+
self._run_session_end(exit_reason)
|
|
1275
|
+
|
|
1059
1276
|
# Suppress async generator cleanup errors during shutdown
|
|
1060
1277
|
original_hook = sys.unraisablehook
|
|
1061
1278
|
|
|
@@ -1072,6 +1289,7 @@ class RichUI:
|
|
|
1072
1289
|
try:
|
|
1073
1290
|
try:
|
|
1074
1291
|
self._run_async(shutdown_mcp_runtime())
|
|
1292
|
+
self._run_async(shutdown_lsp_manager())
|
|
1075
1293
|
except (OSError, RuntimeError, ConnectionError, asyncio.CancelledError) as exc:
|
|
1076
1294
|
# pragma: no cover - defensive shutdown
|
|
1077
1295
|
logger.warning(
|
|
@@ -1080,6 +1298,17 @@ class RichUI:
|
|
|
1080
1298
|
exc,
|
|
1081
1299
|
extra={"session_id": self.session_id},
|
|
1082
1300
|
)
|
|
1301
|
+
|
|
1302
|
+
# Shutdown background shell manager to clean up any background tasks
|
|
1303
|
+
try:
|
|
1304
|
+
shutdown_background_shell(force=True)
|
|
1305
|
+
except (OSError, RuntimeError) as exc:
|
|
1306
|
+
logger.debug(
|
|
1307
|
+
"[ui] Failed to shut down background shell cleanly: %s: %s",
|
|
1308
|
+
type(exc).__name__,
|
|
1309
|
+
exc,
|
|
1310
|
+
extra={"session_id": self.session_id},
|
|
1311
|
+
)
|
|
1083
1312
|
finally:
|
|
1084
1313
|
if not self._loop.is_closed():
|
|
1085
1314
|
# Cancel all pending tasks
|
|
@@ -1121,11 +1350,42 @@ class RichUI:
|
|
|
1121
1350
|
original_messages = list(self.conversation_messages)
|
|
1122
1351
|
spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
|
|
1123
1352
|
|
|
1353
|
+
hook_instructions = ""
|
|
1354
|
+
try:
|
|
1355
|
+
hook_result = await hook_manager.run_pre_compact_async(
|
|
1356
|
+
trigger="manual", custom_instructions=custom_instructions
|
|
1357
|
+
)
|
|
1358
|
+
if hook_result.should_block or not hook_result.should_continue:
|
|
1359
|
+
reason = (
|
|
1360
|
+
hook_result.block_reason
|
|
1361
|
+
or hook_result.stop_reason
|
|
1362
|
+
or "Compaction blocked by hook."
|
|
1363
|
+
)
|
|
1364
|
+
self.console.print(f"[yellow]{escape(str(reason))}[/yellow]")
|
|
1365
|
+
return
|
|
1366
|
+
hook_contexts = self._collect_hook_contexts(hook_result)
|
|
1367
|
+
if hook_contexts:
|
|
1368
|
+
hook_instructions = "\n\n".join(hook_contexts)
|
|
1369
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
|
|
1370
|
+
logger.warning(
|
|
1371
|
+
"[ui] PreCompact hook failed: %s: %s",
|
|
1372
|
+
type(exc).__name__,
|
|
1373
|
+
exc,
|
|
1374
|
+
extra={"session_id": self.session_id},
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
merged_instructions = custom_instructions.strip()
|
|
1378
|
+
if hook_instructions:
|
|
1379
|
+
merged_instructions = (
|
|
1380
|
+
f"{merged_instructions}\n\n{hook_instructions}".strip()
|
|
1381
|
+
if merged_instructions
|
|
1382
|
+
else hook_instructions
|
|
1383
|
+
)
|
|
1124
1384
|
try:
|
|
1125
1385
|
spinner.start()
|
|
1126
1386
|
result = await compact_conversation(
|
|
1127
1387
|
self.conversation_messages,
|
|
1128
|
-
|
|
1388
|
+
merged_instructions,
|
|
1129
1389
|
protocol=protocol,
|
|
1130
1390
|
)
|
|
1131
1391
|
except Exception as exc:
|
|
@@ -1144,6 +1404,7 @@ class RichUI:
|
|
|
1144
1404
|
f"[green]✓ Conversation compacted[/green] "
|
|
1145
1405
|
f"(saved ~{result.tokens_saved} tokens). Use /resume to restore full history."
|
|
1146
1406
|
)
|
|
1407
|
+
await self._run_session_start_async("compact")
|
|
1147
1408
|
elif isinstance(result, CompactionError):
|
|
1148
1409
|
self.console.print(f"[red]{escape(result.message)}[/red]")
|
|
1149
1410
|
|
|
@@ -1171,6 +1432,11 @@ def main_rich(
|
|
|
1171
1432
|
show_full_thinking: Optional[bool] = None,
|
|
1172
1433
|
session_id: Optional[str] = None,
|
|
1173
1434
|
log_file_path: Optional[Path] = None,
|
|
1435
|
+
allowed_tools: Optional[List[str]] = None,
|
|
1436
|
+
custom_system_prompt: Optional[str] = None,
|
|
1437
|
+
append_system_prompt: Optional[str] = None,
|
|
1438
|
+
model: Optional[str] = None,
|
|
1439
|
+
resume_messages: Optional[List[Any]] = None,
|
|
1174
1440
|
) -> None:
|
|
1175
1441
|
"""Main entry point for Rich interface."""
|
|
1176
1442
|
|
|
@@ -1185,6 +1451,11 @@ def main_rich(
|
|
|
1185
1451
|
show_full_thinking=show_full_thinking,
|
|
1186
1452
|
session_id=session_id,
|
|
1187
1453
|
log_file_path=log_file_path,
|
|
1454
|
+
allowed_tools=allowed_tools,
|
|
1455
|
+
custom_system_prompt=custom_system_prompt,
|
|
1456
|
+
append_system_prompt=append_system_prompt,
|
|
1457
|
+
model=model,
|
|
1458
|
+
resume_messages=resume_messages,
|
|
1188
1459
|
)
|
|
1189
1460
|
ui.run()
|
|
1190
1461
|
|
ripperdoc/cli/ui/spinner.py
CHANGED
|
@@ -2,44 +2,56 @@ from contextlib import contextmanager
|
|
|
2
2
|
from typing import Any, Generator, Literal, Optional
|
|
3
3
|
|
|
4
4
|
from rich.console import Console
|
|
5
|
-
from rich.
|
|
6
|
-
from rich.
|
|
5
|
+
from rich.live import Live
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from rich.spinner import Spinner as RichSpinner
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Spinner:
|
|
10
|
-
"""Lightweight spinner wrapper
|
|
11
|
+
"""Lightweight spinner wrapper that plays nicely with other console output."""
|
|
11
12
|
|
|
12
13
|
def __init__(self, console: Console, text: str = "Thinking...", spinner: str = "dots"):
|
|
13
14
|
self.console = console
|
|
14
15
|
self.text = text
|
|
15
16
|
self.spinner = spinner
|
|
16
|
-
self.
|
|
17
|
+
self._style = "cyan"
|
|
18
|
+
self._live: Optional[Live] = None
|
|
19
|
+
# Blue spinner for clearer visual separation in the terminal (icon + text)
|
|
20
|
+
self._renderable: RichSpinner = RichSpinner(
|
|
21
|
+
spinner, text=Text(self.text, style=self._style), style=self._style
|
|
22
|
+
)
|
|
17
23
|
|
|
18
24
|
def start(self) -> None:
|
|
19
25
|
"""Start the spinner if not already running."""
|
|
20
|
-
|
|
21
|
-
if self._status is not None:
|
|
26
|
+
if self._live is not None:
|
|
22
27
|
return
|
|
23
|
-
self.
|
|
24
|
-
|
|
28
|
+
self._renderable.text = Text(self.text, style=self._style)
|
|
29
|
+
self._live = Live(
|
|
30
|
+
self._renderable,
|
|
31
|
+
console=self.console,
|
|
32
|
+
transient=True, # Remove spinner line when stopped to avoid layout glitches
|
|
33
|
+
refresh_per_second=12,
|
|
25
34
|
)
|
|
26
|
-
self.
|
|
35
|
+
self._live.start()
|
|
27
36
|
|
|
28
37
|
def update(self, text: Optional[str] = None) -> None:
|
|
29
38
|
"""Update spinner text."""
|
|
30
|
-
|
|
31
|
-
if self._status is None:
|
|
39
|
+
if self._live is None:
|
|
32
40
|
return
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
if text is not None:
|
|
42
|
+
self.text = text
|
|
43
|
+
self._renderable.text = Text(self.text, style=self._style)
|
|
44
|
+
# Live.refresh() redraws the current renderable
|
|
45
|
+
self._live.refresh()
|
|
35
46
|
|
|
36
47
|
def stop(self) -> None:
|
|
37
48
|
"""Stop the spinner if running."""
|
|
38
|
-
|
|
39
|
-
if self._status is None:
|
|
49
|
+
if self._live is None:
|
|
40
50
|
return
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
try:
|
|
52
|
+
self._live.stop()
|
|
53
|
+
finally:
|
|
54
|
+
self._live = None
|
|
43
55
|
|
|
44
56
|
def __enter__(self) -> "Spinner":
|
|
45
57
|
self.start()
|
|
@@ -53,7 +65,7 @@ class Spinner:
|
|
|
53
65
|
@property
|
|
54
66
|
def is_running(self) -> bool:
|
|
55
67
|
"""Check if spinner is currently running."""
|
|
56
|
-
return self.
|
|
68
|
+
return self._live is not None
|
|
57
69
|
|
|
58
70
|
@contextmanager
|
|
59
71
|
def paused(self) -> Generator[None, None, None]:
|
|
@@ -22,7 +22,6 @@ THINKING_WORDS: list[str] = [
|
|
|
22
22
|
"Cerebrating",
|
|
23
23
|
"Channelling",
|
|
24
24
|
"Churning",
|
|
25
|
-
"Clauding",
|
|
26
25
|
"Coalescing",
|
|
27
26
|
"Cogitating",
|
|
28
27
|
"Computing",
|
|
@@ -114,7 +113,7 @@ class ThinkingSpinner(Spinner):
|
|
|
114
113
|
|
|
115
114
|
def _format_text(self, suffix: Optional[str] = None) -> str:
|
|
116
115
|
elapsed = int(time.monotonic() - self.start_time)
|
|
117
|
-
base = f"
|
|
116
|
+
base = f" {self.thinking_word}… (esc to interrupt · {elapsed}s"
|
|
118
117
|
if self.out_tokens > 0:
|
|
119
118
|
base += f" · ↓ {self.out_tokens} tokens"
|
|
120
119
|
else:
|
ripperdoc/cli/ui/wizard.py
CHANGED
|
@@ -93,14 +93,12 @@ def run_onboarding_wizard(config: GlobalConfig) -> bool:
|
|
|
93
93
|
model_suggestions=(),
|
|
94
94
|
)
|
|
95
95
|
else:
|
|
96
|
-
provider_option = KNOWN_PROVIDERS.get(provider_choice)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
model_suggestions=(),
|
|
103
|
-
)
|
|
96
|
+
provider_option = KNOWN_PROVIDERS.get(provider_choice) or ProviderOption(
|
|
97
|
+
key=provider_choice,
|
|
98
|
+
protocol=ProviderType.OPENAI_COMPATIBLE,
|
|
99
|
+
default_model=default_model_for_protocol(ProviderType.OPENAI_COMPATIBLE),
|
|
100
|
+
model_suggestions=(),
|
|
101
|
+
)
|
|
104
102
|
|
|
105
103
|
api_key = ""
|
|
106
104
|
while not api_key:
|