ripperdoc 0.2.6__py3-none-any.whl → 0.2.8__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 +5 -0
- ripperdoc/cli/commands/__init__.py +71 -6
- ripperdoc/cli/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +636 -0
- ripperdoc/cli/commands/permissions_cmd.py +36 -34
- ripperdoc/cli/commands/resume_cmd.py +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +276 -0
- ripperdoc/cli/ui/helpers.py +100 -3
- ripperdoc/cli/ui/interrupt_handler.py +175 -0
- ripperdoc/cli/ui/message_display.py +249 -0
- ripperdoc/cli/ui/panels.py +63 -0
- ripperdoc/cli/ui/rich_ui.py +233 -648
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- ripperdoc/core/custom_commands.py +411 -0
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +303 -0
- ripperdoc/core/hooks/events.py +540 -0
- ripperdoc/core/hooks/executor.py +498 -0
- ripperdoc/core/hooks/integration.py +353 -0
- ripperdoc/core/hooks/manager.py +720 -0
- ripperdoc/core/providers/anthropic.py +476 -69
- ripperdoc/core/query.py +61 -4
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +5 -5
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +2 -2
- ripperdoc/tools/multi_edit_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +476 -0
- ripperdoc/utils/message_compaction.py +109 -154
- ripperdoc/utils/message_formatting.py +216 -0
- ripperdoc/utils/messages.py +31 -9
- ripperdoc/utils/path_ignore.py +3 -4
- ripperdoc/utils/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Interrupt handling for RichUI.
|
|
2
|
+
|
|
3
|
+
This module handles ESC/Ctrl+C key detection during query execution,
|
|
4
|
+
including terminal raw mode management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Optional, Set
|
|
11
|
+
|
|
12
|
+
from ripperdoc.utils.log import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger()
|
|
15
|
+
|
|
16
|
+
# Keys that trigger interrupt
|
|
17
|
+
INTERRUPT_KEYS: Set[str] = {'\x1b', '\x03'} # ESC, Ctrl+C
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InterruptHandler:
|
|
21
|
+
"""Handles keyboard interrupt detection during async operations."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
"""Initialize the interrupt handler."""
|
|
25
|
+
self._query_interrupted: bool = False
|
|
26
|
+
self._esc_listener_active: bool = False
|
|
27
|
+
self._esc_listener_paused: bool = False
|
|
28
|
+
self._stdin_fd: Optional[int] = None
|
|
29
|
+
self._stdin_old_settings: Optional[list] = None
|
|
30
|
+
self._stdin_in_raw_mode: bool = False
|
|
31
|
+
self._abort_callback: Optional[Any] = None
|
|
32
|
+
|
|
33
|
+
def set_abort_callback(self, callback: Any) -> None:
|
|
34
|
+
"""Set the callback to trigger when interrupt is detected."""
|
|
35
|
+
self._abort_callback = callback
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def was_interrupted(self) -> bool:
|
|
39
|
+
"""Check if the last query was interrupted."""
|
|
40
|
+
return self._query_interrupted
|
|
41
|
+
|
|
42
|
+
def pause_listener(self) -> bool:
|
|
43
|
+
"""Pause ESC listener and restore cooked terminal mode if we own raw mode.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Previous paused state for later restoration.
|
|
47
|
+
"""
|
|
48
|
+
prev = self._esc_listener_paused
|
|
49
|
+
self._esc_listener_paused = True
|
|
50
|
+
try:
|
|
51
|
+
import termios
|
|
52
|
+
except ImportError:
|
|
53
|
+
return prev
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
self._stdin_fd is not None
|
|
57
|
+
and self._stdin_old_settings is not None
|
|
58
|
+
and self._stdin_in_raw_mode
|
|
59
|
+
):
|
|
60
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
61
|
+
termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._stdin_old_settings)
|
|
62
|
+
self._stdin_in_raw_mode = False
|
|
63
|
+
return prev
|
|
64
|
+
|
|
65
|
+
def resume_listener(self, previous_state: bool) -> None:
|
|
66
|
+
"""Restore paused state to what it was before a blocking prompt."""
|
|
67
|
+
self._esc_listener_paused = previous_state
|
|
68
|
+
|
|
69
|
+
async def _listen_for_interrupt_key(self) -> bool:
|
|
70
|
+
"""Listen for interrupt keys (ESC/Ctrl+C) during query execution.
|
|
71
|
+
|
|
72
|
+
Uses raw terminal mode for immediate key detection without waiting
|
|
73
|
+
for escape sequences to complete.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if an interrupt key was pressed.
|
|
77
|
+
"""
|
|
78
|
+
import select
|
|
79
|
+
import termios
|
|
80
|
+
import tty
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
fd = sys.stdin.fileno()
|
|
84
|
+
old_settings = termios.tcgetattr(fd)
|
|
85
|
+
except (OSError, termios.error, ValueError):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
self._stdin_fd = fd
|
|
89
|
+
self._stdin_old_settings = old_settings
|
|
90
|
+
raw_enabled = False
|
|
91
|
+
try:
|
|
92
|
+
while self._esc_listener_active:
|
|
93
|
+
if self._esc_listener_paused:
|
|
94
|
+
if raw_enabled:
|
|
95
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
96
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
97
|
+
raw_enabled = False
|
|
98
|
+
self._stdin_in_raw_mode = False
|
|
99
|
+
await asyncio.sleep(0.05)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if not raw_enabled:
|
|
103
|
+
tty.setraw(fd)
|
|
104
|
+
raw_enabled = True
|
|
105
|
+
self._stdin_in_raw_mode = True
|
|
106
|
+
|
|
107
|
+
await asyncio.sleep(0.02)
|
|
108
|
+
if select.select([sys.stdin], [], [], 0)[0]:
|
|
109
|
+
if sys.stdin.read(1) in INTERRUPT_KEYS:
|
|
110
|
+
return True
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
pass
|
|
113
|
+
finally:
|
|
114
|
+
self._stdin_in_raw_mode = False
|
|
115
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
116
|
+
if raw_enabled:
|
|
117
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
118
|
+
self._stdin_fd = None
|
|
119
|
+
self._stdin_old_settings = None
|
|
120
|
+
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
async def _cancel_task(self, task: asyncio.Task) -> None:
|
|
124
|
+
"""Cancel a task and wait for it to finish."""
|
|
125
|
+
if not task.done():
|
|
126
|
+
task.cancel()
|
|
127
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
128
|
+
await task
|
|
129
|
+
|
|
130
|
+
def _trigger_abort(self) -> None:
|
|
131
|
+
"""Signal the query to abort via callback."""
|
|
132
|
+
if self._abort_callback is not None:
|
|
133
|
+
self._abort_callback()
|
|
134
|
+
|
|
135
|
+
async def run_with_interrupt(self, query_coro: Any) -> bool:
|
|
136
|
+
"""Run a coroutine with ESC key interrupt support.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
query_coro: The coroutine to run with interrupt support.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if interrupted, False if completed normally.
|
|
143
|
+
"""
|
|
144
|
+
self._query_interrupted = False
|
|
145
|
+
self._esc_listener_active = True
|
|
146
|
+
|
|
147
|
+
query_task = asyncio.create_task(query_coro)
|
|
148
|
+
interrupt_task = asyncio.create_task(self._listen_for_interrupt_key())
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
done, _ = await asyncio.wait(
|
|
152
|
+
{query_task, interrupt_task},
|
|
153
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Check if interrupted
|
|
157
|
+
if interrupt_task in done and interrupt_task.result():
|
|
158
|
+
self._query_interrupted = True
|
|
159
|
+
self._trigger_abort()
|
|
160
|
+
await self._cancel_task(query_task)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
# Query completed normally
|
|
164
|
+
if query_task in done:
|
|
165
|
+
await self._cancel_task(interrupt_task)
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
query_task.result()
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
finally:
|
|
173
|
+
self._esc_listener_active = False
|
|
174
|
+
await self._cancel_task(query_task)
|
|
175
|
+
await self._cancel_task(interrupt_task)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Message display and rendering utilities for RichUI.
|
|
2
|
+
|
|
3
|
+
This module handles rendering conversation messages to the terminal, including:
|
|
4
|
+
- Tool call and result formatting
|
|
5
|
+
- Assistant/user message display
|
|
6
|
+
- Reasoning block rendering
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
|
|
15
|
+
from ripperdoc.cli.ui.tool_renderers import ToolResultRendererRegistry
|
|
16
|
+
from ripperdoc.utils.messages import UserMessage, AssistantMessage, ProgressMessage
|
|
17
|
+
from ripperdoc.utils.message_formatting import format_reasoning_preview
|
|
18
|
+
|
|
19
|
+
ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessageDisplay:
|
|
23
|
+
"""Handles message rendering and display operations."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, console: Console, verbose: bool = False):
|
|
26
|
+
"""Initialize the message display handler.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
console: Rich console for output
|
|
30
|
+
verbose: Whether to show verbose output
|
|
31
|
+
"""
|
|
32
|
+
self.console = console
|
|
33
|
+
self.verbose = verbose
|
|
34
|
+
|
|
35
|
+
def format_tool_args(self, tool_name: str, tool_args: Optional[dict]) -> List[str]:
|
|
36
|
+
"""Render tool arguments into concise display-friendly parts."""
|
|
37
|
+
if not tool_args:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
args_parts: List[str] = []
|
|
41
|
+
|
|
42
|
+
def _format_arg(arg_key: str, arg_value: Any) -> str:
|
|
43
|
+
if arg_key == "todos" and isinstance(arg_value, list):
|
|
44
|
+
counts = {"pending": 0, "in_progress": 0, "completed": 0}
|
|
45
|
+
for item in arg_value:
|
|
46
|
+
status = ""
|
|
47
|
+
if isinstance(item, dict):
|
|
48
|
+
status = item.get("status", "")
|
|
49
|
+
elif hasattr(item, "get"):
|
|
50
|
+
status = item.get("status", "")
|
|
51
|
+
elif hasattr(item, "status"):
|
|
52
|
+
status = getattr(item, "status")
|
|
53
|
+
if status in counts:
|
|
54
|
+
counts[status] += 1
|
|
55
|
+
total = len(arg_value)
|
|
56
|
+
return f"{arg_key}: {total} items"
|
|
57
|
+
if isinstance(arg_value, (list, dict)):
|
|
58
|
+
return f"{arg_key}: {len(arg_value)} items"
|
|
59
|
+
if isinstance(arg_value, str) and len(arg_value) > 50:
|
|
60
|
+
return f'{arg_key}: "{arg_value[:50]}..."'
|
|
61
|
+
return f"{arg_key}: {arg_value}"
|
|
62
|
+
|
|
63
|
+
if tool_name == "Bash":
|
|
64
|
+
command_value = tool_args.get("command")
|
|
65
|
+
if command_value is not None:
|
|
66
|
+
args_parts.append(_format_arg("command", command_value))
|
|
67
|
+
|
|
68
|
+
background_value = tool_args.get("run_in_background", tool_args.get("runInBackground"))
|
|
69
|
+
background_value = bool(background_value) if background_value is not None else False
|
|
70
|
+
args_parts.append(f"background: {background_value}")
|
|
71
|
+
|
|
72
|
+
sandbox_value = tool_args.get("sandbox")
|
|
73
|
+
sandbox_value = bool(sandbox_value) if sandbox_value is not None else False
|
|
74
|
+
args_parts.append(f"sandbox: {sandbox_value}")
|
|
75
|
+
|
|
76
|
+
for key, value in tool_args.items():
|
|
77
|
+
if key in {"command", "run_in_background", "runInBackground", "sandbox"}:
|
|
78
|
+
continue
|
|
79
|
+
args_parts.append(_format_arg(key, value))
|
|
80
|
+
return args_parts
|
|
81
|
+
|
|
82
|
+
# Special handling for Edit and MultiEdit tools - don't show old_string
|
|
83
|
+
if tool_name in ["Edit", "MultiEdit"]:
|
|
84
|
+
for key, value in tool_args.items():
|
|
85
|
+
if key == "new_string":
|
|
86
|
+
continue # Skip new_string for Edit/MultiEdit tools
|
|
87
|
+
if key == "old_string":
|
|
88
|
+
continue # Skip old_string for Edit/MultiEdit tools
|
|
89
|
+
# For MultiEdit, also handle edits array
|
|
90
|
+
if key == "edits" and isinstance(value, list):
|
|
91
|
+
args_parts.append(f"edits: {len(value)} operations")
|
|
92
|
+
continue
|
|
93
|
+
args_parts.append(_format_arg(key, value))
|
|
94
|
+
return args_parts
|
|
95
|
+
|
|
96
|
+
for key, value in tool_args.items():
|
|
97
|
+
args_parts.append(_format_arg(key, value))
|
|
98
|
+
return args_parts
|
|
99
|
+
|
|
100
|
+
def print_tool_call(self, sender: str, content: str, tool_args: Optional[dict]) -> None:
|
|
101
|
+
"""Render a tool invocation line."""
|
|
102
|
+
if sender == "Task":
|
|
103
|
+
subagent = ""
|
|
104
|
+
if isinstance(tool_args, dict):
|
|
105
|
+
subagent = tool_args.get("subagent_type") or tool_args.get("subagent") or ""
|
|
106
|
+
desc = ""
|
|
107
|
+
if isinstance(tool_args, dict):
|
|
108
|
+
raw_desc = tool_args.get("description") or tool_args.get("prompt") or ""
|
|
109
|
+
desc = raw_desc if len(str(raw_desc)) <= 120 else str(raw_desc)[:117] + "..."
|
|
110
|
+
label = f"-> Launching subagent: {subagent or 'unknown'}"
|
|
111
|
+
if desc:
|
|
112
|
+
label += f" — {desc}"
|
|
113
|
+
self.console.print(f"[cyan]{escape(label)}[/cyan]")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
tool_name = sender if sender != "Ripperdoc" else content
|
|
117
|
+
tool_display = f"● {tool_name}("
|
|
118
|
+
|
|
119
|
+
args_parts = self.format_tool_args(tool_name, tool_args)
|
|
120
|
+
if args_parts:
|
|
121
|
+
tool_display += ", ".join(args_parts)
|
|
122
|
+
tool_display += ")"
|
|
123
|
+
|
|
124
|
+
self.console.print(f"[dim cyan]{escape(tool_display)}[/]")
|
|
125
|
+
|
|
126
|
+
def print_tool_result(
|
|
127
|
+
self,
|
|
128
|
+
sender: str,
|
|
129
|
+
content: str,
|
|
130
|
+
tool_data: Any,
|
|
131
|
+
tool_error: bool = False,
|
|
132
|
+
parse_bash_output_fn: Optional[Callable] = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Render a tool result summary using the renderer registry."""
|
|
135
|
+
# Check for failure states
|
|
136
|
+
failed = tool_error
|
|
137
|
+
if tool_data is not None:
|
|
138
|
+
if isinstance(tool_data, dict):
|
|
139
|
+
failed = failed or (tool_data.get("success") is False)
|
|
140
|
+
else:
|
|
141
|
+
success = getattr(tool_data, "success", None)
|
|
142
|
+
failed = failed or (success is False)
|
|
143
|
+
failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
|
|
144
|
+
|
|
145
|
+
# Extract warning/token info
|
|
146
|
+
warning_text = None
|
|
147
|
+
token_estimate = None
|
|
148
|
+
if tool_data is not None:
|
|
149
|
+
warning_text = self._get_tool_field(tool_data, "warning")
|
|
150
|
+
token_estimate = self._get_tool_field(tool_data, "token_estimate")
|
|
151
|
+
|
|
152
|
+
# Handle failure case
|
|
153
|
+
if failed:
|
|
154
|
+
if content:
|
|
155
|
+
self.console.print(f" ⎿ [red]{escape(content)}[/red]")
|
|
156
|
+
else:
|
|
157
|
+
self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Display warnings and token estimates
|
|
161
|
+
if warning_text:
|
|
162
|
+
self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
|
|
163
|
+
if token_estimate:
|
|
164
|
+
self.console.print(
|
|
165
|
+
f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
|
|
166
|
+
)
|
|
167
|
+
elif token_estimate and self.verbose:
|
|
168
|
+
self.console.print(f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]")
|
|
169
|
+
|
|
170
|
+
# Handle empty content
|
|
171
|
+
if not content:
|
|
172
|
+
self.console.print(" ⎿ [dim]Tool completed[/]")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
# Use renderer registry for tool-specific rendering
|
|
176
|
+
registry = ToolResultRendererRegistry(
|
|
177
|
+
self.console, self.verbose, parse_bash_output_fn or self._default_parse_bash
|
|
178
|
+
)
|
|
179
|
+
if registry.render(sender, content, tool_data):
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Fallback for unhandled tools
|
|
183
|
+
self.console.print(" ⎿ [dim]Tool completed[/]")
|
|
184
|
+
|
|
185
|
+
def print_generic_tool(self, sender: str, content: str) -> None:
|
|
186
|
+
"""Fallback rendering for miscellaneous tool messages."""
|
|
187
|
+
if sender == "Task" and isinstance(content, str) and content.startswith("[subagent:"):
|
|
188
|
+
agent_label = content.split("]", 1)[0].replace("[subagent:", "").strip()
|
|
189
|
+
summary = content.split("]", 1)[1].strip() if "]" in content else ""
|
|
190
|
+
self.console.print(f"[green]↳ Subagent {escape(agent_label)} finished[/green]")
|
|
191
|
+
if summary:
|
|
192
|
+
self.console.print(f" {summary}", markup=False)
|
|
193
|
+
return
|
|
194
|
+
self.console.print(f"[dim cyan][Tool] {escape(sender)}: {escape(content)}[/]")
|
|
195
|
+
|
|
196
|
+
def print_human_or_assistant(self, sender: str, content: str) -> None:
|
|
197
|
+
"""Render messages from the user or assistant."""
|
|
198
|
+
if sender.lower() == "you":
|
|
199
|
+
self.console.print(f"[bold green]{escape(sender)}:[/] {escape(content)}")
|
|
200
|
+
return
|
|
201
|
+
self.console.print(Markdown(content))
|
|
202
|
+
|
|
203
|
+
def _get_tool_field(self, data: Any, key: str, default: Any = None) -> Any:
|
|
204
|
+
"""Safely fetch a field from either an object or a dict."""
|
|
205
|
+
if isinstance(data, dict):
|
|
206
|
+
return data.get(key, default)
|
|
207
|
+
return getattr(data, key, default)
|
|
208
|
+
|
|
209
|
+
def _default_parse_bash(self, content: str) -> Tuple[List[str], List[str]]:
|
|
210
|
+
"""Default bash output parser."""
|
|
211
|
+
return parse_bash_output_sections(content)
|
|
212
|
+
|
|
213
|
+
def print_reasoning(self, reasoning: Any) -> None:
|
|
214
|
+
"""Display a collapsed preview of reasoning/thinking blocks."""
|
|
215
|
+
preview = format_reasoning_preview(reasoning)
|
|
216
|
+
if preview:
|
|
217
|
+
self.console.print(f"[dim italic]Thinking: {escape(preview)}[/]")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_bash_output_sections(content: str) -> Tuple[List[str], List[str]]:
|
|
221
|
+
"""Parse stdout/stderr sections from a bash output text block."""
|
|
222
|
+
stdout_lines: List[str] = []
|
|
223
|
+
stderr_lines: List[str] = []
|
|
224
|
+
if not content:
|
|
225
|
+
return stdout_lines, stderr_lines
|
|
226
|
+
|
|
227
|
+
current: Optional[str] = None
|
|
228
|
+
for line in content.splitlines():
|
|
229
|
+
stripped = line.strip()
|
|
230
|
+
if stripped.startswith("stdout:"):
|
|
231
|
+
current = "stdout"
|
|
232
|
+
remainder = line.split("stdout:", 1)[1].strip()
|
|
233
|
+
if remainder:
|
|
234
|
+
stdout_lines.append(remainder)
|
|
235
|
+
continue
|
|
236
|
+
if stripped.startswith("stderr:"):
|
|
237
|
+
current = "stderr"
|
|
238
|
+
remainder = line.split("stderr:", 1)[1].strip()
|
|
239
|
+
if remainder:
|
|
240
|
+
stderr_lines.append(remainder)
|
|
241
|
+
continue
|
|
242
|
+
if stripped.startswith("exit code:"):
|
|
243
|
+
break
|
|
244
|
+
if current == "stdout":
|
|
245
|
+
stdout_lines.append(line)
|
|
246
|
+
elif current == "stderr":
|
|
247
|
+
stderr_lines.append(line)
|
|
248
|
+
|
|
249
|
+
return stdout_lines, stderr_lines
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""UI panels and visual components for RichUI.
|
|
2
|
+
|
|
3
|
+
This module contains welcome panels, status bars, and other
|
|
4
|
+
visual UI elements.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Tuple
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from rich import box
|
|
13
|
+
|
|
14
|
+
from ripperdoc import __version__
|
|
15
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_welcome_panel() -> Panel:
|
|
19
|
+
"""Create a welcome panel for the CLI startup."""
|
|
20
|
+
welcome_content = """
|
|
21
|
+
[bold cyan]Welcome to Ripperdoc![/bold cyan]
|
|
22
|
+
|
|
23
|
+
Ripperdoc is an AI-powered coding assistant that helps with software development tasks.
|
|
24
|
+
You can read files, edit code, run commands, and help with various programming tasks.
|
|
25
|
+
|
|
26
|
+
[dim]Type your questions below. Press Ctrl+C to exit.[/dim]
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
return Panel(
|
|
30
|
+
welcome_content,
|
|
31
|
+
title=f"Ripperdoc v{__version__}",
|
|
32
|
+
border_style="cyan",
|
|
33
|
+
box=box.ROUNDED,
|
|
34
|
+
padding=(1, 2),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_status_bar() -> Text:
|
|
39
|
+
"""Create a status bar with current model information."""
|
|
40
|
+
profile = get_profile_for_pointer("main")
|
|
41
|
+
model_name = profile.model if profile else "Not configured"
|
|
42
|
+
|
|
43
|
+
status_text = Text()
|
|
44
|
+
status_text.append("Ripperdoc", style="bold cyan")
|
|
45
|
+
status_text.append(" • ")
|
|
46
|
+
status_text.append(model_name, style="dim")
|
|
47
|
+
status_text.append(" • ")
|
|
48
|
+
status_text.append("Ready", style="green")
|
|
49
|
+
|
|
50
|
+
return status_text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_shortcuts(console: Console) -> None:
|
|
54
|
+
"""Show common keyboard shortcuts and prefixes."""
|
|
55
|
+
pairs: List[Tuple[str, str]] = [
|
|
56
|
+
("? for shortcuts", "! for bash mode"),
|
|
57
|
+
("/ for commands", "@ for file mention"),
|
|
58
|
+
]
|
|
59
|
+
console.print("[dim]Shortcuts[/dim]")
|
|
60
|
+
for left, right in pairs:
|
|
61
|
+
left_text = f" {left}".ljust(32)
|
|
62
|
+
right_text = f"{right}" if right else ""
|
|
63
|
+
console.print(f"{left_text}{right_text}")
|