ripperdoc 0.2.9__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 +379 -51
- ripperdoc/cli/commands/__init__.py +6 -0
- ripperdoc/cli/commands/agents_cmd.py +128 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +63 -7
- ripperdoc/cli/commands/resume_cmd.py +5 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- 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 +14 -8
- ripperdoc/cli/ui/rich_ui.py +737 -47
- ripperdoc/cli/ui/spinner.py +93 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +24 -19
- ripperdoc/core/agents.py +14 -3
- ripperdoc/core/config.py +238 -6
- ripperdoc/core/default_tools.py +91 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +58 -0
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +160 -9
- ripperdoc/core/providers/openai.py +84 -28
- ripperdoc/core/query.py +489 -87
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +15 -5
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +354 -139
- ripperdoc/tools/bash_tool.py +117 -22
- ripperdoc/tools/file_edit_tool.py +228 -50
- ripperdoc/tools/file_read_tool.py +154 -3
- ripperdoc/tools/file_write_tool.py +53 -11
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +609 -0
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +539 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +216 -7
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +812 -0
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +7 -4
- ripperdoc/utils/messages.py +198 -33
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +242 -0
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +294 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
- ripperdoc-0.3.0.dist-info/RECORD +136 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -333
- ripperdoc-0.2.9.dist-info/RECORD +0 -123
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Statistics command to show session usage patterns."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ripperdoc.utils.session_heatmap import render_heatmap
|
|
9
|
+
from ripperdoc.utils.session_stats import (
|
|
10
|
+
collect_session_stats,
|
|
11
|
+
format_duration,
|
|
12
|
+
format_large_number,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .base import SlashCommand
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_number(n: int) -> str:
|
|
19
|
+
"""Format number with thousands separator."""
|
|
20
|
+
return f"{n:,}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _truncate_model_name(model: str, max_len: int = 15) -> str:
|
|
24
|
+
"""Truncate model name with ellipsis if too long."""
|
|
25
|
+
if len(model) <= max_len:
|
|
26
|
+
return model
|
|
27
|
+
return model[: max_len - 1] + "…"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_token_comparison(total_tokens: int) -> str:
|
|
31
|
+
"""Get a fun comparison for total tokens based on famous books.
|
|
32
|
+
|
|
33
|
+
Reference data from famous books' approximate token counts.
|
|
34
|
+
"""
|
|
35
|
+
books = [
|
|
36
|
+
("The Old Man and the Sea", 35000),
|
|
37
|
+
("Animal Farm", 39000),
|
|
38
|
+
("The Great Gatsby", 62000),
|
|
39
|
+
("Brave New World", 83000),
|
|
40
|
+
("Harry Potter and the Philosopher's Stone", 103000),
|
|
41
|
+
("The Hobbit", 123000),
|
|
42
|
+
("1984", 123000),
|
|
43
|
+
("To Kill a Mockingbird", 130000),
|
|
44
|
+
("Pride and Prejudice", 156000),
|
|
45
|
+
("Anna Karenina", 468000),
|
|
46
|
+
("Don Quixote", 520000),
|
|
47
|
+
("The Lord of the Rings", 576000),
|
|
48
|
+
("War and Peace", 730000),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
if total_tokens == 0:
|
|
52
|
+
return "Start chatting to build your stats!"
|
|
53
|
+
|
|
54
|
+
# Find the best matching book
|
|
55
|
+
for i, (name, tokens) in enumerate(books):
|
|
56
|
+
if total_tokens < tokens:
|
|
57
|
+
if i == 0:
|
|
58
|
+
# Less than the smallest book
|
|
59
|
+
percentage = (total_tokens / tokens) * 100
|
|
60
|
+
return f"You've used {percentage:.0f}% of the tokens in {name}"
|
|
61
|
+
else:
|
|
62
|
+
# Between two books
|
|
63
|
+
prev_name, prev_tokens = books[i - 1]
|
|
64
|
+
if total_tokens >= prev_tokens * 1.5:
|
|
65
|
+
# Closer to current book
|
|
66
|
+
multiplier = total_tokens / tokens
|
|
67
|
+
return f"You've used ~{multiplier:.1f}x the tokens in {name}"
|
|
68
|
+
else:
|
|
69
|
+
# Closer to previous book
|
|
70
|
+
multiplier = total_tokens / prev_tokens
|
|
71
|
+
return f"You've used ~{multiplier:.1f}x the tokens in {prev_name}"
|
|
72
|
+
|
|
73
|
+
# More than the largest book
|
|
74
|
+
largest_name, largest_tokens = books[-1]
|
|
75
|
+
multiplier = total_tokens / largest_tokens
|
|
76
|
+
return f"You've used ~{multiplier:.1f}x the tokens in {largest_name}!"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_duration_comparison(duration_minutes: int) -> str:
|
|
80
|
+
"""Get a fun comparison for session duration based on common activities.
|
|
81
|
+
|
|
82
|
+
Reference data for activity durations in minutes.
|
|
83
|
+
"""
|
|
84
|
+
activities = [
|
|
85
|
+
("a TED talk", 18),
|
|
86
|
+
("an episode of The Office", 22),
|
|
87
|
+
("a half marathon (average time)", 120),
|
|
88
|
+
("the movie Inception", 148),
|
|
89
|
+
("a transatlantic flight", 420),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
if duration_minutes == 0:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
# Find the best matching activity
|
|
96
|
+
for i, (name, minutes) in enumerate(activities):
|
|
97
|
+
if duration_minutes < minutes:
|
|
98
|
+
if i == 0:
|
|
99
|
+
# Less than the shortest activity
|
|
100
|
+
percentage = (duration_minutes / minutes) * 100
|
|
101
|
+
return f"Longest session: {percentage:.0f}% of {name}"
|
|
102
|
+
else:
|
|
103
|
+
# Between two activities
|
|
104
|
+
prev_name, prev_minutes = activities[i - 1]
|
|
105
|
+
if duration_minutes >= prev_minutes * 1.5:
|
|
106
|
+
# Closer to current activity
|
|
107
|
+
multiplier = duration_minutes / minutes
|
|
108
|
+
return f"Longest session: ~{multiplier:.1f}x {name}"
|
|
109
|
+
else:
|
|
110
|
+
# Closer to previous activity
|
|
111
|
+
multiplier = duration_minutes / prev_minutes
|
|
112
|
+
return f"Longest session: ~{multiplier:.1f}x {prev_name}"
|
|
113
|
+
|
|
114
|
+
# More than the longest activity
|
|
115
|
+
longest_name, longest_minutes = activities[-1]
|
|
116
|
+
multiplier = duration_minutes / longest_minutes
|
|
117
|
+
return f"Longest session: ~{multiplier:.1f}x {longest_name}!"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _handle(ui: Any, _args: str) -> bool:
|
|
121
|
+
"""Handle the stats command."""
|
|
122
|
+
# Get project path from UI
|
|
123
|
+
project_path = getattr(ui, "project_path", None)
|
|
124
|
+
if project_path is None:
|
|
125
|
+
ui.console.print("[yellow]No project context available[/yellow]")
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Collect statistics
|
|
129
|
+
console = ui.console
|
|
130
|
+
days = 365 # Look back full year for complete heatmap
|
|
131
|
+
weeks_count = 52 # Display 52 weeks (1 year)
|
|
132
|
+
stats = collect_session_stats(project_path, days=days)
|
|
133
|
+
|
|
134
|
+
if stats.total_sessions == 0:
|
|
135
|
+
console.print("[yellow]No session data found[/yellow]")
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
# Create overview panel
|
|
139
|
+
console.print()
|
|
140
|
+
console.print("[bold]Activity Heatmap[/bold]")
|
|
141
|
+
console.print()
|
|
142
|
+
|
|
143
|
+
# Render heatmap with full year (52 weeks)
|
|
144
|
+
render_heatmap(console, stats.daily_activity, weeks_count=weeks_count)
|
|
145
|
+
console.print()
|
|
146
|
+
|
|
147
|
+
# Create two-column layout for statistics
|
|
148
|
+
# Left column: Model and tokens
|
|
149
|
+
# Right column: Sessions and streaks
|
|
150
|
+
table = Table.grid(padding=(0, 4))
|
|
151
|
+
table.add_column(style="cyan", justify="left")
|
|
152
|
+
table.add_column(justify="left")
|
|
153
|
+
table.add_column(style="cyan", justify="left")
|
|
154
|
+
table.add_column(justify="left")
|
|
155
|
+
|
|
156
|
+
# Row 1: Favorite model | Sessions
|
|
157
|
+
favorite_model_display = ""
|
|
158
|
+
if stats.favorite_model:
|
|
159
|
+
favorite_model_display = _truncate_model_name(stats.favorite_model)
|
|
160
|
+
table.add_row(
|
|
161
|
+
"Favorite model:",
|
|
162
|
+
favorite_model_display or "N/A",
|
|
163
|
+
"Sessions:",
|
|
164
|
+
f"{_format_number(stats.total_sessions)}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Row 2: Total tokens | Longest session
|
|
168
|
+
total_tokens_display = (
|
|
169
|
+
format_large_number(stats.total_tokens) if stats.total_tokens > 0 else "0"
|
|
170
|
+
)
|
|
171
|
+
longest_session_display = ""
|
|
172
|
+
if stats.longest_session_duration.total_seconds() > 0:
|
|
173
|
+
longest_session_display = format_duration(stats.longest_session_duration)
|
|
174
|
+
table.add_row(
|
|
175
|
+
"Total tokens:",
|
|
176
|
+
total_tokens_display,
|
|
177
|
+
"Longest session:",
|
|
178
|
+
longest_session_display or "N/A",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Row 3: empty | Current streak
|
|
182
|
+
table.add_row(
|
|
183
|
+
"",
|
|
184
|
+
"",
|
|
185
|
+
"Current streak:",
|
|
186
|
+
f"{stats.current_streak} days",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Row 4: empty | Longest streak
|
|
190
|
+
table.add_row(
|
|
191
|
+
"",
|
|
192
|
+
"",
|
|
193
|
+
"Longest streak:",
|
|
194
|
+
f"{stats.longest_streak} days",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Row 5: empty | Active days
|
|
198
|
+
table.add_row(
|
|
199
|
+
"",
|
|
200
|
+
"",
|
|
201
|
+
"Active days:",
|
|
202
|
+
f"{stats.active_days}/{stats.total_days}",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Row 6: empty | Peak hour
|
|
206
|
+
peak_hour_str = f"{stats.peak_hour:02d}:00-{stats.peak_hour + 1:02d}:00"
|
|
207
|
+
table.add_row(
|
|
208
|
+
"",
|
|
209
|
+
"",
|
|
210
|
+
"Peak hour:",
|
|
211
|
+
peak_hour_str,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Create the main panel with statistics
|
|
215
|
+
# Match heatmap width: WEEKDAY_LABEL_WIDTH (8) + weeks_count (52) = 60
|
|
216
|
+
heatmap_width = 8 + weeks_count + 16
|
|
217
|
+
console.print(Panel(table, title="Statistics", border_style="blue", width=heatmap_width))
|
|
218
|
+
console.print()
|
|
219
|
+
|
|
220
|
+
# Fun comparisons
|
|
221
|
+
# Token comparison
|
|
222
|
+
token_comparison = _get_token_comparison(stats.total_tokens)
|
|
223
|
+
if token_comparison:
|
|
224
|
+
console.print(f"[dim]{token_comparison}[/dim]")
|
|
225
|
+
|
|
226
|
+
# Duration comparison
|
|
227
|
+
duration_minutes = int(stats.longest_session_duration.total_seconds() / 60)
|
|
228
|
+
duration_comparison = _get_duration_comparison(duration_minutes)
|
|
229
|
+
if duration_comparison:
|
|
230
|
+
console.print(f"[dim]{duration_comparison}[/dim]")
|
|
231
|
+
|
|
232
|
+
console.print(f"[dim]Stats from the last {days} days[/dim]")
|
|
233
|
+
console.print()
|
|
234
|
+
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
command = SlashCommand(
|
|
239
|
+
name="stats",
|
|
240
|
+
description="Show session statistics and activity patterns",
|
|
241
|
+
handler=_handle,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
__all__ = ["command"]
|
|
@@ -22,6 +22,12 @@ def _auth_token_display(profile: Optional[ModelProfile]) -> Tuple[str, Optional[
|
|
|
22
22
|
if not profile:
|
|
23
23
|
return ("Not configured", None)
|
|
24
24
|
|
|
25
|
+
# 首先检查全局 RIPPERDOC_AUTH_TOKEN
|
|
26
|
+
if os.getenv("RIPPERDOC_AUTH_TOKEN"):
|
|
27
|
+
return ("RIPPERDOC_AUTH_TOKEN (env)", "RIPPERDOC_AUTH_TOKEN")
|
|
28
|
+
if os.getenv("RIPPERDOC_API_KEY"):
|
|
29
|
+
return ("RIPPERDOC_API_KEY (env)", "RIPPERDOC_API_KEY")
|
|
30
|
+
|
|
25
31
|
provider_value = (
|
|
26
32
|
profile.provider.value if hasattr(profile.provider, "value") else str(profile.provider)
|
|
27
33
|
)
|
|
@@ -44,6 +50,10 @@ def _api_base_display(profile: Optional[ModelProfile]) -> str:
|
|
|
44
50
|
if not profile:
|
|
45
51
|
return "API base URL: Not configured"
|
|
46
52
|
|
|
53
|
+
# 首先检查全局 RIPPERDOC_BASE_URL
|
|
54
|
+
if base_url := os.getenv("RIPPERDOC_BASE_URL"):
|
|
55
|
+
return f"API base URL: {base_url} (RIPPERDOC_BASE_URL env)"
|
|
56
|
+
|
|
47
57
|
label_map = {
|
|
48
58
|
ProviderType.ANTHROPIC: "Anthropic base URL",
|
|
49
59
|
ProviderType.OPENAI_COMPATIBLE: "OpenAI-compatible base URL",
|
|
@@ -100,13 +100,14 @@ def _list_tasks(ui: Any) -> bool:
|
|
|
100
100
|
table.add_column("ID", style="cyan", no_wrap=True)
|
|
101
101
|
table.add_column("Status", style="magenta", no_wrap=True)
|
|
102
102
|
table.add_column("Command", style="white")
|
|
103
|
-
table.add_column("
|
|
103
|
+
table.add_column("Runtime", style="dim", no_wrap=True)
|
|
104
|
+
table.add_column("Age", style="dim", no_wrap=True)
|
|
104
105
|
|
|
105
106
|
for task_id in sorted(task_ids):
|
|
106
107
|
try:
|
|
107
108
|
status = get_background_status(task_id, consume=False)
|
|
108
109
|
except (KeyError, ValueError, RuntimeError, OSError) as exc:
|
|
109
|
-
table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-")
|
|
110
|
+
table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-", "-")
|
|
110
111
|
logger.warning(
|
|
111
112
|
"[tasks_cmd] Failed to read background task status: %s: %s",
|
|
112
113
|
type(exc).__name__,
|
|
@@ -122,6 +123,7 @@ def _list_tasks(ui: Any) -> bool:
|
|
|
122
123
|
_format_status(status),
|
|
123
124
|
escape(command_display),
|
|
124
125
|
_format_duration(status.get("duration_ms")),
|
|
126
|
+
_format_duration(status.get("age_ms")),
|
|
125
127
|
)
|
|
126
128
|
|
|
127
129
|
console.print(
|
|
@@ -210,7 +212,8 @@ def _show_task(ui: Any, task_id: str) -> bool:
|
|
|
210
212
|
details.add_row("ID", escape(task_id))
|
|
211
213
|
details.add_row("Status", _format_status(status))
|
|
212
214
|
details.add_row("Command", escape(status.get("command") or ""))
|
|
213
|
-
details.add_row("
|
|
215
|
+
details.add_row("Runtime", _format_duration(status.get("duration_ms")))
|
|
216
|
+
details.add_row("Age", _format_duration(status.get("age_ms")))
|
|
214
217
|
exit_code = status.get("exit_code")
|
|
215
218
|
details.add_row("Exit code", str(exit_code) if exit_code is not None else "running")
|
|
216
219
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Theme management command for Ripperdoc.
|
|
2
|
+
|
|
3
|
+
Allows users to list, preview, and switch UI themes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ripperdoc.core.config import get_global_config, save_global_config
|
|
12
|
+
from ripperdoc.core.theme import (
|
|
13
|
+
BUILTIN_THEMES,
|
|
14
|
+
Theme,
|
|
15
|
+
get_theme_manager,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .base import SlashCommand
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _show_current_theme(ui: Any) -> None:
|
|
22
|
+
"""Display current theme information."""
|
|
23
|
+
manager = get_theme_manager()
|
|
24
|
+
theme = manager.current
|
|
25
|
+
primary = manager.get_color("primary")
|
|
26
|
+
ui.console.print(f"\nCurrent theme: [bold {primary}]{theme.display_name}[/]")
|
|
27
|
+
ui.console.print(f" {theme.description}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _list_themes(ui: Any) -> None:
|
|
31
|
+
"""List all available themes."""
|
|
32
|
+
manager = get_theme_manager()
|
|
33
|
+
current_name = manager.current.name
|
|
34
|
+
|
|
35
|
+
ui.console.print("\n[bold]Available Themes:[/bold]")
|
|
36
|
+
|
|
37
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
38
|
+
table.add_column("Marker", width=2)
|
|
39
|
+
table.add_column("Name", width=16)
|
|
40
|
+
table.add_column("Description")
|
|
41
|
+
|
|
42
|
+
for name, theme in BUILTIN_THEMES.items():
|
|
43
|
+
is_current = name == current_name
|
|
44
|
+
marker = f"[{manager.get_color('primary')}]→[/]" if is_current else " "
|
|
45
|
+
display = (
|
|
46
|
+
f"[bold {manager.get_color('primary')}]{theme.display_name}[/]"
|
|
47
|
+
if is_current
|
|
48
|
+
else theme.display_name
|
|
49
|
+
)
|
|
50
|
+
table.add_row(marker, display, theme.description)
|
|
51
|
+
|
|
52
|
+
ui.console.print(table)
|
|
53
|
+
ui.console.print("\n[dim]Usage: /themes <name> to switch[/dim]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _preview_theme(ui: Any, theme: Theme) -> None:
|
|
57
|
+
"""Preview a theme's color palette."""
|
|
58
|
+
colors = theme.colors
|
|
59
|
+
ui.console.print(f"\n[bold]Theme Preview:[/bold] {theme.display_name}\n")
|
|
60
|
+
|
|
61
|
+
samples = [
|
|
62
|
+
("Primary", colors.primary, "Brand color, borders"),
|
|
63
|
+
("Secondary", colors.secondary, "Success, ready status"),
|
|
64
|
+
("Error", colors.error, "Error messages"),
|
|
65
|
+
("Warning", colors.warning, "Warning messages"),
|
|
66
|
+
("Info", colors.info, "Info messages"),
|
|
67
|
+
("Tool Call", colors.tool_call, "Tool invocations"),
|
|
68
|
+
("Spinner", colors.spinner, "Loading indicator"),
|
|
69
|
+
("Emphasis", colors.emphasis, "Highlighted text"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
for label, color, desc in samples:
|
|
73
|
+
ui.console.print(f" [{color}]■[/] {label}: {desc}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _switch_theme(ui: Any, theme_name: str, preview_only: bool = False) -> None:
|
|
77
|
+
"""Switch to a different theme."""
|
|
78
|
+
manager = get_theme_manager()
|
|
79
|
+
|
|
80
|
+
if theme_name not in BUILTIN_THEMES:
|
|
81
|
+
ui.console.print(f"[{manager.get_color('error')}]Unknown theme: {escape(theme_name)}[/]")
|
|
82
|
+
available = ", ".join(BUILTIN_THEMES.keys())
|
|
83
|
+
ui.console.print(f"[dim]Available themes: {available}[/dim]")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
theme = BUILTIN_THEMES[theme_name]
|
|
87
|
+
|
|
88
|
+
if preview_only:
|
|
89
|
+
_preview_theme(ui, theme)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Apply theme
|
|
93
|
+
manager.set_theme(theme_name)
|
|
94
|
+
|
|
95
|
+
# Persist to config
|
|
96
|
+
try:
|
|
97
|
+
config = get_global_config()
|
|
98
|
+
config.theme = theme_name
|
|
99
|
+
save_global_config(config)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
ui.console.print(
|
|
102
|
+
f"[{manager.get_color('warning')}]Theme applied but failed to save: {e}[/]"
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
ui.console.print(f"[{manager.get_color('success')}]✓ Theme switched to {theme.display_name}[/]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _handle(ui: Any, arg: str) -> bool:
|
|
110
|
+
"""Handle the /themes command."""
|
|
111
|
+
arg = arg.strip().lower()
|
|
112
|
+
|
|
113
|
+
if not arg:
|
|
114
|
+
_show_current_theme(ui)
|
|
115
|
+
_list_themes(ui)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
parts = arg.split()
|
|
119
|
+
|
|
120
|
+
if parts[0] == "preview" and len(parts) > 1:
|
|
121
|
+
_switch_theme(ui, parts[1], preview_only=True)
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
if parts[0] == "list":
|
|
125
|
+
_list_themes(ui)
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Direct theme switch
|
|
129
|
+
_switch_theme(ui, parts[0])
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
command = SlashCommand(
|
|
134
|
+
name="themes",
|
|
135
|
+
description="List and switch UI themes",
|
|
136
|
+
handler=_handle,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
__all__ = ["command"]
|
|
@@ -10,6 +10,7 @@ from typing import Any, Iterable, List, Set
|
|
|
10
10
|
from prompt_toolkit.completion import Completer, Completion
|
|
11
11
|
|
|
12
12
|
from ripperdoc.utils.path_ignore import should_skip_path, IgnoreFilter
|
|
13
|
+
from ripperdoc.utils.image_utils import is_image_file
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class FileMentionCompleter(Completer):
|
|
@@ -28,9 +29,33 @@ class FileMentionCompleter(Completer):
|
|
|
28
29
|
self.project_path = project_path
|
|
29
30
|
self.ignore_filter = ignore_filter
|
|
30
31
|
|
|
32
|
+
def _should_skip_for_completion(self, item: Path) -> bool:
|
|
33
|
+
"""Check if an item should be skipped during completion.
|
|
34
|
+
|
|
35
|
+
Image files are never skipped since we support image input.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
item: Path to check
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the item should be skipped
|
|
42
|
+
"""
|
|
43
|
+
# Don't skip image files (we support them now)
|
|
44
|
+
if item.is_file() and is_image_file(item):
|
|
45
|
+
return False
|
|
46
|
+
# Use the project's ignore filter for other files
|
|
47
|
+
return should_skip_path(
|
|
48
|
+
item,
|
|
49
|
+
self.project_path,
|
|
50
|
+
ignore_filter=self.ignore_filter,
|
|
51
|
+
skip_hidden=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
31
54
|
def _collect_files_recursive(self, root_dir: Path, max_depth: int = 5) -> List[Path]:
|
|
32
55
|
"""Recursively collect all files from root_dir, respecting ignore rules.
|
|
33
56
|
|
|
57
|
+
Note: Image files are NOT filtered out since we support image input.
|
|
58
|
+
|
|
34
59
|
Args:
|
|
35
60
|
root_dir: Directory to search from
|
|
36
61
|
max_depth: Maximum directory depth to search
|
|
@@ -46,13 +71,13 @@ class FileMentionCompleter(Completer):
|
|
|
46
71
|
|
|
47
72
|
try:
|
|
48
73
|
for item in current_dir.iterdir():
|
|
49
|
-
#
|
|
50
|
-
if
|
|
51
|
-
item
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
):
|
|
74
|
+
# Don't skip image files (we support them now)
|
|
75
|
+
if item.is_file() and is_image_file(item):
|
|
76
|
+
files.append(item)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Use the project's ignore filter to skip other files
|
|
80
|
+
if self._should_skip_for_completion(item):
|
|
56
81
|
continue
|
|
57
82
|
|
|
58
83
|
if item.is_file():
|
|
@@ -122,7 +147,12 @@ class FileMentionCompleter(Completer):
|
|
|
122
147
|
display_path += "/"
|
|
123
148
|
|
|
124
149
|
# Right side: show type only
|
|
125
|
-
meta = "📁 directory"
|
|
150
|
+
meta = "📁 directory"
|
|
151
|
+
if item.is_file():
|
|
152
|
+
if is_image_file(item):
|
|
153
|
+
meta = "🖼️ image"
|
|
154
|
+
else:
|
|
155
|
+
meta = "📄 file"
|
|
126
156
|
|
|
127
157
|
_add_match(display_path, item, meta, 0)
|
|
128
158
|
except ValueError:
|
|
@@ -152,7 +182,12 @@ class FileMentionCompleter(Completer):
|
|
|
152
182
|
display_path += "/"
|
|
153
183
|
|
|
154
184
|
# Right side: show type only
|
|
155
|
-
meta = "📁 directory"
|
|
185
|
+
meta = "📁 directory"
|
|
186
|
+
if item.is_file():
|
|
187
|
+
if is_image_file(item):
|
|
188
|
+
meta = "🖼️ image"
|
|
189
|
+
else:
|
|
190
|
+
meta = "📄 file"
|
|
156
191
|
|
|
157
192
|
_add_match(display_path, item, meta, 0)
|
|
158
193
|
except ValueError:
|
|
@@ -177,7 +212,12 @@ class FileMentionCompleter(Completer):
|
|
|
177
212
|
display_path += "/"
|
|
178
213
|
|
|
179
214
|
# Right side: show type only
|
|
180
|
-
meta = "📁 directory"
|
|
215
|
+
meta = "📁 directory"
|
|
216
|
+
if item.is_file():
|
|
217
|
+
if is_image_file(item):
|
|
218
|
+
meta = "🖼️ image"
|
|
219
|
+
else:
|
|
220
|
+
meta = "📄 file"
|
|
181
221
|
_add_match(display_path, item, meta, 0)
|
|
182
222
|
except ValueError:
|
|
183
223
|
continue
|
|
@@ -206,7 +246,12 @@ class FileMentionCompleter(Completer):
|
|
|
206
246
|
if item.is_dir():
|
|
207
247
|
display_path += "/"
|
|
208
248
|
|
|
209
|
-
meta = "📁 directory"
|
|
249
|
+
meta = "📁 directory"
|
|
250
|
+
if item.is_file():
|
|
251
|
+
if is_image_file(item):
|
|
252
|
+
meta = "🖼️ image"
|
|
253
|
+
else:
|
|
254
|
+
meta = "📄 file"
|
|
210
255
|
_add_match(display_path, item, meta, score)
|
|
211
256
|
|
|
212
257
|
# If the query exactly matches a directory, also surface its children for quicker drilling
|
|
@@ -226,7 +271,12 @@ class FileMentionCompleter(Completer):
|
|
|
226
271
|
display_path = str(rel_path)
|
|
227
272
|
if item.is_dir():
|
|
228
273
|
display_path += "/"
|
|
229
|
-
meta = "📁 directory"
|
|
274
|
+
meta = "📁 directory"
|
|
275
|
+
if item.is_file():
|
|
276
|
+
if is_image_file(item):
|
|
277
|
+
meta = "🖼️ image"
|
|
278
|
+
else:
|
|
279
|
+
meta = "📄 file"
|
|
230
280
|
_add_match(display_path, item, meta, 400)
|
|
231
281
|
except ValueError:
|
|
232
282
|
continue
|
|
@@ -267,7 +317,7 @@ class FileMentionCompleter(Completer):
|
|
|
267
317
|
for display_path, item, meta, score in matches:
|
|
268
318
|
yield Completion(
|
|
269
319
|
display_path,
|
|
270
|
-
start_position=-
|
|
320
|
+
start_position=-len(query),
|
|
271
321
|
display=display_path,
|
|
272
322
|
display_meta=meta,
|
|
273
323
|
)
|
ripperdoc/cli/ui/helpers.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import random
|
|
4
4
|
from typing import List, Optional
|
|
5
5
|
|
|
6
|
-
from ripperdoc.core.config import
|
|
6
|
+
from ripperdoc.core.config import get_effective_model_profile, get_global_config, ModelProfile
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
# Fun words to display while the AI is "thinking"
|
|
@@ -103,8 +103,11 @@ def get_random_thinking_word() -> str:
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
|
|
106
|
-
"""Return the configured ModelProfile for a logical pointer or default.
|
|
107
|
-
|
|
106
|
+
"""Return the configured ModelProfile for a logical pointer or default.
|
|
107
|
+
|
|
108
|
+
此函数现在尊重 RIPPERDOC_* 环境变量的覆盖。
|
|
109
|
+
"""
|
|
110
|
+
profile = get_effective_model_profile(pointer)
|
|
108
111
|
if profile:
|
|
109
112
|
return profile
|
|
110
113
|
config = get_global_config()
|
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
from typing import Any, Optional, Set
|
|
11
11
|
|
|
12
12
|
from ripperdoc.utils.log import get_logger
|
|
13
|
+
from ripperdoc.utils.platform import is_windows
|
|
13
14
|
|
|
14
15
|
logger = get_logger()
|
|
15
16
|
|
|
@@ -47,6 +48,11 @@ class InterruptHandler:
|
|
|
47
48
|
"""
|
|
48
49
|
prev = self._esc_listener_paused
|
|
49
50
|
self._esc_listener_paused = True
|
|
51
|
+
|
|
52
|
+
# Windows doesn't support termios
|
|
53
|
+
if is_windows():
|
|
54
|
+
return prev
|
|
55
|
+
|
|
50
56
|
try:
|
|
51
57
|
import termios
|
|
52
58
|
except ImportError:
|
|
@@ -75,6 +81,34 @@ class InterruptHandler:
|
|
|
75
81
|
Returns:
|
|
76
82
|
True if an interrupt key was pressed.
|
|
77
83
|
"""
|
|
84
|
+
if is_windows():
|
|
85
|
+
# Windows: use msvcrt for non-blocking key detection
|
|
86
|
+
try:
|
|
87
|
+
import msvcrt
|
|
88
|
+
except ImportError:
|
|
89
|
+
# Fallback: just wait - Ctrl+C is handled by OS
|
|
90
|
+
while self._esc_listener_active:
|
|
91
|
+
await asyncio.sleep(0.1)
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
while self._esc_listener_active:
|
|
95
|
+
if self._esc_listener_paused:
|
|
96
|
+
await asyncio.sleep(0.05)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Check for key press in a thread to avoid blocking
|
|
100
|
+
def check_key() -> Optional[str]:
|
|
101
|
+
if msvcrt.kbhit(): # type: ignore[attr-defined]
|
|
102
|
+
return msvcrt.getch().decode("latin-1") # type: ignore[attr-defined,no-any-return]
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
key = await asyncio.to_thread(check_key)
|
|
106
|
+
if key in INTERRUPT_KEYS:
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
await asyncio.sleep(0.02)
|
|
110
|
+
return False
|
|
111
|
+
|
|
78
112
|
import select
|
|
79
113
|
import termios
|
|
80
114
|
import tty
|