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.
Files changed (73) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  17. ripperdoc/cli/ui/message_display.py +7 -0
  18. ripperdoc/cli/ui/panels.py +13 -8
  19. ripperdoc/cli/ui/rich_ui.py +513 -84
  20. ripperdoc/cli/ui/spinner.py +68 -5
  21. ripperdoc/cli/ui/tool_renderers.py +10 -9
  22. ripperdoc/cli/ui/wizard.py +18 -11
  23. ripperdoc/core/agents.py +4 -0
  24. ripperdoc/core/config.py +235 -0
  25. ripperdoc/core/default_tools.py +1 -0
  26. ripperdoc/core/hooks/llm_callback.py +0 -1
  27. ripperdoc/core/hooks/manager.py +6 -0
  28. ripperdoc/core/permissions.py +123 -39
  29. ripperdoc/core/providers/openai.py +55 -9
  30. ripperdoc/core/query.py +349 -108
  31. ripperdoc/core/query_utils.py +17 -14
  32. ripperdoc/core/skills.py +1 -0
  33. ripperdoc/core/theme.py +298 -0
  34. ripperdoc/core/tool.py +8 -3
  35. ripperdoc/protocol/__init__.py +14 -0
  36. ripperdoc/protocol/models.py +300 -0
  37. ripperdoc/protocol/stdio.py +1453 -0
  38. ripperdoc/tools/background_shell.py +49 -5
  39. ripperdoc/tools/bash_tool.py +75 -9
  40. ripperdoc/tools/file_edit_tool.py +98 -29
  41. ripperdoc/tools/file_read_tool.py +139 -8
  42. ripperdoc/tools/file_write_tool.py +46 -3
  43. ripperdoc/tools/grep_tool.py +98 -8
  44. ripperdoc/tools/lsp_tool.py +9 -15
  45. ripperdoc/tools/multi_edit_tool.py +26 -3
  46. ripperdoc/tools/skill_tool.py +52 -1
  47. ripperdoc/tools/task_tool.py +33 -8
  48. ripperdoc/utils/file_watch.py +12 -6
  49. ripperdoc/utils/image_utils.py +125 -0
  50. ripperdoc/utils/log.py +30 -3
  51. ripperdoc/utils/lsp.py +9 -3
  52. ripperdoc/utils/mcp.py +80 -18
  53. ripperdoc/utils/message_formatting.py +2 -2
  54. ripperdoc/utils/messages.py +177 -32
  55. ripperdoc/utils/pending_messages.py +50 -0
  56. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  57. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  58. ripperdoc/utils/platform.py +198 -0
  59. ripperdoc/utils/session_heatmap.py +1 -3
  60. ripperdoc/utils/session_history.py +2 -2
  61. ripperdoc/utils/session_stats.py +1 -0
  62. ripperdoc/utils/shell_utils.py +8 -5
  63. ripperdoc/utils/todo.py +0 -6
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/METADATA +49 -17
  65. ripperdoc-0.3.1.dist-info/RECORD +136 -0
  66. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/WHEEL +1 -1
  67. ripperdoc/cli/ui/interrupt_handler.py +0 -174
  68. ripperdoc/sdk/__init__.py +0 -9
  69. ripperdoc/sdk/client.py +0 -408
  70. ripperdoc-0.2.10.dist-info/RECORD +0 -129
  71. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/entry_points.txt +0 -0
  72. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/licenses/LICENSE +0 -0
  73. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/top_level.txt +0 -0
@@ -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.interrupt_handler import InterruptHandler
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
- self._permission_checker = (
152
- None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
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
- @property
219
- def _query_interrupted(self) -> bool:
220
- return self._interrupt_handler.was_interrupted
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
- @property
223
- def _esc_listener_paused(self) -> bool:
224
- return self._interrupt_handler._esc_listener_paused
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
- @_esc_listener_paused.setter
227
- def _esc_listener_paused(self, value: bool) -> None:
228
- self._interrupt_handler._esc_listener_paused = value
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.update_tokens(output_token_est, suffix=f"Working... {message.content}")
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
- user_message = create_user_message(user_input)
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(user_input)
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
- spinner.stop()
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
- spinner.start()
836
- spinner.update("Thinking...")
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
- spinner.stop()
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
- self._resume_interrupt_listener(was_paused)
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
- # Delegate to InterruptHandler
956
- def _pause_interrupt_listener(self) -> bool:
957
- return self._interrupt_handler.pause_listener()
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 _resume_interrupt_listener(self, previous_state: bool) -> None:
960
- self._interrupt_handler.resume_listener(previous_state)
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
- def _trigger_abort(self) -> None:
963
- """Signal the query to abort."""
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
- async def _run_query_with_esc_interrupt(self, query_coro: Any) -> bool:
968
- """Run a query with ESC key interrupt support."""
969
- return await self._interrupt_handler.run_with_interrupt(query_coro)
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
- self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
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
- """Use Tab to accept the highlighted completion when visible."""
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 ESC to interrupt. Press Ctrl+C twice to exit.[/dim]\n"
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
- if interrupted:
1211
- console.print(
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