ripperdoc 0.2.6__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 +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Slash command to inspect and manage background bash tasks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import textwrap
|
|
5
|
+
|
|
6
|
+
from rich import box
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
|
|
11
|
+
from ripperdoc.tools.background_shell import (
|
|
12
|
+
get_background_status,
|
|
13
|
+
kill_background_task,
|
|
14
|
+
list_background_tasks,
|
|
15
|
+
)
|
|
16
|
+
from ripperdoc.utils.log import get_logger
|
|
17
|
+
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
from .base import SlashCommand
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = get_logger()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_duration(duration_ms: Optional[float]) -> str:
|
|
26
|
+
"""Render milliseconds into a short human-readable duration."""
|
|
27
|
+
if duration_ms is None:
|
|
28
|
+
return "-"
|
|
29
|
+
try:
|
|
30
|
+
duration = float(duration_ms)
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return "-"
|
|
33
|
+
if duration < 1000:
|
|
34
|
+
return f"{int(duration)} ms"
|
|
35
|
+
seconds = duration / 1000.0
|
|
36
|
+
if seconds < 60:
|
|
37
|
+
return f"{seconds:.1f}s"
|
|
38
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
39
|
+
if minutes < 60:
|
|
40
|
+
return f"{minutes}m {secs}s"
|
|
41
|
+
hours, mins = divmod(minutes, 60)
|
|
42
|
+
return f"{hours}h {mins}m"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format_status(status: dict) -> str:
|
|
46
|
+
"""Return a colored status label with exit codes when available."""
|
|
47
|
+
state = status.get("status") or "unknown"
|
|
48
|
+
if status.get("timed_out"):
|
|
49
|
+
state = "failed"
|
|
50
|
+
if status.get("killed"):
|
|
51
|
+
state = "killed"
|
|
52
|
+
|
|
53
|
+
exit_code = status.get("exit_code")
|
|
54
|
+
label = state
|
|
55
|
+
if exit_code is not None and state not in ("running", "killed"):
|
|
56
|
+
label = f"{label} ({exit_code})"
|
|
57
|
+
|
|
58
|
+
color = {
|
|
59
|
+
"running": "yellow",
|
|
60
|
+
"completed": "green",
|
|
61
|
+
"failed": "red",
|
|
62
|
+
"killed": "red",
|
|
63
|
+
}.get(state)
|
|
64
|
+
return f"[{color}]{label}[/{color}]" if color else label
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tail_lines(text: str, max_lines: int = 20, max_chars: int = 4000) -> str:
|
|
68
|
+
"""Return a shortened view of output for display."""
|
|
69
|
+
if not text:
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
lines = text.splitlines()
|
|
73
|
+
prefixes = []
|
|
74
|
+
|
|
75
|
+
if len(lines) > max_lines:
|
|
76
|
+
lines = lines[-max_lines:]
|
|
77
|
+
prefixes.append(f"[dim]... showing last {max_lines} lines[/dim]")
|
|
78
|
+
|
|
79
|
+
content = "\n".join(lines)
|
|
80
|
+
if len(content) > max_chars:
|
|
81
|
+
content = content[-max_chars:]
|
|
82
|
+
prefixes.insert(0, f"[dim]... output truncated to last {max_chars} chars[/dim]")
|
|
83
|
+
|
|
84
|
+
if prefixes:
|
|
85
|
+
return "\n".join(prefixes + [content])
|
|
86
|
+
return content
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _list_tasks(ui: Any) -> bool:
|
|
90
|
+
console = ui.console
|
|
91
|
+
task_ids = list_background_tasks()
|
|
92
|
+
|
|
93
|
+
if not task_ids:
|
|
94
|
+
console.print(
|
|
95
|
+
Panel("No tasks currently running", title="Background tasks", box=box.ROUNDED)
|
|
96
|
+
)
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
table = Table(box=box.SIMPLE_HEAVY, expand=True)
|
|
100
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
101
|
+
table.add_column("Status", style="magenta", no_wrap=True)
|
|
102
|
+
table.add_column("Command", style="white")
|
|
103
|
+
table.add_column("Duration", style="dim", no_wrap=True)
|
|
104
|
+
|
|
105
|
+
for task_id in sorted(task_ids):
|
|
106
|
+
try:
|
|
107
|
+
status = get_background_status(task_id, consume=False)
|
|
108
|
+
except (KeyError, ValueError, RuntimeError, OSError) as exc:
|
|
109
|
+
table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-")
|
|
110
|
+
logger.warning(
|
|
111
|
+
"[tasks_cmd] Failed to read background task status: %s: %s",
|
|
112
|
+
type(exc).__name__, exc,
|
|
113
|
+
extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
command = status.get("command") or ""
|
|
118
|
+
command_display = textwrap.shorten(command, width=80, placeholder="...")
|
|
119
|
+
table.add_row(
|
|
120
|
+
escape(task_id),
|
|
121
|
+
_format_status(status),
|
|
122
|
+
escape(command_display),
|
|
123
|
+
_format_duration(status.get("duration_ms")),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
console.print(
|
|
127
|
+
Panel(
|
|
128
|
+
table,
|
|
129
|
+
title="Background tasks",
|
|
130
|
+
box=box.ROUNDED,
|
|
131
|
+
padding=(1, 2),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
console.print(
|
|
135
|
+
"[dim]Use /tasks show <id> to view details/output, /tasks kill <id> to stop a task, or BashOutput/KillBash tools directly.[/dim]"
|
|
136
|
+
)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _kill_task(ui: Any, task_id: str) -> bool:
|
|
141
|
+
console = ui.console
|
|
142
|
+
try:
|
|
143
|
+
status = get_background_status(task_id, consume=False)
|
|
144
|
+
except KeyError:
|
|
145
|
+
console.print(f"[red]No task found with id '{escape(task_id)}'.[/red]")
|
|
146
|
+
return True
|
|
147
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
148
|
+
console.print(f"[red]Failed to read task '{escape(task_id)}': {escape(str(exc))}[/red]")
|
|
149
|
+
logger.warning(
|
|
150
|
+
"[tasks_cmd] Failed to read task before kill: %s: %s",
|
|
151
|
+
type(exc).__name__, exc,
|
|
152
|
+
extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
|
|
153
|
+
)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
if status.get("status") != "running":
|
|
157
|
+
console.print(
|
|
158
|
+
f"[yellow]Task {escape(task_id)} is not running (status: {escape(str(status.get('status')))}).[/yellow]"
|
|
159
|
+
)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
runner = getattr(ui, "run_async", None)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
if callable(runner):
|
|
166
|
+
killed = runner(kill_background_task(task_id))
|
|
167
|
+
else:
|
|
168
|
+
killed = asyncio.run(kill_background_task(task_id))
|
|
169
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
170
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
171
|
+
raise
|
|
172
|
+
console.print(f"[red]Error stopping task {escape(task_id)}: {escape(str(exc))}[/red]")
|
|
173
|
+
logger.warning(
|
|
174
|
+
"[tasks_cmd] Error stopping background task: %s: %s",
|
|
175
|
+
type(exc).__name__, exc,
|
|
176
|
+
extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
|
|
177
|
+
)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
if killed:
|
|
181
|
+
console.print(
|
|
182
|
+
f"[green]Killed task {escape(task_id)}[/green] — {escape(status.get('command') or '')}",
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
console.print(f"[red]Failed to kill task {escape(task_id)}[/red]")
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _show_task(ui: Any, task_id: str) -> bool:
|
|
190
|
+
console = ui.console
|
|
191
|
+
try:
|
|
192
|
+
status = get_background_status(task_id, consume=False)
|
|
193
|
+
except KeyError:
|
|
194
|
+
console.print(f"[red]No task found with id '{escape(task_id)}'.[/red]")
|
|
195
|
+
return True
|
|
196
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
197
|
+
console.print(f"[red]Failed to read task '{escape(task_id)}': {escape(str(exc))}[/red]")
|
|
198
|
+
logger.warning(
|
|
199
|
+
"[tasks_cmd] Failed to read task for detail view: %s: %s",
|
|
200
|
+
type(exc).__name__, exc,
|
|
201
|
+
extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
|
|
202
|
+
)
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
details = Table(box=box.SIMPLE_HEAVY, show_header=False)
|
|
206
|
+
details.add_row("ID", escape(task_id))
|
|
207
|
+
details.add_row("Status", _format_status(status))
|
|
208
|
+
details.add_row("Command", escape(status.get("command") or ""))
|
|
209
|
+
details.add_row("Duration", _format_duration(status.get("duration_ms")))
|
|
210
|
+
exit_code = status.get("exit_code")
|
|
211
|
+
details.add_row("Exit code", str(exit_code) if exit_code is not None else "running")
|
|
212
|
+
|
|
213
|
+
console.print(
|
|
214
|
+
Panel(details, title=f"Task {escape(task_id)}", box=box.ROUNDED, padding=(1, 2)),
|
|
215
|
+
markup=False,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
stdout_block = _tail_lines(status.get("stdout") or "")
|
|
219
|
+
stderr_block = _tail_lines(status.get("stderr") or "")
|
|
220
|
+
|
|
221
|
+
console.print(
|
|
222
|
+
Panel(
|
|
223
|
+
escape(stdout_block) if stdout_block else "[dim]No stdout yet[/dim]",
|
|
224
|
+
title="stdout (latest)",
|
|
225
|
+
box=box.SIMPLE,
|
|
226
|
+
padding=(1, 2),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
console.print(
|
|
230
|
+
Panel(
|
|
231
|
+
escape(stderr_block) if stderr_block else "[dim]No stderr yet[/dim]",
|
|
232
|
+
title="stderr (latest)",
|
|
233
|
+
box=box.SIMPLE,
|
|
234
|
+
padding=(1, 2),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _handle(ui: Any, args: str) -> bool:
|
|
241
|
+
parts = args.split()
|
|
242
|
+
logger.info(
|
|
243
|
+
"[tasks_cmd] Handling /tasks command",
|
|
244
|
+
extra={
|
|
245
|
+
"session_id": getattr(ui, "session_id", None),
|
|
246
|
+
"raw_args": args,
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
if not parts:
|
|
250
|
+
return _list_tasks(ui)
|
|
251
|
+
|
|
252
|
+
command = parts[0].lower()
|
|
253
|
+
if command in {"kill", "stop"}:
|
|
254
|
+
if len(parts) < 2:
|
|
255
|
+
ui.console.print("[red]Usage: /tasks kill <task_id>[/red]")
|
|
256
|
+
return True
|
|
257
|
+
return _kill_task(ui, parts[1])
|
|
258
|
+
|
|
259
|
+
if command in {"show", "info", "view"}:
|
|
260
|
+
if len(parts) < 2:
|
|
261
|
+
ui.console.print("[red]Usage: /tasks show <task_id>[/red]")
|
|
262
|
+
return True
|
|
263
|
+
return _show_task(ui, parts[1])
|
|
264
|
+
|
|
265
|
+
ui.console.print(
|
|
266
|
+
"[red]Unknown subcommand. Use /tasks, /tasks show <id>, or /tasks kill <id>.[/red]"
|
|
267
|
+
)
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
command = SlashCommand(
|
|
272
|
+
name="tasks",
|
|
273
|
+
description="List, inspect, and manage background tasks started with run_in_background",
|
|
274
|
+
handler=_handle,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = ["command"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Slash command to display stored todos."""
|
|
2
|
+
|
|
3
|
+
from rich import box
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
|
|
7
|
+
from ripperdoc.utils.todo import (
|
|
8
|
+
format_todo_lines,
|
|
9
|
+
format_todo_summary,
|
|
10
|
+
get_next_actionable,
|
|
11
|
+
load_todos,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
from .base import SlashCommand
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
19
|
+
console = ui.console
|
|
20
|
+
todos = load_todos(ui.project_path)
|
|
21
|
+
next_only = trimmed_arg.strip().lower() in ("next", "-n", "--next")
|
|
22
|
+
|
|
23
|
+
if not todos:
|
|
24
|
+
console.print(" ⎿ [dim]No todos currently tracked[/]")
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
if next_only:
|
|
28
|
+
next_todo = get_next_actionable(todos)
|
|
29
|
+
if not next_todo:
|
|
30
|
+
console.print("[yellow]No actionable todos (none pending or in_progress).[/yellow]")
|
|
31
|
+
return True
|
|
32
|
+
console.print(
|
|
33
|
+
Panel(
|
|
34
|
+
f"{escape(next_todo.content)}\n[id: {escape(next_todo.id)} | {escape(next_todo.status)} | {escape(next_todo.priority)}]",
|
|
35
|
+
title="Next todo",
|
|
36
|
+
box=box.ROUNDED,
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
summary = escape(format_todo_summary(todos))
|
|
42
|
+
lines = [escape(line) for line in format_todo_lines(todos)]
|
|
43
|
+
body = "\n".join(lines)
|
|
44
|
+
panel = Panel(
|
|
45
|
+
body or "No todos currently tracked",
|
|
46
|
+
title="Todos",
|
|
47
|
+
subtitle=summary,
|
|
48
|
+
box=box.ROUNDED,
|
|
49
|
+
padding=(1, 2),
|
|
50
|
+
)
|
|
51
|
+
console.print(panel)
|
|
52
|
+
|
|
53
|
+
next_todo = get_next_actionable(todos)
|
|
54
|
+
if next_todo:
|
|
55
|
+
console.print(
|
|
56
|
+
f"[dim]Next: {escape(next_todo.content)} (id: {escape(next_todo.id)}, status: {escape(next_todo.status)})[/dim]"
|
|
57
|
+
)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
command = SlashCommand(
|
|
62
|
+
name="todos",
|
|
63
|
+
description="Show the stored todo list for this project",
|
|
64
|
+
handler=_handle,
|
|
65
|
+
aliases=(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["command"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .base import SlashCommand
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _handle(ui: Any, _: str) -> bool:
|
|
6
|
+
ui.console.print("\n[bold]Available Tools:[/bold]")
|
|
7
|
+
for tool in ui.get_default_tools():
|
|
8
|
+
ui.console.print(f" • {tool.name}")
|
|
9
|
+
return True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
command = SlashCommand(
|
|
13
|
+
name="tools",
|
|
14
|
+
description="List available tools",
|
|
15
|
+
handler=_handle,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["command"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""UI components for the Ripperdoc CLI."""
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Shared helpers for rendering context/token usage in CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from ripperdoc.utils.message_compaction import ContextBreakdown
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_tokens(tokens: int) -> str:
|
|
12
|
+
"""Render token counts in a compact human-readable form."""
|
|
13
|
+
if tokens >= 1_000_000:
|
|
14
|
+
value = tokens / 1_000_000
|
|
15
|
+
suffix = "M"
|
|
16
|
+
elif tokens >= 1_000:
|
|
17
|
+
value = tokens / 1_000
|
|
18
|
+
suffix = "k"
|
|
19
|
+
else:
|
|
20
|
+
return str(tokens)
|
|
21
|
+
text = f"{value:.1f}"
|
|
22
|
+
if text.endswith(".0"):
|
|
23
|
+
text = text[:-2]
|
|
24
|
+
return f"{text}{suffix}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def styled_symbol(symbol: str, color: str) -> str:
|
|
28
|
+
return f"[{color}]{symbol}[/]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def visible_length(text: str) -> int:
|
|
32
|
+
"""Best-effort length calculation that ignores Rich markup."""
|
|
33
|
+
if not text:
|
|
34
|
+
return 0
|
|
35
|
+
return len(re.sub(r"\[/?[^\]]+\]", "", text))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_icon_bar(
|
|
39
|
+
used_tokens: int,
|
|
40
|
+
reserved_tokens: int,
|
|
41
|
+
max_tokens: int,
|
|
42
|
+
*,
|
|
43
|
+
segments: int = 10,
|
|
44
|
+
glyph_used: str = "⛁",
|
|
45
|
+
glyph_partial: str = "⛀",
|
|
46
|
+
glyph_reserved: str = "⛝",
|
|
47
|
+
glyph_empty: str = "⛶",
|
|
48
|
+
color_used: Optional[str] = None,
|
|
49
|
+
color_reserved: Optional[str] = None,
|
|
50
|
+
color_empty: Optional[str] = "grey50",
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Render a segmented bar using contextual glyphs."""
|
|
53
|
+
if max_tokens <= 0:
|
|
54
|
+
max_tokens = 1
|
|
55
|
+
|
|
56
|
+
used_tokens = max(0, min(used_tokens, max_tokens))
|
|
57
|
+
reserved_tokens = max(0, min(reserved_tokens, max_tokens - used_tokens))
|
|
58
|
+
|
|
59
|
+
def style(symbol: str, color: Optional[str]) -> str:
|
|
60
|
+
return f"[{color}]{symbol}[/]" if color else symbol
|
|
61
|
+
|
|
62
|
+
bar: List[str] = []
|
|
63
|
+
used_slots_float = (used_tokens / max_tokens) * segments
|
|
64
|
+
used_full = int(used_slots_float)
|
|
65
|
+
used_partial = used_slots_float - used_full
|
|
66
|
+
bar.extend([style(glyph_used, color_used)] * min(used_full, segments))
|
|
67
|
+
if used_partial > 0.05 and len(bar) < segments:
|
|
68
|
+
bar.append(style(glyph_partial, color_used))
|
|
69
|
+
|
|
70
|
+
reserved_slots_float = (reserved_tokens / max_tokens) * segments
|
|
71
|
+
reserved_full = int(reserved_slots_float)
|
|
72
|
+
reserved_partial = reserved_slots_float - reserved_full
|
|
73
|
+
remaining = segments - len(bar)
|
|
74
|
+
if remaining > 0:
|
|
75
|
+
bar.extend([style(glyph_reserved, color_reserved)] * min(reserved_full, remaining))
|
|
76
|
+
remaining = segments - len(bar)
|
|
77
|
+
if reserved_partial > 0.05 and remaining > 0:
|
|
78
|
+
bar.append(style(glyph_reserved, color_reserved))
|
|
79
|
+
|
|
80
|
+
remaining = segments - len(bar)
|
|
81
|
+
if remaining > 0:
|
|
82
|
+
bar.extend([style(glyph_empty, color_empty)] * remaining)
|
|
83
|
+
|
|
84
|
+
return " ".join(bar[:segments])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_segment_grid(
|
|
88
|
+
breakdown: ContextBreakdown,
|
|
89
|
+
*,
|
|
90
|
+
per_row: int = 10,
|
|
91
|
+
) -> List[str]:
|
|
92
|
+
"""Build a 10x10 proportional grid (left→right for main sections, reserved pinned to bottom-right)."""
|
|
93
|
+
total_slots = max(1, per_row * per_row) # default 100 slots
|
|
94
|
+
|
|
95
|
+
categories: List[Dict[str, Any]] = [
|
|
96
|
+
{
|
|
97
|
+
"label": "System prompt",
|
|
98
|
+
"glyph": "⛁",
|
|
99
|
+
"color": "grey58",
|
|
100
|
+
"tokens": breakdown.system_prompt_tokens,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"label": "MCP instructions",
|
|
104
|
+
"glyph": "⛁",
|
|
105
|
+
"color": "cyan",
|
|
106
|
+
"tokens": getattr(breakdown, "mcp_tokens", 0),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"label": "System tools",
|
|
110
|
+
"glyph": "⛁",
|
|
111
|
+
"color": "green3",
|
|
112
|
+
"tokens": breakdown.tool_schema_tokens,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"label": "Memory files",
|
|
116
|
+
"glyph": "⛁",
|
|
117
|
+
"color": "dark_orange3",
|
|
118
|
+
"tokens": breakdown.memory_tokens,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"label": "Messages",
|
|
122
|
+
"glyph": "⛁",
|
|
123
|
+
"color": "medium_purple",
|
|
124
|
+
"tokens": breakdown.message_tokens,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"label": "Free space",
|
|
128
|
+
"glyph": "⛶",
|
|
129
|
+
"color": "grey46",
|
|
130
|
+
"tokens": max(breakdown.free_tokens, 0),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"label": "Autocompact buffer",
|
|
134
|
+
"glyph": "⛝",
|
|
135
|
+
"color": "yellow3",
|
|
136
|
+
"tokens": max(breakdown.reserved_tokens, 0),
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
max_tokens = max(breakdown.max_context_tokens, 1)
|
|
141
|
+
|
|
142
|
+
# First compute slot counts for all categories so totals match the grid size.
|
|
143
|
+
allocations: List[int] = []
|
|
144
|
+
remainders: List[tuple[float, int]] = []
|
|
145
|
+
allocated = 0
|
|
146
|
+
for idx, category in enumerate(categories):
|
|
147
|
+
token_value_int = int(category["tokens"])
|
|
148
|
+
token_value = max(0, token_value_int)
|
|
149
|
+
raw_slots = (token_value / max_tokens) * total_slots
|
|
150
|
+
base = int(raw_slots)
|
|
151
|
+
if token_value > 0 and base == 0:
|
|
152
|
+
base = 1 # ensure tiny but nonzero sections are visible
|
|
153
|
+
allocations.append(base)
|
|
154
|
+
allocated += base
|
|
155
|
+
remainders.append((raw_slots - base, idx))
|
|
156
|
+
|
|
157
|
+
min_allowed = [1 if int(cat["tokens"]) > 0 else 0 for cat in categories]
|
|
158
|
+
|
|
159
|
+
while allocated > total_slots:
|
|
160
|
+
for _, idx in sorted(remainders, key=lambda x: x[0]):
|
|
161
|
+
if allocated <= total_slots:
|
|
162
|
+
break
|
|
163
|
+
if allocations[idx] > min_allowed[idx]:
|
|
164
|
+
allocations[idx] -= 1
|
|
165
|
+
allocated -= 1
|
|
166
|
+
else:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
while allocated < total_slots:
|
|
170
|
+
for _, idx in sorted(remainders, key=lambda x: x[0], reverse=True):
|
|
171
|
+
if allocated >= total_slots:
|
|
172
|
+
break
|
|
173
|
+
allocations[idx] += 1
|
|
174
|
+
allocated += 1
|
|
175
|
+
else:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
# Place all non-reserved sections from the top-left; place reserved from the bottom-right.
|
|
179
|
+
reserved_count = allocations[-1]
|
|
180
|
+
forward_categories = categories[:-1]
|
|
181
|
+
forward_allocations = allocations[:-1]
|
|
182
|
+
|
|
183
|
+
icons: List[Optional[str]] = [None] * total_slots
|
|
184
|
+
cursor = 0
|
|
185
|
+
for category, count in zip(forward_categories, forward_allocations):
|
|
186
|
+
for _ in range(count):
|
|
187
|
+
if cursor >= total_slots:
|
|
188
|
+
break
|
|
189
|
+
icons[cursor] = styled_symbol(str(category["glyph"]), str(category["color"]))
|
|
190
|
+
cursor += 1
|
|
191
|
+
|
|
192
|
+
end_cursor = total_slots - 1
|
|
193
|
+
reserved_symbol = styled_symbol(str(categories[-1]["glyph"]), str(categories[-1]["color"]))
|
|
194
|
+
for _ in range(reserved_count):
|
|
195
|
+
if end_cursor < 0:
|
|
196
|
+
break
|
|
197
|
+
icons[end_cursor] = reserved_symbol
|
|
198
|
+
end_cursor -= 1
|
|
199
|
+
|
|
200
|
+
# Fill any gaps (should not normally happen) with free-space icons.
|
|
201
|
+
free_symbol = styled_symbol("⛶", "grey46")
|
|
202
|
+
for idx, value in enumerate(icons):
|
|
203
|
+
if value is None:
|
|
204
|
+
icons[idx] = free_symbol
|
|
205
|
+
|
|
206
|
+
rows: List[str] = []
|
|
207
|
+
for start in range(0, total_slots, per_row):
|
|
208
|
+
row_icons = [icon for icon in icons[start : start + per_row] if icon is not None]
|
|
209
|
+
rows.append(" ".join(row_icons))
|
|
210
|
+
return rows
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def context_usage_lines(
|
|
214
|
+
breakdown: ContextBreakdown, model_label: str, auto_compact_enabled: bool
|
|
215
|
+
) -> List[str]:
|
|
216
|
+
"""Build a stylized context usage block using a fixed 10x10 grid."""
|
|
217
|
+
grid_lines: List[str] = []
|
|
218
|
+
grid_lines.append(" ⎿ Context Usage")
|
|
219
|
+
|
|
220
|
+
grid_rows = make_segment_grid(breakdown, per_row=10)
|
|
221
|
+
if grid_rows:
|
|
222
|
+
header_row = grid_rows[0]
|
|
223
|
+
grid_lines.append(
|
|
224
|
+
f" {header_row} {model_label} · "
|
|
225
|
+
f"{format_tokens(breakdown.effective_tokens)}/"
|
|
226
|
+
f"{format_tokens(breakdown.max_context_tokens)} tokens "
|
|
227
|
+
f"({breakdown.percent_used:.1f}%)"
|
|
228
|
+
)
|
|
229
|
+
for row in grid_rows[1:]:
|
|
230
|
+
grid_lines.append(f" {row}")
|
|
231
|
+
|
|
232
|
+
# Textual stats (without additional mini bars).
|
|
233
|
+
stats: List[Tuple[str, Optional[int], Optional[float]]] = [
|
|
234
|
+
(
|
|
235
|
+
f"{styled_symbol('⛁', 'grey58')} System prompt",
|
|
236
|
+
breakdown.system_prompt_tokens,
|
|
237
|
+
breakdown.percent_of_limit(breakdown.system_prompt_tokens),
|
|
238
|
+
),
|
|
239
|
+
(
|
|
240
|
+
f"{styled_symbol('⛁', 'cyan')} MCP instructions",
|
|
241
|
+
getattr(breakdown, "mcp_tokens", 0),
|
|
242
|
+
breakdown.percent_of_limit(getattr(breakdown, "mcp_tokens", 0)),
|
|
243
|
+
),
|
|
244
|
+
(
|
|
245
|
+
f"{styled_symbol('⛁', 'green3')} System tools",
|
|
246
|
+
breakdown.tool_schema_tokens,
|
|
247
|
+
breakdown.percent_of_limit(breakdown.tool_schema_tokens),
|
|
248
|
+
),
|
|
249
|
+
(
|
|
250
|
+
f"{styled_symbol('⛁', 'dark_orange3')} Memory files",
|
|
251
|
+
breakdown.memory_tokens,
|
|
252
|
+
breakdown.percent_of_limit(breakdown.memory_tokens),
|
|
253
|
+
),
|
|
254
|
+
(
|
|
255
|
+
f"{styled_symbol('⛁', 'medium_purple')} Messages",
|
|
256
|
+
breakdown.message_tokens,
|
|
257
|
+
breakdown.percent_of_limit(breakdown.message_tokens),
|
|
258
|
+
),
|
|
259
|
+
(
|
|
260
|
+
f"{styled_symbol('⛶', 'grey46')} Free space",
|
|
261
|
+
breakdown.free_tokens,
|
|
262
|
+
breakdown.percent_of_limit(breakdown.free_tokens),
|
|
263
|
+
),
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
reserved_label = f"{styled_symbol('⛝', 'yellow3')} Autocompact buffer"
|
|
267
|
+
if auto_compact_enabled and breakdown.reserved_tokens:
|
|
268
|
+
stats.append(
|
|
269
|
+
(
|
|
270
|
+
reserved_label,
|
|
271
|
+
breakdown.reserved_tokens,
|
|
272
|
+
breakdown.percent_of_limit(breakdown.reserved_tokens),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
stats.append((reserved_label, None, None))
|
|
277
|
+
|
|
278
|
+
stats_lines: List[str] = []
|
|
279
|
+
for label, tokens, percent in stats:
|
|
280
|
+
if tokens is None:
|
|
281
|
+
stats_lines.append(f"{label}: disabled")
|
|
282
|
+
else:
|
|
283
|
+
stats_lines.append(f"{label}: {format_tokens(tokens)} tokens ({percent:.1f}%)")
|
|
284
|
+
|
|
285
|
+
total_rows = max(len(grid_lines), len(stats_lines))
|
|
286
|
+
padded_grid = [""] * (total_rows - len(grid_lines)) + grid_lines
|
|
287
|
+
padded_stats = [""] * (total_rows - len(stats_lines)) + stats_lines
|
|
288
|
+
|
|
289
|
+
combined: List[str] = []
|
|
290
|
+
for left, right in zip(padded_grid, padded_stats):
|
|
291
|
+
# left_pad = " " * max(0, grid_width - visible_length(left))
|
|
292
|
+
left_pad = ""
|
|
293
|
+
if right:
|
|
294
|
+
combined.append(f"{left}{left_pad} {right}")
|
|
295
|
+
else:
|
|
296
|
+
combined.append(f"{left}")
|
|
297
|
+
|
|
298
|
+
return combined
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared helper functions for the Rich UI."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ripperdoc.core.config import get_current_model_profile, get_global_config, ModelProfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
|
|
9
|
+
"""Return the configured ModelProfile for a logical pointer or default."""
|
|
10
|
+
profile = get_current_model_profile(pointer)
|
|
11
|
+
if profile:
|
|
12
|
+
return profile
|
|
13
|
+
config = get_global_config()
|
|
14
|
+
if "default" in config.model_profiles:
|
|
15
|
+
return config.model_profiles.get("default")
|
|
16
|
+
if config.model_profiles:
|
|
17
|
+
first_name = next(iter(config.model_profiles))
|
|
18
|
+
return config.model_profiles.get(first_name)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["get_profile_for_pointer"]
|