ripperdoc 0.2.10__py3-none-any.whl → 0.3.0__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_handler.py +34 -0
- ripperdoc/cli/ui/panels.py +13 -8
- ripperdoc/cli/ui/rich_ui.py +451 -32
- 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 +82 -5
- 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.0.dist-info}/METADATA +49 -17
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -408
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.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
|
|
@@ -48,8 +52,6 @@ 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,
|
|
@@ -81,6 +83,7 @@ from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
|
81
83
|
from ripperdoc.utils.path_ignore import build_ignore_filter
|
|
82
84
|
from ripperdoc.cli.ui.file_mention_completer import FileMentionCompleter
|
|
83
85
|
from ripperdoc.utils.message_formatting import stringify_message_content
|
|
86
|
+
from ripperdoc.utils.image_utils import read_image_as_base64, is_image_file
|
|
84
87
|
|
|
85
88
|
|
|
86
89
|
# Type alias for conversation messages
|
|
@@ -90,6 +93,201 @@ console = Console()
|
|
|
90
93
|
logger = get_logger()
|
|
91
94
|
|
|
92
95
|
|
|
96
|
+
def _suggest_slash_commands(name: str, project_path: Optional[Path]) -> List[str]:
|
|
97
|
+
"""Return close matching slash commands for a mistyped name."""
|
|
98
|
+
if not name:
|
|
99
|
+
return []
|
|
100
|
+
seen = set()
|
|
101
|
+
candidates: List[str] = []
|
|
102
|
+
for command_name, _cmd in slash_command_completions(project_path):
|
|
103
|
+
if command_name not in seen:
|
|
104
|
+
candidates.append(command_name)
|
|
105
|
+
seen.add(command_name)
|
|
106
|
+
return difflib.get_close_matches(name, candidates, n=3, cutoff=0.6)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_image_paths(text: str) -> List[str]:
|
|
110
|
+
"""Extract @-referenced image paths from text.
|
|
111
|
+
|
|
112
|
+
Handles cases like:
|
|
113
|
+
- "@image.png describe this" (space after)
|
|
114
|
+
- "@image.png描述这个" (no space after, Chinese text)
|
|
115
|
+
- "@image.png.whatIsThis" (no space, ASCII text)
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
text: User input text
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of file paths (without the @ prefix)
|
|
122
|
+
"""
|
|
123
|
+
import re
|
|
124
|
+
from pathlib import Path
|
|
125
|
+
|
|
126
|
+
result = []
|
|
127
|
+
|
|
128
|
+
# Find all @ followed by content until space or end
|
|
129
|
+
for match in re.finditer(r"@(\S+)", text):
|
|
130
|
+
candidate = match.group(1)
|
|
131
|
+
if not candidate:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Try to find the actual file path by progressively trimming
|
|
135
|
+
# First, check if the full candidate is a file
|
|
136
|
+
if Path(candidate).exists():
|
|
137
|
+
result.append(candidate)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Not a file, try to find where the file path ends
|
|
141
|
+
# Common file extensions
|
|
142
|
+
extensions = [
|
|
143
|
+
".png",
|
|
144
|
+
".jpg",
|
|
145
|
+
".jpeg",
|
|
146
|
+
".gif",
|
|
147
|
+
".webp",
|
|
148
|
+
".bmp",
|
|
149
|
+
".svg",
|
|
150
|
+
".py",
|
|
151
|
+
".js",
|
|
152
|
+
".ts",
|
|
153
|
+
".tsx",
|
|
154
|
+
".jsx",
|
|
155
|
+
".vue",
|
|
156
|
+
".go",
|
|
157
|
+
".rs",
|
|
158
|
+
".java",
|
|
159
|
+
".c",
|
|
160
|
+
".cpp",
|
|
161
|
+
".h",
|
|
162
|
+
".hpp",
|
|
163
|
+
".cs",
|
|
164
|
+
".php",
|
|
165
|
+
".rb",
|
|
166
|
+
".sh",
|
|
167
|
+
".md",
|
|
168
|
+
".txt",
|
|
169
|
+
".json",
|
|
170
|
+
".yaml",
|
|
171
|
+
".yml",
|
|
172
|
+
".xml",
|
|
173
|
+
".html",
|
|
174
|
+
".css",
|
|
175
|
+
".scss",
|
|
176
|
+
".sql",
|
|
177
|
+
".db",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
found_path = None
|
|
181
|
+
for ext in extensions:
|
|
182
|
+
# Look for this extension in the candidate
|
|
183
|
+
if ext.lower() in candidate.lower():
|
|
184
|
+
# Found extension, extract path up to and including it
|
|
185
|
+
ext_pos = candidate.lower().find(ext.lower())
|
|
186
|
+
potential_path = candidate[: ext_pos + len(ext)]
|
|
187
|
+
if Path(potential_path).exists():
|
|
188
|
+
found_path = potential_path
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
# Also try to find the LAST occurrence of this extension
|
|
192
|
+
# For cases like "file.txt.extraText"
|
|
193
|
+
last_ext_pos = candidate.lower().rfind(ext.lower())
|
|
194
|
+
if last_ext_pos > ext_pos:
|
|
195
|
+
potential_path = candidate[: last_ext_pos + len(ext)]
|
|
196
|
+
if Path(potential_path).exists():
|
|
197
|
+
found_path = potential_path
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
if found_path:
|
|
201
|
+
result.append(found_path)
|
|
202
|
+
else:
|
|
203
|
+
# No file found, keep the original candidate
|
|
204
|
+
# The processing function will handle non-existent files
|
|
205
|
+
result.append(candidate)
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _process_images_in_input(
|
|
211
|
+
user_input: str,
|
|
212
|
+
project_path: Path,
|
|
213
|
+
model_pointer: str,
|
|
214
|
+
) -> tuple[str, List[Dict[str, Any]]]:
|
|
215
|
+
"""Process @ references for images in user input.
|
|
216
|
+
|
|
217
|
+
Only image files are processed and converted to image blocks.
|
|
218
|
+
Text files and non-existent files are left as-is in the text.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
user_input: Raw user input text
|
|
222
|
+
project_path: Project root path
|
|
223
|
+
model_pointer: Model pointer to check vision support
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
(processed_text, image_blocks) tuple
|
|
227
|
+
"""
|
|
228
|
+
import re
|
|
229
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
230
|
+
|
|
231
|
+
image_blocks: List[Dict[str, Any]] = []
|
|
232
|
+
processed_text = user_input
|
|
233
|
+
|
|
234
|
+
# Check if current model supports vision
|
|
235
|
+
profile = get_profile_for_pointer(model_pointer)
|
|
236
|
+
supports_vision = profile and model_supports_vision(profile)
|
|
237
|
+
|
|
238
|
+
if not supports_vision:
|
|
239
|
+
# Model doesn't support vision, leave all @ references as-is
|
|
240
|
+
return processed_text, image_blocks
|
|
241
|
+
|
|
242
|
+
referenced_paths = _extract_image_paths(user_input)
|
|
243
|
+
|
|
244
|
+
for ref_path in referenced_paths:
|
|
245
|
+
# Try relative path first, then absolute path
|
|
246
|
+
path_candidate = project_path / ref_path
|
|
247
|
+
if not path_candidate.exists():
|
|
248
|
+
path_candidate = Path(ref_path)
|
|
249
|
+
|
|
250
|
+
if not path_candidate.exists():
|
|
251
|
+
logger.debug(
|
|
252
|
+
"[ui] @ referenced file not found",
|
|
253
|
+
extra={"path": ref_path},
|
|
254
|
+
)
|
|
255
|
+
# Keep the reference in text (LLM should know file doesn't exist)
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Only process image files
|
|
259
|
+
if not is_image_file(path_candidate):
|
|
260
|
+
# Not an image file, keep @ reference in text
|
|
261
|
+
# The LLM can decide to read it with the Read tool if needed
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
# Process image file
|
|
265
|
+
result = read_image_as_base64(path_candidate)
|
|
266
|
+
if result:
|
|
267
|
+
base64_data, mime_type = result
|
|
268
|
+
image_blocks.append(
|
|
269
|
+
{
|
|
270
|
+
"type": "image",
|
|
271
|
+
"source_type": "base64",
|
|
272
|
+
"media_type": mime_type,
|
|
273
|
+
"image_data": base64_data,
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
# Remove image reference from text (content included separately as image block)
|
|
277
|
+
processed_text = processed_text.replace(f"@{ref_path}", "")
|
|
278
|
+
else:
|
|
279
|
+
# Failed to read image, keep reference in text
|
|
280
|
+
logger.warning(
|
|
281
|
+
"[ui] Failed to read @ referenced image",
|
|
282
|
+
extra={"path": ref_path},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Clean up extra whitespace
|
|
286
|
+
processed_text = re.sub(r"\s+", " ", processed_text).strip()
|
|
287
|
+
|
|
288
|
+
return processed_text, image_blocks
|
|
289
|
+
|
|
290
|
+
|
|
93
291
|
class RichUI:
|
|
94
292
|
"""Rich-based UI for Ripperdoc."""
|
|
95
293
|
|
|
@@ -105,6 +303,7 @@ class RichUI:
|
|
|
105
303
|
append_system_prompt: Optional[str] = None,
|
|
106
304
|
model: Optional[str] = None,
|
|
107
305
|
resume_messages: Optional[List[Any]] = None,
|
|
306
|
+
initial_query: Optional[str] = None,
|
|
108
307
|
):
|
|
109
308
|
self._loop = asyncio.new_event_loop()
|
|
110
309
|
asyncio.set_event_loop(self._loop)
|
|
@@ -121,6 +320,7 @@ class RichUI:
|
|
|
121
320
|
self._current_tool: Optional[str] = None
|
|
122
321
|
self._should_exit: bool = False
|
|
123
322
|
self._last_ctrl_c_time: float = 0.0 # Track Ctrl+C timing for double-press exit
|
|
323
|
+
self._initial_query = initial_query # Query from piped stdin to auto-send on startup
|
|
124
324
|
self.command_list = list_slash_commands()
|
|
125
325
|
self._custom_command_list = list_custom_commands()
|
|
126
326
|
self._prompt_session: Optional[PromptSession] = None
|
|
@@ -147,10 +347,31 @@ class RichUI:
|
|
|
147
347
|
self._session_start_time = time.time()
|
|
148
348
|
self._session_end_sent = False
|
|
149
349
|
self._exit_reason: Optional[str] = None
|
|
350
|
+
self._using_tty_input = False # Track if we're using /dev/tty for input
|
|
351
|
+
self._thinking_mode_enabled = False # Toggle for extended thinking mode
|
|
150
352
|
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
353
|
+
|
|
354
|
+
# Create permission checker with Rich console and PromptSession support
|
|
355
|
+
if not yolo_mode:
|
|
356
|
+
# Create a dedicated PromptSession for permission dialogs
|
|
357
|
+
# This provides better interrupt handling than console.input()
|
|
358
|
+
from prompt_toolkit import PromptSession
|
|
359
|
+
|
|
360
|
+
# Disable CPR (Cursor Position Request) to avoid warnings in terminals
|
|
361
|
+
# that don't support it (like some remote/CI terminals)
|
|
362
|
+
import os
|
|
363
|
+
os.environ['PROMPT_TOOLKIT_NO_CPR'] = '1'
|
|
364
|
+
|
|
365
|
+
permission_session = PromptSession()
|
|
366
|
+
|
|
367
|
+
self._permission_checker = make_permission_checker(
|
|
368
|
+
self.project_path,
|
|
369
|
+
yolo_mode=False,
|
|
370
|
+
console=console, # Pass console for Rich Panel rendering
|
|
371
|
+
prompt_session=permission_session, # Use PromptSession for input
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
self._permission_checker = None
|
|
154
375
|
# Build ignore filter for file completion
|
|
155
376
|
from ripperdoc.utils.path_ignore import get_project_ignore_patterns
|
|
156
377
|
|
|
@@ -169,10 +390,14 @@ class RichUI:
|
|
|
169
390
|
else:
|
|
170
391
|
self.show_full_thinking = show_full_thinking
|
|
171
392
|
|
|
393
|
+
# Initialize theme from config
|
|
394
|
+
theme_manager = get_theme_manager()
|
|
395
|
+
theme_name = getattr(config, "theme", None) or "dark"
|
|
396
|
+
if not theme_manager.set_theme(theme_name):
|
|
397
|
+
theme_manager.set_theme("dark") # Fallback to default
|
|
398
|
+
|
|
172
399
|
# Initialize component handlers
|
|
173
|
-
self._message_display = MessageDisplay(
|
|
174
|
-
self.console, self.verbose, self.show_full_thinking
|
|
175
|
-
)
|
|
400
|
+
self._message_display = MessageDisplay(self.console, self.verbose, self.show_full_thinking)
|
|
176
401
|
self._interrupt_handler = InterruptHandler()
|
|
177
402
|
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
178
403
|
|
|
@@ -227,6 +452,58 @@ class RichUI:
|
|
|
227
452
|
def _esc_listener_paused(self, value: bool) -> None:
|
|
228
453
|
self._interrupt_handler._esc_listener_paused = value
|
|
229
454
|
|
|
455
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
456
|
+
# Thinking mode toggle
|
|
457
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
def _supports_thinking_mode(self) -> bool:
|
|
460
|
+
"""Check if the current model supports extended thinking mode."""
|
|
461
|
+
from ripperdoc.core.query import infer_thinking_mode
|
|
462
|
+
from ripperdoc.core.config import ProviderType
|
|
463
|
+
|
|
464
|
+
model_profile = get_profile_for_pointer("main")
|
|
465
|
+
if not model_profile:
|
|
466
|
+
return False
|
|
467
|
+
# Anthropic natively supports thinking mode
|
|
468
|
+
if model_profile.provider == ProviderType.ANTHROPIC:
|
|
469
|
+
return True
|
|
470
|
+
# For other providers, check if we can infer a thinking mode
|
|
471
|
+
return infer_thinking_mode(model_profile) is not None
|
|
472
|
+
|
|
473
|
+
def _toggle_thinking_mode(self) -> None:
|
|
474
|
+
"""Toggle thinking mode on/off. Status is shown in rprompt."""
|
|
475
|
+
if not self._supports_thinking_mode():
|
|
476
|
+
self.console.print("[yellow]Current model does not support thinking mode.[/yellow]")
|
|
477
|
+
return
|
|
478
|
+
self._thinking_mode_enabled = not self._thinking_mode_enabled
|
|
479
|
+
|
|
480
|
+
def _get_thinking_tokens(self) -> int:
|
|
481
|
+
"""Get the thinking tokens budget based on current mode."""
|
|
482
|
+
if not self._thinking_mode_enabled:
|
|
483
|
+
return 0
|
|
484
|
+
config = get_global_config()
|
|
485
|
+
return config.default_thinking_tokens
|
|
486
|
+
|
|
487
|
+
def _get_prompt(self) -> str:
|
|
488
|
+
"""Generate the input prompt."""
|
|
489
|
+
return "> "
|
|
490
|
+
|
|
491
|
+
def _get_rprompt(self) -> Union[str, FormattedText]:
|
|
492
|
+
"""Generate the right prompt with thinking mode status."""
|
|
493
|
+
if not self._supports_thinking_mode():
|
|
494
|
+
return ""
|
|
495
|
+
if self._thinking_mode_enabled:
|
|
496
|
+
return FormattedText(
|
|
497
|
+
[
|
|
498
|
+
("class:rprompt-on", "⚡ Thinking"),
|
|
499
|
+
]
|
|
500
|
+
)
|
|
501
|
+
return FormattedText(
|
|
502
|
+
[
|
|
503
|
+
("class:rprompt-off", "Thinking: off"),
|
|
504
|
+
]
|
|
505
|
+
)
|
|
506
|
+
|
|
230
507
|
def _context_usage_lines(
|
|
231
508
|
self, breakdown: Any, model_label: str, auto_compact_enabled: bool
|
|
232
509
|
) -> List[str]:
|
|
@@ -758,16 +1035,48 @@ class RichUI:
|
|
|
758
1035
|
output_token_est += delta_tokens
|
|
759
1036
|
spinner.update_tokens(output_token_est)
|
|
760
1037
|
else:
|
|
761
|
-
spinner
|
|
1038
|
+
# Simplify spinner suffix for bash command progress to avoid clutter
|
|
1039
|
+
suffix = self._simplify_progress_suffix(message.content)
|
|
1040
|
+
spinner.update_tokens(output_token_est, suffix=suffix)
|
|
762
1041
|
|
|
763
1042
|
return output_token_est
|
|
764
1043
|
|
|
1044
|
+
def _simplify_progress_suffix(self, content: Any) -> str:
|
|
1045
|
+
"""Simplify progress message content for cleaner spinner display.
|
|
1046
|
+
|
|
1047
|
+
For bash command progress (format: "Running... (elapsed)\nstdout_preview"),
|
|
1048
|
+
extract only the timing information to avoid cluttering the spinner with
|
|
1049
|
+
multi-line stdout content that causes terminal wrapping issues.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
content: Progress message content (can be str or other types)
|
|
1053
|
+
|
|
1054
|
+
Returns:
|
|
1055
|
+
Simplified suffix string for spinner display
|
|
1056
|
+
"""
|
|
1057
|
+
if not isinstance(content, str):
|
|
1058
|
+
return f"Working... {content}"
|
|
1059
|
+
|
|
1060
|
+
# Handle bash command progress: "Running... (10s)\nstdout..."
|
|
1061
|
+
if content.startswith("Running..."):
|
|
1062
|
+
# Extract just the "Running... (time)" part before any newline
|
|
1063
|
+
first_line = content.split("\n", 1)[0]
|
|
1064
|
+
return first_line
|
|
1065
|
+
|
|
1066
|
+
# For other progress messages, limit length to avoid terminal wrapping
|
|
1067
|
+
max_length = 60
|
|
1068
|
+
if len(content) > max_length:
|
|
1069
|
+
return f"Working... {content[:max_length]}..."
|
|
1070
|
+
|
|
1071
|
+
return f"Working... {content}"
|
|
1072
|
+
|
|
765
1073
|
async def process_query(self, user_input: str) -> None:
|
|
766
1074
|
"""Process a user query and display the response."""
|
|
767
1075
|
# Initialize or reset query context
|
|
768
1076
|
if not self.query_context:
|
|
769
1077
|
self.query_context = QueryContext(
|
|
770
1078
|
tools=self.get_default_tools(),
|
|
1079
|
+
max_thinking_tokens=self._get_thinking_tokens(),
|
|
771
1080
|
yolo_mode=self.yolo_mode,
|
|
772
1081
|
verbose=self.verbose,
|
|
773
1082
|
model=self.model,
|
|
@@ -776,6 +1085,8 @@ class RichUI:
|
|
|
776
1085
|
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
777
1086
|
if abort_controller is not None:
|
|
778
1087
|
abort_controller.clear()
|
|
1088
|
+
# Update thinking tokens in case user toggled thinking mode
|
|
1089
|
+
self.query_context.max_thinking_tokens = self._get_thinking_tokens()
|
|
779
1090
|
self.query_context.stop_hook_active = False
|
|
780
1091
|
|
|
781
1092
|
logger.info(
|
|
@@ -791,9 +1102,7 @@ class RichUI:
|
|
|
791
1102
|
hook_result = await hook_manager.run_user_prompt_submit_async(user_input)
|
|
792
1103
|
if hook_result.should_block or not hook_result.should_continue:
|
|
793
1104
|
reason = (
|
|
794
|
-
hook_result.block_reason
|
|
795
|
-
or hook_result.stop_reason
|
|
796
|
-
or "Prompt blocked by hook."
|
|
1105
|
+
hook_result.block_reason or hook_result.stop_reason or "Prompt blocked by hook."
|
|
797
1106
|
)
|
|
798
1107
|
self.console.print(f"[red]{escape(str(reason))}[/red]")
|
|
799
1108
|
return
|
|
@@ -804,11 +1113,29 @@ class RichUI:
|
|
|
804
1113
|
user_input, hook_instructions
|
|
805
1114
|
)
|
|
806
1115
|
|
|
1116
|
+
# Process images in user input
|
|
1117
|
+
processed_input, image_blocks = _process_images_in_input(
|
|
1118
|
+
user_input, self.project_path, self.model
|
|
1119
|
+
)
|
|
1120
|
+
|
|
807
1121
|
# Create and log user message
|
|
808
|
-
|
|
1122
|
+
if image_blocks:
|
|
1123
|
+
# Has images: use structured content
|
|
1124
|
+
content_blocks = []
|
|
1125
|
+
# Add images first
|
|
1126
|
+
for block in image_blocks:
|
|
1127
|
+
content_blocks.append({"type": "image", **block})
|
|
1128
|
+
# Add user's text input
|
|
1129
|
+
if processed_input:
|
|
1130
|
+
content_blocks.append({"type": "text", "text": processed_input})
|
|
1131
|
+
user_message = create_user_message(content=content_blocks)
|
|
1132
|
+
else:
|
|
1133
|
+
# No images: use plain text
|
|
1134
|
+
user_message = create_user_message(content=processed_input)
|
|
1135
|
+
|
|
809
1136
|
messages: List[ConversationMessage] = self.conversation_messages + [user_message]
|
|
810
1137
|
self._log_message(user_message)
|
|
811
|
-
self._append_prompt_history(
|
|
1138
|
+
self._append_prompt_history(processed_input)
|
|
812
1139
|
|
|
813
1140
|
# Get model configuration
|
|
814
1141
|
config = get_global_config()
|
|
@@ -1023,7 +1350,14 @@ class RichUI:
|
|
|
1023
1350
|
# Return the expanded content to be processed as a query
|
|
1024
1351
|
return expanded_content
|
|
1025
1352
|
|
|
1026
|
-
|
|
1353
|
+
suggestions = _suggest_slash_commands(command_name, self.project_path)
|
|
1354
|
+
hint = ""
|
|
1355
|
+
if suggestions:
|
|
1356
|
+
hint = " [dim]Did you mean "
|
|
1357
|
+
hint += ", ".join(f"/{escape(s)}" for s in suggestions)
|
|
1358
|
+
hint += "?[/dim]"
|
|
1359
|
+
|
|
1360
|
+
self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]{hint}")
|
|
1027
1361
|
return True
|
|
1028
1362
|
|
|
1029
1363
|
def get_prompt_session(self) -> PromptSession:
|
|
@@ -1081,8 +1415,18 @@ class RichUI:
|
|
|
1081
1415
|
|
|
1082
1416
|
@key_bindings.add("tab")
|
|
1083
1417
|
def _(event: Any) -> None:
|
|
1084
|
-
"""
|
|
1418
|
+
"""Toggle thinking mode when input is empty; otherwise handle completion."""
|
|
1085
1419
|
buf = event.current_buffer
|
|
1420
|
+
# If input is empty, toggle thinking mode
|
|
1421
|
+
if not buf.text.strip():
|
|
1422
|
+
from prompt_toolkit.application import run_in_terminal
|
|
1423
|
+
|
|
1424
|
+
def _toggle() -> None:
|
|
1425
|
+
ui_instance._toggle_thinking_mode()
|
|
1426
|
+
|
|
1427
|
+
run_in_terminal(_toggle)
|
|
1428
|
+
return
|
|
1429
|
+
# Otherwise, handle completion as usual
|
|
1086
1430
|
if buf.complete_state and buf.complete_state.current_completion:
|
|
1087
1431
|
buf.apply_completion(buf.complete_state.current_completion)
|
|
1088
1432
|
else:
|
|
@@ -1132,6 +1476,42 @@ class RichUI:
|
|
|
1132
1476
|
# Clear the buffer after printing
|
|
1133
1477
|
buf.reset()
|
|
1134
1478
|
|
|
1479
|
+
# If stdin is not a TTY (e.g., piped input), try to use /dev/tty for interactive input
|
|
1480
|
+
# This allows the user to continue interacting after processing piped content
|
|
1481
|
+
input_obj = None
|
|
1482
|
+
if not sys.stdin.isatty():
|
|
1483
|
+
# First check if /dev/tty exists and is accessible
|
|
1484
|
+
try:
|
|
1485
|
+
import os
|
|
1486
|
+
|
|
1487
|
+
if os.path.exists("/dev/tty"):
|
|
1488
|
+
from prompt_toolkit.input import create_input
|
|
1489
|
+
|
|
1490
|
+
input_obj = create_input(always_prefer_tty=True)
|
|
1491
|
+
self._using_tty_input = True # Mark that we're using /dev/tty
|
|
1492
|
+
logger.info(
|
|
1493
|
+
"[ui] Stdin is not a TTY, using /dev/tty for prompt input",
|
|
1494
|
+
extra={"session_id": self.session_id},
|
|
1495
|
+
)
|
|
1496
|
+
else:
|
|
1497
|
+
logger.info(
|
|
1498
|
+
"[ui] Stdin is not a TTY and /dev/tty not available",
|
|
1499
|
+
extra={"session_id": self.session_id},
|
|
1500
|
+
)
|
|
1501
|
+
except (OSError, RuntimeError, ValueError, ImportError) as exc:
|
|
1502
|
+
logger.warning(
|
|
1503
|
+
"[ui] Failed to create TTY input: %s: %s",
|
|
1504
|
+
type(exc).__name__,
|
|
1505
|
+
exc,
|
|
1506
|
+
extra={"session_id": self.session_id},
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
prompt_style = Style.from_dict(
|
|
1510
|
+
{
|
|
1511
|
+
"rprompt-on": "fg:ansicyan bold",
|
|
1512
|
+
"rprompt-off": "fg:ansibrightblack",
|
|
1513
|
+
}
|
|
1514
|
+
)
|
|
1135
1515
|
self._prompt_session = PromptSession(
|
|
1136
1516
|
completer=combined_completer,
|
|
1137
1517
|
complete_style=CompleteStyle.COLUMN,
|
|
@@ -1139,6 +1519,9 @@ class RichUI:
|
|
|
1139
1519
|
history=InMemoryHistory(),
|
|
1140
1520
|
key_bindings=key_bindings,
|
|
1141
1521
|
multiline=True,
|
|
1522
|
+
input=input_obj,
|
|
1523
|
+
style=prompt_style,
|
|
1524
|
+
rprompt=self._get_rprompt,
|
|
1142
1525
|
)
|
|
1143
1526
|
return self._prompt_session
|
|
1144
1527
|
|
|
@@ -1154,7 +1537,8 @@ class RichUI:
|
|
|
1154
1537
|
console.print()
|
|
1155
1538
|
console.print(
|
|
1156
1539
|
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
|
|
1157
|
-
"Press Alt+Enter for newline. Press
|
|
1540
|
+
"Press Alt+Enter for newline. Press Tab to toggle thinking mode. "
|
|
1541
|
+
"Press ESC to interrupt.[/dim]\n"
|
|
1158
1542
|
)
|
|
1159
1543
|
|
|
1160
1544
|
session = self.get_prompt_session()
|
|
@@ -1165,10 +1549,34 @@ class RichUI:
|
|
|
1165
1549
|
|
|
1166
1550
|
exit_reason = "other"
|
|
1167
1551
|
try:
|
|
1552
|
+
# Process initial query from piped stdin if provided
|
|
1553
|
+
if self._initial_query:
|
|
1554
|
+
console.print(f"> {self._initial_query}")
|
|
1555
|
+
logger.info(
|
|
1556
|
+
"[ui] Processing initial query from stdin",
|
|
1557
|
+
extra={
|
|
1558
|
+
"session_id": self.session_id,
|
|
1559
|
+
"prompt_length": len(self._initial_query),
|
|
1560
|
+
"prompt_preview": self._initial_query[:200],
|
|
1561
|
+
},
|
|
1562
|
+
)
|
|
1563
|
+
console.print() # Add spacing before response
|
|
1564
|
+
|
|
1565
|
+
# Use _run_async instead of _run_async_with_esc_interrupt for piped stdin
|
|
1566
|
+
# since there's no TTY for ESC key detection
|
|
1567
|
+
self._run_async(self.process_query(self._initial_query))
|
|
1568
|
+
|
|
1569
|
+
logger.info(
|
|
1570
|
+
"[ui] Initial query completed successfully",
|
|
1571
|
+
extra={"session_id": self.session_id},
|
|
1572
|
+
)
|
|
1573
|
+
console.print() # Add spacing after response
|
|
1574
|
+
self._initial_query = None # Clear after processing
|
|
1575
|
+
|
|
1168
1576
|
while not self._should_exit:
|
|
1169
1577
|
try:
|
|
1170
|
-
# Get user input
|
|
1171
|
-
user_input = session.prompt(
|
|
1578
|
+
# Get user input with dynamic prompt
|
|
1579
|
+
user_input = session.prompt(self._get_prompt())
|
|
1172
1580
|
|
|
1173
1581
|
if not user_input.strip():
|
|
1174
1582
|
continue
|
|
@@ -1205,16 +1613,22 @@ class RichUI:
|
|
|
1205
1613
|
"prompt_preview": user_input[:200],
|
|
1206
1614
|
},
|
|
1207
1615
|
)
|
|
1208
|
-
interrupted = self._run_async_with_esc_interrupt(self.process_query(user_input))
|
|
1209
1616
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
extra={"session_id": self.session_id},
|
|
1617
|
+
# When using /dev/tty input, disable ESC interrupt to avoid conflicts
|
|
1618
|
+
if self._using_tty_input:
|
|
1619
|
+
self._run_async(self.process_query(user_input))
|
|
1620
|
+
else:
|
|
1621
|
+
interrupted = self._run_async_with_esc_interrupt(
|
|
1622
|
+
self.process_query(user_input)
|
|
1217
1623
|
)
|
|
1624
|
+
if interrupted:
|
|
1625
|
+
console.print(
|
|
1626
|
+
"\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
|
|
1627
|
+
)
|
|
1628
|
+
logger.info(
|
|
1629
|
+
"[ui] Query interrupted by ESC key",
|
|
1630
|
+
extra={"session_id": self.session_id},
|
|
1631
|
+
)
|
|
1218
1632
|
|
|
1219
1633
|
console.print() # Add spacing between interactions
|
|
1220
1634
|
|
|
@@ -1237,9 +1651,7 @@ class RichUI:
|
|
|
1237
1651
|
|
|
1238
1652
|
# First Ctrl+C - just abort the query and continue
|
|
1239
1653
|
self._last_ctrl_c_time = current_time
|
|
1240
|
-
console.print(
|
|
1241
|
-
"\n[dim]Query interrupted. Press Ctrl+C again to exit.[/dim]"
|
|
1242
|
-
)
|
|
1654
|
+
console.print("\n[dim]Query interrupted. Press Ctrl+C again to exit.[/dim]")
|
|
1243
1655
|
continue
|
|
1244
1656
|
except EOFError:
|
|
1245
1657
|
console.print("\n[yellow]Goodbye![/yellow]")
|
|
@@ -1437,8 +1849,14 @@ def main_rich(
|
|
|
1437
1849
|
append_system_prompt: Optional[str] = None,
|
|
1438
1850
|
model: Optional[str] = None,
|
|
1439
1851
|
resume_messages: Optional[List[Any]] = None,
|
|
1852
|
+
initial_query: Optional[str] = None,
|
|
1440
1853
|
) -> None:
|
|
1441
|
-
"""Main entry point for Rich interface.
|
|
1854
|
+
"""Main entry point for Rich interface.
|
|
1855
|
+
|
|
1856
|
+
Args:
|
|
1857
|
+
initial_query: If provided, automatically send this query after starting the session.
|
|
1858
|
+
Used for piped stdin input (e.g., `echo "query" | ripperdoc`).
|
|
1859
|
+
"""
|
|
1442
1860
|
|
|
1443
1861
|
# Ensure onboarding is complete
|
|
1444
1862
|
if not check_onboarding_rich():
|
|
@@ -1456,6 +1874,7 @@ def main_rich(
|
|
|
1456
1874
|
append_system_prompt=append_system_prompt,
|
|
1457
1875
|
model=model,
|
|
1458
1876
|
resume_messages=resume_messages,
|
|
1877
|
+
initial_query=initial_query,
|
|
1459
1878
|
)
|
|
1460
1879
|
ui.run()
|
|
1461
1880
|
|