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.
Files changed (70) 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_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.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
@@ -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
- self._permission_checker = (
152
- None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
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.update_tokens(output_token_est, suffix=f"Working... {message.content}")
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
- user_message = create_user_message(user_input)
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(user_input)
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
- self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
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
- """Use Tab to accept the highlighted completion when visible."""
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 ESC to interrupt. Press Ctrl+C twice to exit.[/dim]\n"
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
- 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},
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