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.
Files changed (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. 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"]