ripperdoc 0.2.10__py3-none-any.whl → 0.3.1__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 +164 -57
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +3 -7
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +61 -5
- ripperdoc/cli/commands/resume_cmd.py +1 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +4 -4
- ripperdoc/cli/commands/status_cmd.py +10 -0
- ripperdoc/cli/commands/tasks_cmd.py +6 -3
- ripperdoc/cli/commands/themes_cmd.py +139 -0
- ripperdoc/cli/ui/file_mention_completer.py +63 -13
- ripperdoc/cli/ui/helpers.py +6 -3
- ripperdoc/cli/ui/interrupt_listener.py +233 -0
- ripperdoc/cli/ui/message_display.py +7 -0
- ripperdoc/cli/ui/panels.py +13 -8
- ripperdoc/cli/ui/rich_ui.py +513 -84
- ripperdoc/cli/ui/spinner.py +68 -5
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +18 -11
- ripperdoc/core/agents.py +4 -0
- ripperdoc/core/config.py +235 -0
- ripperdoc/core/default_tools.py +1 -0
- ripperdoc/core/hooks/llm_callback.py +0 -1
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +123 -39
- ripperdoc/core/providers/openai.py +55 -9
- ripperdoc/core/query.py +349 -108
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +8 -3
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +49 -5
- ripperdoc/tools/bash_tool.py +75 -9
- ripperdoc/tools/file_edit_tool.py +98 -29
- ripperdoc/tools/file_read_tool.py +139 -8
- ripperdoc/tools/file_write_tool.py +46 -3
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +9 -15
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +33 -8
- ripperdoc/utils/file_watch.py +12 -6
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +9 -3
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +2 -2
- ripperdoc/utils/messages.py +177 -32
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +1 -3
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +1 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/METADATA +49 -17
- ripperdoc-0.3.1.dist-info/RECORD +136 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/WHEEL +1 -1
- ripperdoc/cli/ui/interrupt_handler.py +0 -174
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -408
- ripperdoc-0.2.10.dist-info/RECORD +0 -129
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -4,6 +4,7 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
+
import difflib
|
|
7
8
|
import json
|
|
8
9
|
import sys
|
|
9
10
|
import time
|
|
@@ -16,12 +17,15 @@ from rich.markup import escape
|
|
|
16
17
|
|
|
17
18
|
from prompt_toolkit import PromptSession
|
|
18
19
|
from prompt_toolkit.completion import Completer, Completion, merge_completers
|
|
20
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
19
21
|
from prompt_toolkit.history import InMemoryHistory
|
|
20
22
|
from prompt_toolkit.key_binding import KeyBindings
|
|
21
23
|
from prompt_toolkit.shortcuts.prompt import CompleteStyle
|
|
24
|
+
from prompt_toolkit.styles import Style
|
|
22
25
|
|
|
23
|
-
from ripperdoc.core.config import get_global_config, provider_protocol
|
|
26
|
+
from ripperdoc.core.config import get_global_config, provider_protocol, model_supports_vision
|
|
24
27
|
from ripperdoc.core.default_tools import get_default_tools
|
|
28
|
+
from ripperdoc.core.theme import get_theme_manager
|
|
25
29
|
from ripperdoc.core.query import query, QueryContext
|
|
26
30
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
27
31
|
from ripperdoc.core.skills import build_skill_summary, load_all_skills
|
|
@@ -43,13 +47,11 @@ from ripperdoc.cli.ui.thinking_spinner import ThinkingSpinner
|
|
|
43
47
|
from ripperdoc.cli.ui.context_display import context_usage_lines
|
|
44
48
|
from ripperdoc.cli.ui.panels import create_welcome_panel, create_status_bar, print_shortcuts
|
|
45
49
|
from ripperdoc.cli.ui.message_display import MessageDisplay, parse_bash_output_sections
|
|
46
|
-
from ripperdoc.cli.ui.
|
|
50
|
+
from ripperdoc.cli.ui.interrupt_listener import EscInterruptListener
|
|
47
51
|
from ripperdoc.utils.conversation_compaction import (
|
|
48
52
|
compact_conversation,
|
|
49
53
|
CompactionResult,
|
|
50
54
|
CompactionError,
|
|
51
|
-
extract_tool_ids_from_message,
|
|
52
|
-
get_complete_tool_pairs_tail,
|
|
53
55
|
)
|
|
54
56
|
from ripperdoc.utils.message_compaction import (
|
|
55
57
|
estimate_conversation_tokens,
|
|
@@ -75,12 +77,15 @@ from ripperdoc.utils.messages import (
|
|
|
75
77
|
UserMessage,
|
|
76
78
|
AssistantMessage,
|
|
77
79
|
ProgressMessage,
|
|
80
|
+
INTERRUPT_MESSAGE,
|
|
81
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
78
82
|
create_user_message,
|
|
79
83
|
)
|
|
80
84
|
from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
81
85
|
from ripperdoc.utils.path_ignore import build_ignore_filter
|
|
82
86
|
from ripperdoc.cli.ui.file_mention_completer import FileMentionCompleter
|
|
83
87
|
from ripperdoc.utils.message_formatting import stringify_message_content
|
|
88
|
+
from ripperdoc.utils.image_utils import read_image_as_base64, is_image_file
|
|
84
89
|
|
|
85
90
|
|
|
86
91
|
# Type alias for conversation messages
|
|
@@ -90,6 +95,201 @@ console = Console()
|
|
|
90
95
|
logger = get_logger()
|
|
91
96
|
|
|
92
97
|
|
|
98
|
+
def _suggest_slash_commands(name: str, project_path: Optional[Path]) -> List[str]:
|
|
99
|
+
"""Return close matching slash commands for a mistyped name."""
|
|
100
|
+
if not name:
|
|
101
|
+
return []
|
|
102
|
+
seen = set()
|
|
103
|
+
candidates: List[str] = []
|
|
104
|
+
for command_name, _cmd in slash_command_completions(project_path):
|
|
105
|
+
if command_name not in seen:
|
|
106
|
+
candidates.append(command_name)
|
|
107
|
+
seen.add(command_name)
|
|
108
|
+
return difflib.get_close_matches(name, candidates, n=3, cutoff=0.6)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _extract_image_paths(text: str) -> List[str]:
|
|
112
|
+
"""Extract @-referenced image paths from text.
|
|
113
|
+
|
|
114
|
+
Handles cases like:
|
|
115
|
+
- "@image.png describe this" (space after)
|
|
116
|
+
- "@image.png描述这个" (no space after, Chinese text)
|
|
117
|
+
- "@image.png.whatIsThis" (no space, ASCII text)
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
text: User input text
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of file paths (without the @ prefix)
|
|
124
|
+
"""
|
|
125
|
+
import re
|
|
126
|
+
from pathlib import Path
|
|
127
|
+
|
|
128
|
+
result = []
|
|
129
|
+
|
|
130
|
+
# Find all @ followed by content until space or end
|
|
131
|
+
for match in re.finditer(r"@(\S+)", text):
|
|
132
|
+
candidate = match.group(1)
|
|
133
|
+
if not candidate:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Try to find the actual file path by progressively trimming
|
|
137
|
+
# First, check if the full candidate is a file
|
|
138
|
+
if Path(candidate).exists():
|
|
139
|
+
result.append(candidate)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Not a file, try to find where the file path ends
|
|
143
|
+
# Common file extensions
|
|
144
|
+
extensions = [
|
|
145
|
+
".png",
|
|
146
|
+
".jpg",
|
|
147
|
+
".jpeg",
|
|
148
|
+
".gif",
|
|
149
|
+
".webp",
|
|
150
|
+
".bmp",
|
|
151
|
+
".svg",
|
|
152
|
+
".py",
|
|
153
|
+
".js",
|
|
154
|
+
".ts",
|
|
155
|
+
".tsx",
|
|
156
|
+
".jsx",
|
|
157
|
+
".vue",
|
|
158
|
+
".go",
|
|
159
|
+
".rs",
|
|
160
|
+
".java",
|
|
161
|
+
".c",
|
|
162
|
+
".cpp",
|
|
163
|
+
".h",
|
|
164
|
+
".hpp",
|
|
165
|
+
".cs",
|
|
166
|
+
".php",
|
|
167
|
+
".rb",
|
|
168
|
+
".sh",
|
|
169
|
+
".md",
|
|
170
|
+
".txt",
|
|
171
|
+
".json",
|
|
172
|
+
".yaml",
|
|
173
|
+
".yml",
|
|
174
|
+
".xml",
|
|
175
|
+
".html",
|
|
176
|
+
".css",
|
|
177
|
+
".scss",
|
|
178
|
+
".sql",
|
|
179
|
+
".db",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
found_path = None
|
|
183
|
+
for ext in extensions:
|
|
184
|
+
# Look for this extension in the candidate
|
|
185
|
+
if ext.lower() in candidate.lower():
|
|
186
|
+
# Found extension, extract path up to and including it
|
|
187
|
+
ext_pos = candidate.lower().find(ext.lower())
|
|
188
|
+
potential_path = candidate[: ext_pos + len(ext)]
|
|
189
|
+
if Path(potential_path).exists():
|
|
190
|
+
found_path = potential_path
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
# Also try to find the LAST occurrence of this extension
|
|
194
|
+
# For cases like "file.txt.extraText"
|
|
195
|
+
last_ext_pos = candidate.lower().rfind(ext.lower())
|
|
196
|
+
if last_ext_pos > ext_pos:
|
|
197
|
+
potential_path = candidate[: last_ext_pos + len(ext)]
|
|
198
|
+
if Path(potential_path).exists():
|
|
199
|
+
found_path = potential_path
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
if found_path:
|
|
203
|
+
result.append(found_path)
|
|
204
|
+
else:
|
|
205
|
+
# No file found, keep the original candidate
|
|
206
|
+
# The processing function will handle non-existent files
|
|
207
|
+
result.append(candidate)
|
|
208
|
+
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _process_images_in_input(
|
|
213
|
+
user_input: str,
|
|
214
|
+
project_path: Path,
|
|
215
|
+
model_pointer: str,
|
|
216
|
+
) -> tuple[str, List[Dict[str, Any]]]:
|
|
217
|
+
"""Process @ references for images in user input.
|
|
218
|
+
|
|
219
|
+
Only image files are processed and converted to image blocks.
|
|
220
|
+
Text files and non-existent files are left as-is in the text.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
user_input: Raw user input text
|
|
224
|
+
project_path: Project root path
|
|
225
|
+
model_pointer: Model pointer to check vision support
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
(processed_text, image_blocks) tuple
|
|
229
|
+
"""
|
|
230
|
+
import re
|
|
231
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
232
|
+
|
|
233
|
+
image_blocks: List[Dict[str, Any]] = []
|
|
234
|
+
processed_text = user_input
|
|
235
|
+
|
|
236
|
+
# Check if current model supports vision
|
|
237
|
+
profile = get_profile_for_pointer(model_pointer)
|
|
238
|
+
supports_vision = profile and model_supports_vision(profile)
|
|
239
|
+
|
|
240
|
+
if not supports_vision:
|
|
241
|
+
# Model doesn't support vision, leave all @ references as-is
|
|
242
|
+
return processed_text, image_blocks
|
|
243
|
+
|
|
244
|
+
referenced_paths = _extract_image_paths(user_input)
|
|
245
|
+
|
|
246
|
+
for ref_path in referenced_paths:
|
|
247
|
+
# Try relative path first, then absolute path
|
|
248
|
+
path_candidate = project_path / ref_path
|
|
249
|
+
if not path_candidate.exists():
|
|
250
|
+
path_candidate = Path(ref_path)
|
|
251
|
+
|
|
252
|
+
if not path_candidate.exists():
|
|
253
|
+
logger.debug(
|
|
254
|
+
"[ui] @ referenced file not found",
|
|
255
|
+
extra={"path": ref_path},
|
|
256
|
+
)
|
|
257
|
+
# Keep the reference in text (LLM should know file doesn't exist)
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Only process image files
|
|
261
|
+
if not is_image_file(path_candidate):
|
|
262
|
+
# Not an image file, keep @ reference in text
|
|
263
|
+
# The LLM can decide to read it with the Read tool if needed
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Process image file
|
|
267
|
+
result = read_image_as_base64(path_candidate)
|
|
268
|
+
if result:
|
|
269
|
+
base64_data, mime_type = result
|
|
270
|
+
image_blocks.append(
|
|
271
|
+
{
|
|
272
|
+
"type": "image",
|
|
273
|
+
"source_type": "base64",
|
|
274
|
+
"media_type": mime_type,
|
|
275
|
+
"image_data": base64_data,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
# Remove image reference from text (content included separately as image block)
|
|
279
|
+
processed_text = processed_text.replace(f"@{ref_path}", "")
|
|
280
|
+
else:
|
|
281
|
+
# Failed to read image, keep reference in text
|
|
282
|
+
logger.warning(
|
|
283
|
+
"[ui] Failed to read @ referenced image",
|
|
284
|
+
extra={"path": ref_path},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Clean up extra whitespace
|
|
288
|
+
processed_text = re.sub(r"\s+", " ", processed_text).strip()
|
|
289
|
+
|
|
290
|
+
return processed_text, image_blocks
|
|
291
|
+
|
|
292
|
+
|
|
93
293
|
class RichUI:
|
|
94
294
|
"""Rich-based UI for Ripperdoc."""
|
|
95
295
|
|
|
@@ -105,6 +305,7 @@ class RichUI:
|
|
|
105
305
|
append_system_prompt: Optional[str] = None,
|
|
106
306
|
model: Optional[str] = None,
|
|
107
307
|
resume_messages: Optional[List[Any]] = None,
|
|
308
|
+
initial_query: Optional[str] = None,
|
|
108
309
|
):
|
|
109
310
|
self._loop = asyncio.new_event_loop()
|
|
110
311
|
asyncio.set_event_loop(self._loop)
|
|
@@ -121,6 +322,7 @@ class RichUI:
|
|
|
121
322
|
self._current_tool: Optional[str] = None
|
|
122
323
|
self._should_exit: bool = False
|
|
123
324
|
self._last_ctrl_c_time: float = 0.0 # Track Ctrl+C timing for double-press exit
|
|
325
|
+
self._initial_query = initial_query # Query from piped stdin to auto-send on startup
|
|
124
326
|
self.command_list = list_slash_commands()
|
|
125
327
|
self._custom_command_list = list_custom_commands()
|
|
126
328
|
self._prompt_session: Optional[PromptSession] = None
|
|
@@ -147,10 +349,35 @@ class RichUI:
|
|
|
147
349
|
self._session_start_time = time.time()
|
|
148
350
|
self._session_end_sent = False
|
|
149
351
|
self._exit_reason: Optional[str] = None
|
|
352
|
+
self._using_tty_input = False # Track if we're using /dev/tty for input
|
|
353
|
+
self._thinking_mode_enabled = False # Toggle for extended thinking mode
|
|
354
|
+
self._interrupt_listener = EscInterruptListener(self._schedule_esc_interrupt, logger=logger)
|
|
355
|
+
self._esc_interrupt_seen = False
|
|
356
|
+
self._query_in_progress = False
|
|
357
|
+
self._active_spinner: Optional[ThinkingSpinner] = None
|
|
150
358
|
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
359
|
+
|
|
360
|
+
# Create permission checker with Rich console and PromptSession support
|
|
361
|
+
if not yolo_mode:
|
|
362
|
+
# Create a dedicated PromptSession for permission dialogs
|
|
363
|
+
# This provides better interrupt handling than console.input()
|
|
364
|
+
from prompt_toolkit import PromptSession
|
|
365
|
+
|
|
366
|
+
# Disable CPR (Cursor Position Request) to avoid warnings in terminals
|
|
367
|
+
# that don't support it (like some remote/CI terminals)
|
|
368
|
+
import os
|
|
369
|
+
os.environ['PROMPT_TOOLKIT_NO_CPR'] = '1'
|
|
370
|
+
|
|
371
|
+
permission_session = PromptSession()
|
|
372
|
+
|
|
373
|
+
self._permission_checker = make_permission_checker(
|
|
374
|
+
self.project_path,
|
|
375
|
+
yolo_mode=False,
|
|
376
|
+
console=console, # Pass console for Rich Panel rendering
|
|
377
|
+
prompt_session=permission_session, # Use PromptSession for input
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
self._permission_checker = None
|
|
154
381
|
# Build ignore filter for file completion
|
|
155
382
|
from ripperdoc.utils.path_ignore import get_project_ignore_patterns
|
|
156
383
|
|
|
@@ -169,12 +396,14 @@ class RichUI:
|
|
|
169
396
|
else:
|
|
170
397
|
self.show_full_thinking = show_full_thinking
|
|
171
398
|
|
|
399
|
+
# Initialize theme from config
|
|
400
|
+
theme_manager = get_theme_manager()
|
|
401
|
+
theme_name = getattr(config, "theme", None) or "dark"
|
|
402
|
+
if not theme_manager.set_theme(theme_name):
|
|
403
|
+
theme_manager.set_theme("dark") # Fallback to default
|
|
404
|
+
|
|
172
405
|
# Initialize component handlers
|
|
173
|
-
self._message_display = MessageDisplay(
|
|
174
|
-
self.console, self.verbose, self.show_full_thinking
|
|
175
|
-
)
|
|
176
|
-
self._interrupt_handler = InterruptHandler()
|
|
177
|
-
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
406
|
+
self._message_display = MessageDisplay(self.console, self.verbose, self.show_full_thinking)
|
|
178
407
|
|
|
179
408
|
# Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
|
|
180
409
|
try:
|
|
@@ -215,17 +444,57 @@ class RichUI:
|
|
|
215
444
|
# Properties for backward compatibility with interrupt handler
|
|
216
445
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
217
446
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
447
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
448
|
+
# Thinking mode toggle
|
|
449
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
def _supports_thinking_mode(self) -> bool:
|
|
452
|
+
"""Check if the current model supports extended thinking mode."""
|
|
453
|
+
from ripperdoc.core.query import infer_thinking_mode
|
|
454
|
+
from ripperdoc.core.config import ProviderType
|
|
455
|
+
|
|
456
|
+
model_profile = get_profile_for_pointer("main")
|
|
457
|
+
if not model_profile:
|
|
458
|
+
return False
|
|
459
|
+
# Anthropic natively supports thinking mode
|
|
460
|
+
if model_profile.provider == ProviderType.ANTHROPIC:
|
|
461
|
+
return True
|
|
462
|
+
# For other providers, check if we can infer a thinking mode
|
|
463
|
+
return infer_thinking_mode(model_profile) is not None
|
|
221
464
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
465
|
+
def _toggle_thinking_mode(self) -> None:
|
|
466
|
+
"""Toggle thinking mode on/off. Status is shown in rprompt."""
|
|
467
|
+
if not self._supports_thinking_mode():
|
|
468
|
+
self.console.print("[yellow]Current model does not support thinking mode.[/yellow]")
|
|
469
|
+
return
|
|
470
|
+
self._thinking_mode_enabled = not self._thinking_mode_enabled
|
|
225
471
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
self.
|
|
472
|
+
def _get_thinking_tokens(self) -> int:
|
|
473
|
+
"""Get the thinking tokens budget based on current mode."""
|
|
474
|
+
if not self._thinking_mode_enabled:
|
|
475
|
+
return 0
|
|
476
|
+
config = get_global_config()
|
|
477
|
+
return config.default_thinking_tokens
|
|
478
|
+
|
|
479
|
+
def _get_prompt(self) -> str:
|
|
480
|
+
"""Generate the input prompt."""
|
|
481
|
+
return "> "
|
|
482
|
+
|
|
483
|
+
def _get_rprompt(self) -> Union[str, FormattedText]:
|
|
484
|
+
"""Generate the right prompt with thinking mode status."""
|
|
485
|
+
if not self._supports_thinking_mode():
|
|
486
|
+
return ""
|
|
487
|
+
if self._thinking_mode_enabled:
|
|
488
|
+
return FormattedText(
|
|
489
|
+
[
|
|
490
|
+
("class:rprompt-on", "⚡ Thinking"),
|
|
491
|
+
]
|
|
492
|
+
)
|
|
493
|
+
return FormattedText(
|
|
494
|
+
[
|
|
495
|
+
("class:rprompt-off", "Thinking: off"),
|
|
496
|
+
]
|
|
497
|
+
)
|
|
229
498
|
|
|
230
499
|
def _context_usage_lines(
|
|
231
500
|
self, breakdown: Any, model_label: str, auto_compact_enabled: bool
|
|
@@ -647,6 +916,11 @@ class RichUI:
|
|
|
647
916
|
last_tool_name: Optional[str] = None
|
|
648
917
|
|
|
649
918
|
if isinstance(message.message.content, str):
|
|
919
|
+
if self._esc_interrupt_seen and message.message.content.strip() in (
|
|
920
|
+
INTERRUPT_MESSAGE,
|
|
921
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
922
|
+
):
|
|
923
|
+
return last_tool_name
|
|
650
924
|
with pause():
|
|
651
925
|
self.display_message("Ripperdoc", message.message.content)
|
|
652
926
|
elif isinstance(message.message.content, list):
|
|
@@ -758,16 +1032,48 @@ class RichUI:
|
|
|
758
1032
|
output_token_est += delta_tokens
|
|
759
1033
|
spinner.update_tokens(output_token_est)
|
|
760
1034
|
else:
|
|
761
|
-
spinner
|
|
1035
|
+
# Simplify spinner suffix for bash command progress to avoid clutter
|
|
1036
|
+
suffix = self._simplify_progress_suffix(message.content)
|
|
1037
|
+
spinner.update_tokens(output_token_est, suffix=suffix)
|
|
762
1038
|
|
|
763
1039
|
return output_token_est
|
|
764
1040
|
|
|
1041
|
+
def _simplify_progress_suffix(self, content: Any) -> str:
|
|
1042
|
+
"""Simplify progress message content for cleaner spinner display.
|
|
1043
|
+
|
|
1044
|
+
For bash command progress (format: "Running... (elapsed)\nstdout_preview"),
|
|
1045
|
+
extract only the timing information to avoid cluttering the spinner with
|
|
1046
|
+
multi-line stdout content that causes terminal wrapping issues.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
content: Progress message content (can be str or other types)
|
|
1050
|
+
|
|
1051
|
+
Returns:
|
|
1052
|
+
Simplified suffix string for spinner display
|
|
1053
|
+
"""
|
|
1054
|
+
if not isinstance(content, str):
|
|
1055
|
+
return f"Working... {content}"
|
|
1056
|
+
|
|
1057
|
+
# Handle bash command progress: "Running... (10s)\nstdout..."
|
|
1058
|
+
if content.startswith("Running..."):
|
|
1059
|
+
# Extract just the "Running... (time)" part before any newline
|
|
1060
|
+
first_line = content.split("\n", 1)[0]
|
|
1061
|
+
return first_line
|
|
1062
|
+
|
|
1063
|
+
# For other progress messages, limit length to avoid terminal wrapping
|
|
1064
|
+
max_length = 60
|
|
1065
|
+
if len(content) > max_length:
|
|
1066
|
+
return f"Working... {content[:max_length]}..."
|
|
1067
|
+
|
|
1068
|
+
return f"Working... {content}"
|
|
1069
|
+
|
|
765
1070
|
async def process_query(self, user_input: str) -> None:
|
|
766
1071
|
"""Process a user query and display the response."""
|
|
767
1072
|
# Initialize or reset query context
|
|
768
1073
|
if not self.query_context:
|
|
769
1074
|
self.query_context = QueryContext(
|
|
770
1075
|
tools=self.get_default_tools(),
|
|
1076
|
+
max_thinking_tokens=self._get_thinking_tokens(),
|
|
771
1077
|
yolo_mode=self.yolo_mode,
|
|
772
1078
|
verbose=self.verbose,
|
|
773
1079
|
model=self.model,
|
|
@@ -776,6 +1082,8 @@ class RichUI:
|
|
|
776
1082
|
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
777
1083
|
if abort_controller is not None:
|
|
778
1084
|
abort_controller.clear()
|
|
1085
|
+
# Update thinking tokens in case user toggled thinking mode
|
|
1086
|
+
self.query_context.max_thinking_tokens = self._get_thinking_tokens()
|
|
779
1087
|
self.query_context.stop_hook_active = False
|
|
780
1088
|
|
|
781
1089
|
logger.info(
|
|
@@ -791,9 +1099,7 @@ class RichUI:
|
|
|
791
1099
|
hook_result = await hook_manager.run_user_prompt_submit_async(user_input)
|
|
792
1100
|
if hook_result.should_block or not hook_result.should_continue:
|
|
793
1101
|
reason = (
|
|
794
|
-
hook_result.block_reason
|
|
795
|
-
or hook_result.stop_reason
|
|
796
|
-
or "Prompt blocked by hook."
|
|
1102
|
+
hook_result.block_reason or hook_result.stop_reason or "Prompt blocked by hook."
|
|
797
1103
|
)
|
|
798
1104
|
self.console.print(f"[red]{escape(str(reason))}[/red]")
|
|
799
1105
|
return
|
|
@@ -804,11 +1110,29 @@ class RichUI:
|
|
|
804
1110
|
user_input, hook_instructions
|
|
805
1111
|
)
|
|
806
1112
|
|
|
1113
|
+
# Process images in user input
|
|
1114
|
+
processed_input, image_blocks = _process_images_in_input(
|
|
1115
|
+
user_input, self.project_path, self.model
|
|
1116
|
+
)
|
|
1117
|
+
|
|
807
1118
|
# Create and log user message
|
|
808
|
-
|
|
1119
|
+
if image_blocks:
|
|
1120
|
+
# Has images: use structured content
|
|
1121
|
+
content_blocks = []
|
|
1122
|
+
# Add images first
|
|
1123
|
+
for block in image_blocks:
|
|
1124
|
+
content_blocks.append({"type": "image", **block})
|
|
1125
|
+
# Add user's text input
|
|
1126
|
+
if processed_input:
|
|
1127
|
+
content_blocks.append({"type": "text", "text": processed_input})
|
|
1128
|
+
user_message = create_user_message(content=content_blocks)
|
|
1129
|
+
else:
|
|
1130
|
+
# No images: use plain text
|
|
1131
|
+
user_message = create_user_message(content=processed_input)
|
|
1132
|
+
|
|
809
1133
|
messages: List[ConversationMessage] = self.conversation_messages + [user_message]
|
|
810
1134
|
self._log_message(user_message)
|
|
811
|
-
self._append_prompt_history(
|
|
1135
|
+
self._append_prompt_history(processed_input)
|
|
812
1136
|
|
|
813
1137
|
# Get model configuration
|
|
814
1138
|
config = get_global_config()
|
|
@@ -829,11 +1153,26 @@ class RichUI:
|
|
|
829
1153
|
spinner = ThinkingSpinner(console, prompt_tokens_est)
|
|
830
1154
|
|
|
831
1155
|
def pause_ui() -> None:
|
|
832
|
-
|
|
1156
|
+
self._pause_interrupt_listener()
|
|
1157
|
+
try:
|
|
1158
|
+
spinner.stop()
|
|
1159
|
+
except (RuntimeError, ValueError, OSError):
|
|
1160
|
+
logger.debug("[ui] Failed to pause spinner")
|
|
833
1161
|
|
|
834
1162
|
def resume_ui() -> None:
|
|
835
|
-
|
|
836
|
-
|
|
1163
|
+
if self._esc_interrupt_seen:
|
|
1164
|
+
return
|
|
1165
|
+
try:
|
|
1166
|
+
spinner.start()
|
|
1167
|
+
spinner.update("Thinking...")
|
|
1168
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
1169
|
+
logger.debug(
|
|
1170
|
+
"[ui] Failed to restart spinner after pause: %s: %s",
|
|
1171
|
+
type(exc).__name__,
|
|
1172
|
+
exc,
|
|
1173
|
+
)
|
|
1174
|
+
finally:
|
|
1175
|
+
self._resume_interrupt_listener()
|
|
837
1176
|
|
|
838
1177
|
self.query_context.pause_ui = pause_ui
|
|
839
1178
|
self.query_context.resume_ui = resume_ui
|
|
@@ -842,8 +1181,7 @@ class RichUI:
|
|
|
842
1181
|
base_permission_checker = self._permission_checker
|
|
843
1182
|
|
|
844
1183
|
async def permission_checker(tool: Any, parsed_input: Any) -> bool:
|
|
845
|
-
|
|
846
|
-
was_paused = self._pause_interrupt_listener()
|
|
1184
|
+
pause_ui()
|
|
847
1185
|
try:
|
|
848
1186
|
if base_permission_checker is not None:
|
|
849
1187
|
result = await base_permission_checker(tool, parsed_input)
|
|
@@ -859,18 +1197,7 @@ class RichUI:
|
|
|
859
1197
|
return allowed
|
|
860
1198
|
return True
|
|
861
1199
|
finally:
|
|
862
|
-
|
|
863
|
-
# Wrap spinner restart in try-except to prevent exceptions
|
|
864
|
-
# from discarding the permission result
|
|
865
|
-
try:
|
|
866
|
-
spinner.start()
|
|
867
|
-
spinner.update("Thinking...")
|
|
868
|
-
except (RuntimeError, ValueError, OSError) as exc:
|
|
869
|
-
logger.debug(
|
|
870
|
-
"[ui] Failed to restart spinner after permission check: %s: %s",
|
|
871
|
-
type(exc).__name__,
|
|
872
|
-
exc,
|
|
873
|
-
)
|
|
1200
|
+
resume_ui()
|
|
874
1201
|
|
|
875
1202
|
# Process query stream
|
|
876
1203
|
tool_registry: Dict[str, Dict[str, Any]] = {}
|
|
@@ -878,6 +1205,10 @@ class RichUI:
|
|
|
878
1205
|
output_token_est = 0
|
|
879
1206
|
|
|
880
1207
|
try:
|
|
1208
|
+
self._active_spinner = spinner
|
|
1209
|
+
self._esc_interrupt_seen = False
|
|
1210
|
+
self._query_in_progress = True
|
|
1211
|
+
self._start_interrupt_listener()
|
|
881
1212
|
spinner.start()
|
|
882
1213
|
async for message in query(
|
|
883
1214
|
messages,
|
|
@@ -926,6 +1257,9 @@ class RichUI:
|
|
|
926
1257
|
extra={"session_id": self.session_id},
|
|
927
1258
|
)
|
|
928
1259
|
|
|
1260
|
+
self._stop_interrupt_listener()
|
|
1261
|
+
self._query_in_progress = False
|
|
1262
|
+
self._active_spinner = None
|
|
929
1263
|
self.conversation_messages = messages
|
|
930
1264
|
logger.info(
|
|
931
1265
|
"[ui] Query processing completed",
|
|
@@ -952,21 +1286,49 @@ class RichUI:
|
|
|
952
1286
|
# ESC Key Interrupt Support
|
|
953
1287
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
954
1288
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1289
|
+
def _schedule_esc_interrupt(self) -> None:
|
|
1290
|
+
"""Schedule ESC interrupt handling on the UI event loop."""
|
|
1291
|
+
if self._loop.is_closed():
|
|
1292
|
+
return
|
|
1293
|
+
try:
|
|
1294
|
+
self._loop.call_soon_threadsafe(self._handle_esc_interrupt)
|
|
1295
|
+
except RuntimeError:
|
|
1296
|
+
pass
|
|
958
1297
|
|
|
959
|
-
def
|
|
960
|
-
|
|
1298
|
+
def _handle_esc_interrupt(self) -> None:
|
|
1299
|
+
"""Abort the current query and display the interrupt notice."""
|
|
1300
|
+
if not self._query_in_progress:
|
|
1301
|
+
return
|
|
1302
|
+
if self._esc_interrupt_seen:
|
|
1303
|
+
return
|
|
1304
|
+
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
1305
|
+
if abort_controller is None or abort_controller.is_set():
|
|
1306
|
+
return
|
|
1307
|
+
|
|
1308
|
+
self._esc_interrupt_seen = True
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
if self.query_context and self.query_context.pause_ui:
|
|
1312
|
+
self.query_context.pause_ui()
|
|
1313
|
+
elif self._active_spinner:
|
|
1314
|
+
self._active_spinner.stop()
|
|
1315
|
+
except (RuntimeError, ValueError, OSError):
|
|
1316
|
+
logger.debug("[ui] Failed to pause spinner for ESC interrupt")
|
|
961
1317
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
if self.query_context and hasattr(self.query_context, "abort_controller"):
|
|
965
|
-
self.query_context.abort_controller.set()
|
|
1318
|
+
self._message_display.print_interrupt_notice()
|
|
1319
|
+
abort_controller.set()
|
|
966
1320
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1321
|
+
def _start_interrupt_listener(self) -> None:
|
|
1322
|
+
self._interrupt_listener.start()
|
|
1323
|
+
|
|
1324
|
+
def _stop_interrupt_listener(self) -> None:
|
|
1325
|
+
self._interrupt_listener.stop()
|
|
1326
|
+
|
|
1327
|
+
def _pause_interrupt_listener(self) -> None:
|
|
1328
|
+
self._interrupt_listener.pause()
|
|
1329
|
+
|
|
1330
|
+
def _resume_interrupt_listener(self) -> None:
|
|
1331
|
+
self._interrupt_listener.resume()
|
|
970
1332
|
|
|
971
1333
|
def _run_async(self, coro: Any) -> Any:
|
|
972
1334
|
"""Run a coroutine on the persistent event loop."""
|
|
@@ -975,16 +1337,6 @@ class RichUI:
|
|
|
975
1337
|
asyncio.set_event_loop(self._loop)
|
|
976
1338
|
return self._loop.run_until_complete(coro)
|
|
977
1339
|
|
|
978
|
-
def _run_async_with_esc_interrupt(self, coro: Any) -> bool:
|
|
979
|
-
"""Run a coroutine with ESC key interrupt support.
|
|
980
|
-
|
|
981
|
-
Returns True if interrupted by ESC, False if completed normally.
|
|
982
|
-
"""
|
|
983
|
-
if self._loop.is_closed():
|
|
984
|
-
self._loop = asyncio.new_event_loop()
|
|
985
|
-
asyncio.set_event_loop(self._loop)
|
|
986
|
-
return self._loop.run_until_complete(self._run_query_with_esc_interrupt(coro))
|
|
987
|
-
|
|
988
1340
|
def run_async(self, coro: Any) -> Any:
|
|
989
1341
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
990
1342
|
return self._run_async(coro)
|
|
@@ -1023,7 +1375,14 @@ class RichUI:
|
|
|
1023
1375
|
# Return the expanded content to be processed as a query
|
|
1024
1376
|
return expanded_content
|
|
1025
1377
|
|
|
1026
|
-
|
|
1378
|
+
suggestions = _suggest_slash_commands(command_name, self.project_path)
|
|
1379
|
+
hint = ""
|
|
1380
|
+
if suggestions:
|
|
1381
|
+
hint = " [dim]Did you mean "
|
|
1382
|
+
hint += ", ".join(f"/{escape(s)}" for s in suggestions)
|
|
1383
|
+
hint += "?[/dim]"
|
|
1384
|
+
|
|
1385
|
+
self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]{hint}")
|
|
1027
1386
|
return True
|
|
1028
1387
|
|
|
1029
1388
|
def get_prompt_session(self) -> PromptSession:
|
|
@@ -1081,8 +1440,18 @@ class RichUI:
|
|
|
1081
1440
|
|
|
1082
1441
|
@key_bindings.add("tab")
|
|
1083
1442
|
def _(event: Any) -> None:
|
|
1084
|
-
"""
|
|
1443
|
+
"""Toggle thinking mode when input is empty; otherwise handle completion."""
|
|
1085
1444
|
buf = event.current_buffer
|
|
1445
|
+
# If input is empty, toggle thinking mode
|
|
1446
|
+
if not buf.text.strip():
|
|
1447
|
+
from prompt_toolkit.application import run_in_terminal
|
|
1448
|
+
|
|
1449
|
+
def _toggle() -> None:
|
|
1450
|
+
ui_instance._toggle_thinking_mode()
|
|
1451
|
+
|
|
1452
|
+
run_in_terminal(_toggle)
|
|
1453
|
+
return
|
|
1454
|
+
# Otherwise, handle completion as usual
|
|
1086
1455
|
if buf.complete_state and buf.complete_state.current_completion:
|
|
1087
1456
|
buf.apply_completion(buf.complete_state.current_completion)
|
|
1088
1457
|
else:
|
|
@@ -1132,6 +1501,42 @@ class RichUI:
|
|
|
1132
1501
|
# Clear the buffer after printing
|
|
1133
1502
|
buf.reset()
|
|
1134
1503
|
|
|
1504
|
+
# If stdin is not a TTY (e.g., piped input), try to use /dev/tty for interactive input
|
|
1505
|
+
# This allows the user to continue interacting after processing piped content
|
|
1506
|
+
input_obj = None
|
|
1507
|
+
if not sys.stdin.isatty():
|
|
1508
|
+
# First check if /dev/tty exists and is accessible
|
|
1509
|
+
try:
|
|
1510
|
+
import os
|
|
1511
|
+
|
|
1512
|
+
if os.path.exists("/dev/tty"):
|
|
1513
|
+
from prompt_toolkit.input import create_input
|
|
1514
|
+
|
|
1515
|
+
input_obj = create_input(always_prefer_tty=True)
|
|
1516
|
+
self._using_tty_input = True # Mark that we're using /dev/tty
|
|
1517
|
+
logger.info(
|
|
1518
|
+
"[ui] Stdin is not a TTY, using /dev/tty for prompt input",
|
|
1519
|
+
extra={"session_id": self.session_id},
|
|
1520
|
+
)
|
|
1521
|
+
else:
|
|
1522
|
+
logger.info(
|
|
1523
|
+
"[ui] Stdin is not a TTY and /dev/tty not available",
|
|
1524
|
+
extra={"session_id": self.session_id},
|
|
1525
|
+
)
|
|
1526
|
+
except (OSError, RuntimeError, ValueError, ImportError) as exc:
|
|
1527
|
+
logger.warning(
|
|
1528
|
+
"[ui] Failed to create TTY input: %s: %s",
|
|
1529
|
+
type(exc).__name__,
|
|
1530
|
+
exc,
|
|
1531
|
+
extra={"session_id": self.session_id},
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
prompt_style = Style.from_dict(
|
|
1535
|
+
{
|
|
1536
|
+
"rprompt-on": "fg:ansicyan bold",
|
|
1537
|
+
"rprompt-off": "fg:ansibrightblack",
|
|
1538
|
+
}
|
|
1539
|
+
)
|
|
1135
1540
|
self._prompt_session = PromptSession(
|
|
1136
1541
|
completer=combined_completer,
|
|
1137
1542
|
complete_style=CompleteStyle.COLUMN,
|
|
@@ -1139,6 +1544,9 @@ class RichUI:
|
|
|
1139
1544
|
history=InMemoryHistory(),
|
|
1140
1545
|
key_bindings=key_bindings,
|
|
1141
1546
|
multiline=True,
|
|
1547
|
+
input=input_obj,
|
|
1548
|
+
style=prompt_style,
|
|
1549
|
+
rprompt=self._get_rprompt,
|
|
1142
1550
|
)
|
|
1143
1551
|
return self._prompt_session
|
|
1144
1552
|
|
|
@@ -1154,7 +1562,7 @@ class RichUI:
|
|
|
1154
1562
|
console.print()
|
|
1155
1563
|
console.print(
|
|
1156
1564
|
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
|
|
1157
|
-
"Press Alt+Enter for newline. Press
|
|
1565
|
+
"Press Alt+Enter for newline. Press Tab to toggle thinking mode.[/dim]\n"
|
|
1158
1566
|
)
|
|
1159
1567
|
|
|
1160
1568
|
session = self.get_prompt_session()
|
|
@@ -1165,10 +1573,33 @@ class RichUI:
|
|
|
1165
1573
|
|
|
1166
1574
|
exit_reason = "other"
|
|
1167
1575
|
try:
|
|
1576
|
+
# Process initial query from piped stdin if provided
|
|
1577
|
+
if self._initial_query:
|
|
1578
|
+
console.print(f"> {self._initial_query}")
|
|
1579
|
+
logger.info(
|
|
1580
|
+
"[ui] Processing initial query from stdin",
|
|
1581
|
+
extra={
|
|
1582
|
+
"session_id": self.session_id,
|
|
1583
|
+
"prompt_length": len(self._initial_query),
|
|
1584
|
+
"prompt_preview": self._initial_query[:200],
|
|
1585
|
+
},
|
|
1586
|
+
)
|
|
1587
|
+
console.print() # Add spacing before response
|
|
1588
|
+
|
|
1589
|
+
# Process initial query (ESC interrupt handling removed)
|
|
1590
|
+
self._run_async(self.process_query(self._initial_query))
|
|
1591
|
+
|
|
1592
|
+
logger.info(
|
|
1593
|
+
"[ui] Initial query completed successfully",
|
|
1594
|
+
extra={"session_id": self.session_id},
|
|
1595
|
+
)
|
|
1596
|
+
console.print() # Add spacing after response
|
|
1597
|
+
self._initial_query = None # Clear after processing
|
|
1598
|
+
|
|
1168
1599
|
while not self._should_exit:
|
|
1169
1600
|
try:
|
|
1170
|
-
# Get user input
|
|
1171
|
-
user_input = session.prompt(
|
|
1601
|
+
# Get user input with dynamic prompt
|
|
1602
|
+
user_input = session.prompt(self._get_prompt())
|
|
1172
1603
|
|
|
1173
1604
|
if not user_input.strip():
|
|
1174
1605
|
continue
|
|
@@ -1205,16 +1636,9 @@ class RichUI:
|
|
|
1205
1636
|
"prompt_preview": user_input[:200],
|
|
1206
1637
|
},
|
|
1207
1638
|
)
|
|
1208
|
-
interrupted = self._run_async_with_esc_interrupt(self.process_query(user_input))
|
|
1209
1639
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
"\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
|
|
1213
|
-
)
|
|
1214
|
-
logger.info(
|
|
1215
|
-
"[ui] Query interrupted by ESC key",
|
|
1216
|
-
extra={"session_id": self.session_id},
|
|
1217
|
-
)
|
|
1640
|
+
# Run query (ESC interrupt handling removed)
|
|
1641
|
+
self._run_async(self.process_query(user_input))
|
|
1218
1642
|
|
|
1219
1643
|
console.print() # Add spacing between interactions
|
|
1220
1644
|
|
|
@@ -1237,9 +1661,7 @@ class RichUI:
|
|
|
1237
1661
|
|
|
1238
1662
|
# First Ctrl+C - just abort the query and continue
|
|
1239
1663
|
self._last_ctrl_c_time = current_time
|
|
1240
|
-
console.print(
|
|
1241
|
-
"\n[dim]Query interrupted. Press Ctrl+C again to exit.[/dim]"
|
|
1242
|
-
)
|
|
1664
|
+
console.print("\n[dim]Query interrupted. Press Ctrl+C again to exit.[/dim]")
|
|
1243
1665
|
continue
|
|
1244
1666
|
except EOFError:
|
|
1245
1667
|
console.print("\n[yellow]Goodbye![/yellow]")
|
|
@@ -1437,8 +1859,14 @@ def main_rich(
|
|
|
1437
1859
|
append_system_prompt: Optional[str] = None,
|
|
1438
1860
|
model: Optional[str] = None,
|
|
1439
1861
|
resume_messages: Optional[List[Any]] = None,
|
|
1862
|
+
initial_query: Optional[str] = None,
|
|
1440
1863
|
) -> None:
|
|
1441
|
-
"""Main entry point for Rich interface.
|
|
1864
|
+
"""Main entry point for Rich interface.
|
|
1865
|
+
|
|
1866
|
+
Args:
|
|
1867
|
+
initial_query: If provided, automatically send this query after starting the session.
|
|
1868
|
+
Used for piped stdin input (e.g., `echo "query" | ripperdoc`).
|
|
1869
|
+
"""
|
|
1442
1870
|
|
|
1443
1871
|
# Ensure onboarding is complete
|
|
1444
1872
|
if not check_onboarding_rich():
|
|
@@ -1456,6 +1884,7 @@ def main_rich(
|
|
|
1456
1884
|
append_system_prompt=append_system_prompt,
|
|
1457
1885
|
model=model,
|
|
1458
1886
|
resume_messages=resume_messages,
|
|
1887
|
+
initial_query=initial_query,
|
|
1459
1888
|
)
|
|
1460
1889
|
ui.run()
|
|
1461
1890
|
|