ripperdoc 0.2.10__py3-none-any.whl → 0.3.1__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 (73) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  17. ripperdoc/cli/ui/message_display.py +7 -0
  18. ripperdoc/cli/ui/panels.py +13 -8
  19. ripperdoc/cli/ui/rich_ui.py +513 -84
  20. ripperdoc/cli/ui/spinner.py +68 -5
  21. ripperdoc/cli/ui/tool_renderers.py +10 -9
  22. ripperdoc/cli/ui/wizard.py +18 -11
  23. ripperdoc/core/agents.py +4 -0
  24. ripperdoc/core/config.py +235 -0
  25. ripperdoc/core/default_tools.py +1 -0
  26. ripperdoc/core/hooks/llm_callback.py +0 -1
  27. ripperdoc/core/hooks/manager.py +6 -0
  28. ripperdoc/core/permissions.py +123 -39
  29. ripperdoc/core/providers/openai.py +55 -9
  30. ripperdoc/core/query.py +349 -108
  31. ripperdoc/core/query_utils.py +17 -14
  32. ripperdoc/core/skills.py +1 -0
  33. ripperdoc/core/theme.py +298 -0
  34. ripperdoc/core/tool.py +8 -3
  35. ripperdoc/protocol/__init__.py +14 -0
  36. ripperdoc/protocol/models.py +300 -0
  37. ripperdoc/protocol/stdio.py +1453 -0
  38. ripperdoc/tools/background_shell.py +49 -5
  39. ripperdoc/tools/bash_tool.py +75 -9
  40. ripperdoc/tools/file_edit_tool.py +98 -29
  41. ripperdoc/tools/file_read_tool.py +139 -8
  42. ripperdoc/tools/file_write_tool.py +46 -3
  43. ripperdoc/tools/grep_tool.py +98 -8
  44. ripperdoc/tools/lsp_tool.py +9 -15
  45. ripperdoc/tools/multi_edit_tool.py +26 -3
  46. ripperdoc/tools/skill_tool.py +52 -1
  47. ripperdoc/tools/task_tool.py +33 -8
  48. ripperdoc/utils/file_watch.py +12 -6
  49. ripperdoc/utils/image_utils.py +125 -0
  50. ripperdoc/utils/log.py +30 -3
  51. ripperdoc/utils/lsp.py +9 -3
  52. ripperdoc/utils/mcp.py +80 -18
  53. ripperdoc/utils/message_formatting.py +2 -2
  54. ripperdoc/utils/messages.py +177 -32
  55. ripperdoc/utils/pending_messages.py +50 -0
  56. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  57. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  58. ripperdoc/utils/platform.py +198 -0
  59. ripperdoc/utils/session_heatmap.py +1 -3
  60. ripperdoc/utils/session_history.py +2 -2
  61. ripperdoc/utils/session_stats.py +1 -0
  62. ripperdoc/utils/shell_utils.py +8 -5
  63. ripperdoc/utils/todo.py +0 -6
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/METADATA +49 -17
  65. ripperdoc-0.3.1.dist-info/RECORD +136 -0
  66. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/WHEEL +1 -1
  67. ripperdoc/cli/ui/interrupt_handler.py +0 -174
  68. ripperdoc/sdk/__init__.py +0 -9
  69. ripperdoc/sdk/client.py +0 -408
  70. ripperdoc-0.2.10.dist-info/RECORD +0 -129
  71. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/entry_points.txt +0 -0
  72. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/licenses/LICENSE +0 -0
  73. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Ripperdoc - AI-powered coding agent."""
2
2
 
3
- __version__ = "0.2.10"
3
+ __version__ = "0.3.1"
ripperdoc/cli/cli.py CHANGED
@@ -44,7 +44,6 @@ from ripperdoc.utils.log import enable_session_file_logging, get_logger
44
44
 
45
45
  from rich.console import Console
46
46
  from rich.markdown import Markdown
47
- from rich.panel import Panel
48
47
  from rich.markup import escape
49
48
 
50
49
  console = Console()
@@ -98,7 +97,6 @@ async def run_query(
98
97
  model: Optional[str] = None,
99
98
  ) -> None:
100
99
  """Run a single query and print the response."""
101
-
102
100
  logger.info(
103
101
  "[cli] Running single prompt session",
104
102
  extra={
@@ -212,55 +210,37 @@ async def run_query(
212
210
  mcp_instructions=mcp_instructions,
213
211
  )
214
212
 
215
- # Run the query
213
+ # Run the query - collect final response text
214
+ final_response_parts: List[str] = []
216
215
  try:
217
216
  async for message in query(
218
217
  messages, system_prompt, context, query_context, can_use_tool
219
218
  ):
220
219
  if message.type == "assistant" and hasattr(message, "message"):
221
- # Print assistant message
220
+ # Collect assistant message text for final output
222
221
  if isinstance(message.message.content, str):
223
- console.print(
224
- Panel(
225
- Markdown(message.message.content),
226
- title="Ripperdoc",
227
- border_style="cyan",
228
- padding=(0, 1),
229
- )
230
- )
222
+ final_response_parts.append(message.message.content)
231
223
  else:
232
224
  # Handle structured content
233
225
  for block in message.message.content:
234
226
  if isinstance(block, dict):
235
227
  if block.get("type") == "text":
236
- console.print(
237
- Panel(
238
- Markdown(block["text"]),
239
- title="Ripperdoc",
240
- border_style="cyan",
241
- padding=(0, 1),
242
- )
243
- )
228
+ final_response_parts.append(block["text"])
244
229
  else:
245
230
  if hasattr(block, "type") and block.type == "text":
246
- console.print(
247
- Panel(
248
- Markdown(block.text or ""),
249
- title="Ripperdoc",
250
- border_style="cyan",
251
- padding=(0, 1),
252
- )
253
- )
254
-
255
- elif message.type == "progress" and hasattr(message, "content"):
256
- # Print progress
257
- if verbose:
258
- console.print(f"[dim]Progress: {escape(str(message.content))}[/dim]")
231
+ final_response_parts.append(block.text or "")
232
+
233
+ # Skip progress messages entirely for -p mode
259
234
 
260
235
  # Add message to history
261
236
  messages.append(message) # type: ignore[arg-type]
262
237
  session_history.append(message) # type: ignore[arg-type]
263
238
 
239
+ # Print final response as clean markdown (no panel, no decoration)
240
+ if final_response_parts:
241
+ final_text = "\n".join(final_response_parts)
242
+ console.print(Markdown(final_text))
243
+
264
244
  except KeyboardInterrupt:
265
245
  console.print("\n[yellow]Interrupted by user[/yellow]")
266
246
  except asyncio.CancelledError:
@@ -308,7 +288,6 @@ async def run_query(
308
288
  logger.debug("[cli] Shutdown MCP runtime", extra={"session_id": session_id})
309
289
 
310
290
 
311
-
312
291
  @click.group(invoke_without_command=True)
313
292
  @click.version_option(version=__version__)
314
293
  @click.option("--cwd", type=click.Path(exists=True), help="Working directory")
@@ -374,19 +353,51 @@ def cli(
374
353
  ) -> None:
375
354
  """Ripperdoc - AI-powered coding agent"""
376
355
  session_id = str(uuid.uuid4())
356
+ cwd_changed: Optional[str] = None
377
357
 
378
358
  # Set working directory
379
359
  if cwd:
380
360
  import os
381
361
 
382
362
  os.chdir(cwd)
363
+ cwd_changed = cwd
364
+
365
+ project_path = Path.cwd()
366
+
367
+ # Handle --continue option: load the most recent session
368
+ resume_messages = None
369
+ most_recent = None
370
+ if continue_session:
371
+ summaries = list_session_summaries(project_path)
372
+ if summaries:
373
+ most_recent = summaries[0]
374
+ session_id = most_recent.session_id
375
+ resume_messages = load_session_messages(project_path, session_id)
376
+ console.print(f"[dim]Continuing session: {most_recent.last_prompt}[/dim]")
377
+ else:
378
+ console.print("[yellow]No previous sessions found in this directory.[/yellow]")
379
+
380
+ log_file = enable_session_file_logging(project_path, session_id)
381
+
382
+ if cwd_changed:
383
383
  logger.debug(
384
384
  "[cli] Changed working directory via --cwd",
385
- extra={"cwd": cwd, "session_id": session_id},
385
+ extra={"cwd": cwd_changed, "session_id": session_id},
386
386
  )
387
387
 
388
- project_path = Path.cwd()
389
- log_file = enable_session_file_logging(project_path, session_id)
388
+ if most_recent:
389
+ logger.info(
390
+ "[cli] Continuing session",
391
+ extra={
392
+ "session_id": session_id,
393
+ "message_count": len(resume_messages) if resume_messages else 0,
394
+ "last_prompt": most_recent.last_prompt,
395
+ "log_file": str(log_file),
396
+ },
397
+ )
398
+ elif continue_session:
399
+ logger.warning("[cli] No previous sessions found to continue")
400
+
390
401
  logger.info(
391
402
  "[cli] Starting CLI invocation",
392
403
  extra={
@@ -412,26 +423,39 @@ def cli(
412
423
  # Parse --tools option
413
424
  allowed_tools = parse_tools_option(tools)
414
425
 
415
- # Handle --continue option: load the most recent session
416
- resume_messages = None
417
- if continue_session:
418
- summaries = list_session_summaries(project_path)
419
- if summaries:
420
- most_recent = summaries[0]
421
- session_id = most_recent.session_id
422
- resume_messages = load_session_messages(project_path, session_id)
423
- logger.info(
424
- "[cli] Continuing session",
425
- extra={
426
- "session_id": session_id,
427
- "message_count": len(resume_messages),
428
- "last_prompt": most_recent.last_prompt,
429
- },
430
- )
431
- console.print(f"[dim]Continuing session: {most_recent.last_prompt}[/dim]")
432
- else:
433
- logger.warning("[cli] No previous sessions found to continue")
434
- console.print("[yellow]No previous sessions found in this directory.[/yellow]")
426
+ # Handle piped stdin input
427
+ # - With -p flag: Not applicable (prompt comes from -p argument)
428
+ # - Without -p: stdin becomes the initial query in interactive mode
429
+ initial_query: Optional[str] = None
430
+ if prompt is None and ctx.invoked_subcommand is None:
431
+ stdin_stream = click.get_text_stream("stdin")
432
+ try:
433
+ stdin_is_tty = stdin_stream.isatty()
434
+ except Exception:
435
+ stdin_is_tty = True
436
+
437
+ if not stdin_is_tty:
438
+ try:
439
+ stdin_data = stdin_stream.read()
440
+ except (OSError, ValueError) as exc:
441
+ logger.warning(
442
+ "[cli] Failed to read stdin for initial query: %s: %s",
443
+ type(exc).__name__,
444
+ exc,
445
+ extra={"session_id": session_id},
446
+ )
447
+ else:
448
+ trimmed = stdin_data.rstrip("\n")
449
+ if trimmed.strip():
450
+ initial_query = trimmed
451
+ logger.info(
452
+ "[cli] Received initial query from stdin",
453
+ extra={
454
+ "session_id": session_id,
455
+ "query_length": len(initial_query),
456
+ "query_preview": initial_query[:200],
457
+ },
458
+ )
435
459
 
436
460
  logger.debug(
437
461
  "[cli] Configuration initialized",
@@ -480,6 +504,7 @@ def cli(
480
504
  append_system_prompt=append_system_prompt,
481
505
  model=model,
482
506
  resume_messages=resume_messages,
507
+ initial_query=initial_query,
483
508
  )
484
509
  return
485
510
 
@@ -507,6 +532,88 @@ def config_cmd() -> None:
507
532
  console.print()
508
533
 
509
534
 
535
+ @cli.command(name="stdio")
536
+ @click.option(
537
+ "--input-format",
538
+ type=click.Choice(["stream-json", "auto"]),
539
+ default="stream-json",
540
+ help="Input format for messages.",
541
+ )
542
+ @click.option(
543
+ "--output-format",
544
+ type=click.Choice(["stream-json"]),
545
+ default="stream-json",
546
+ help="Output format for messages.",
547
+ )
548
+ @click.option(
549
+ "--model",
550
+ type=str,
551
+ default=None,
552
+ help="Model profile for the current session.",
553
+ )
554
+ @click.option(
555
+ "--permission-mode",
556
+ type=click.Choice(["default", "acceptEdits", "plan", "bypassPermissions"]),
557
+ default="default",
558
+ help="Permission mode for tool usage.",
559
+ )
560
+ @click.option(
561
+ "--max-turns",
562
+ type=int,
563
+ default=None,
564
+ help="Maximum number of conversation turns.",
565
+ )
566
+ @click.option(
567
+ "--system-prompt",
568
+ type=str,
569
+ default=None,
570
+ help="System prompt to use for the session.",
571
+ )
572
+ @click.option(
573
+ "--print",
574
+ "-p",
575
+ is_flag=True,
576
+ help="Print mode (for single prompt queries).",
577
+ )
578
+ @click.option(
579
+ "--",
580
+ "prompt",
581
+ type=str,
582
+ default=None,
583
+ help="Direct prompt (for print mode).",
584
+ )
585
+ def stdio_cmd(
586
+ input_format: str,
587
+ output_format: str,
588
+ model: str | None,
589
+ permission_mode: str,
590
+ max_turns: int | None,
591
+ system_prompt: str | None,
592
+ print: bool,
593
+ prompt: str | None,
594
+ ) -> None:
595
+ """Stdio mode for SDK subprocess communication.
596
+
597
+ This command enables Ripperdoc to communicate with SDKs via JSON Control
598
+ Protocol over stdin/stdout.
599
+ """
600
+ from ripperdoc.protocol.stdio import _run_stdio
601
+ import asyncio
602
+
603
+ asyncio.run(
604
+ _run_stdio(
605
+ input_format=input_format,
606
+ output_format=output_format,
607
+ model=model,
608
+ permission_mode=permission_mode,
609
+ max_turns=max_turns,
610
+ system_prompt=system_prompt,
611
+ print_mode=print,
612
+ prompt=prompt,
613
+ )
614
+ )
615
+
616
+
510
617
  @cli.command(name="version")
511
618
  def version_cmd() -> None:
512
619
  """Show version information"""
@@ -21,9 +21,11 @@ from .mcp_cmd import command as mcp_command
21
21
  from .models_cmd import command as models_command
22
22
  from .permissions_cmd import command as permissions_command
23
23
  from .resume_cmd import command as resume_command
24
+ from .skills_cmd import command as skills_command
24
25
  from .stats_cmd import command as stats_command
25
26
  from .tasks_cmd import command as tasks_command
26
27
  from .status_cmd import command as status_command
28
+ from .themes_cmd import command as themes_command
27
29
  from .todos_cmd import command as todos_command
28
30
  from .tools_cmd import command as tools_command
29
31
 
@@ -64,7 +66,9 @@ ALL_COMMANDS: List[SlashCommand] = [
64
66
  context_command,
65
67
  compact_command,
66
68
  resume_command,
69
+ skills_command,
67
70
  agents_command,
71
+ themes_command,
68
72
  ]
69
73
 
70
74
  COMMAND_REGISTRY: Dict[str, SlashCommand] = _build_registry(ALL_COMMANDS)
@@ -18,7 +18,7 @@ from ripperdoc.tools.task_tool import (
18
18
  )
19
19
  from ripperdoc.utils.log import get_logger
20
20
 
21
- from typing import Any, Dict, Optional
21
+ from typing import Any, Dict
22
22
  from .base import SlashCommand
23
23
 
24
24
  logger = get_logger()
@@ -101,9 +101,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
101
101
  for run_id in sorted(run_ids):
102
102
  snapshot: Dict[Any, Any] = get_agent_run_snapshot(run_id) or {}
103
103
  result_text = snapshot.get("result_text") or snapshot.get("error") or ""
104
- result_preview = (
105
- result_text if len(result_text) <= 80 else result_text[:77] + "..."
106
- )
104
+ result_preview = result_text if len(result_text) <= 80 else result_text[:77] + "..."
107
105
  table.add_row(
108
106
  escape(run_id),
109
107
  escape(snapshot.get("status") or "unknown"),
@@ -136,9 +134,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
136
134
  details.add_row("Status", escape(snapshot.get("status") or "unknown"))
137
135
  details.add_row("Agent", escape(snapshot.get("agent_type") or "unknown"))
138
136
  details.add_row("Duration", _format_duration(snapshot.get("duration_ms")))
139
- details.add_row(
140
- "Background", "yes" if snapshot.get("is_background") else "no"
141
- )
137
+ details.add_row("Background", "yes" if snapshot.get("is_background") else "no")
142
138
  if snapshot.get("model_used"):
143
139
  details.add_row("Model", escape(str(snapshot.get("model_used"))))
144
140
  if snapshot.get("tool_use_count"):
@@ -15,6 +15,8 @@ from ripperdoc.core.config import (
15
15
  api_key_env_candidates,
16
16
  get_global_config,
17
17
  get_project_config,
18
+ get_ripperdoc_env_status,
19
+ has_ripperdoc_env_overrides,
18
20
  )
19
21
  from ripperdoc.cli.ui.helpers import get_profile_for_pointer
20
22
  from ripperdoc.utils.log import get_logger
@@ -41,6 +43,12 @@ def _api_key_status(provider: ProviderType, profile_key: Optional[str]) -> Tuple
41
43
  """Check API key presence and source."""
42
44
  import os
43
45
 
46
+ # 首先检查全局 RIPPERDOC_API_KEY
47
+ if ripperdoc_api_key := os.getenv("RIPPERDOC_API_KEY"):
48
+ masked = ripperdoc_api_key[:4] + "…" if len(ripperdoc_api_key) > 4 else "set"
49
+ return ("ok", f"Found in $RIPPERDOC_API_KEY ({masked})")
50
+
51
+ # 然后检查 provider 特定的环境变量
44
52
  for env_var in api_key_env_candidates(provider):
45
53
  if os.environ.get(env_var):
46
54
  masked = os.environ[env_var]
@@ -186,6 +194,23 @@ def _project_status(project_path: Path) -> Tuple[str, str, str]:
186
194
  )
187
195
 
188
196
 
197
+ def _ripperdoc_env_status() -> List[Tuple[str, str, str]]:
198
+ """Check RIPPERDOC_* environment variable overrides."""
199
+ rows: List[Tuple[str, str, str]] = []
200
+
201
+ if not has_ripperdoc_env_overrides():
202
+ rows.append(_status_row("Env overrides", "ok", "No RIPPERDOC_* overrides active"))
203
+ return rows
204
+
205
+ rows.append(_status_row("Env overrides", "ok", "RIPPERDOC_* variables detected"))
206
+
207
+ status = get_ripperdoc_env_status()
208
+ for key, value in status.items():
209
+ rows.append(_status_row("", "ok", f" {key}: {value}"))
210
+
211
+ return rows
212
+
213
+
189
214
  def _render_table(console: Any, rows: List[Tuple[str, str, str]]) -> None:
190
215
  table = Table(show_header=True, header_style="bold cyan")
191
216
  table.add_column("Check")
@@ -202,6 +227,10 @@ def _handle(ui: Any, _: str) -> bool:
202
227
 
203
228
  results.append(_onboarding_status())
204
229
  results.extend(_model_status(project_path))
230
+
231
+ # 检查 RIPPERDOC_* 环境变量
232
+ results.extend(_ripperdoc_env_status())
233
+
205
234
  project_row = _project_status(project_path)
206
235
  results.append(project_row)
207
236
 
@@ -18,6 +18,7 @@ from ripperdoc.utils.memory import (
18
18
  MEMORY_FILE_NAME,
19
19
  collect_all_memory_files,
20
20
  )
21
+ from ripperdoc.utils.platform import is_windows
21
22
 
22
23
  from .base import SlashCommand
23
24
 
@@ -89,7 +90,7 @@ def _determine_editor_command() -> Optional[List[str]]:
89
90
  return shlex.split(value)
90
91
 
91
92
  candidates = ["code", "nano", "vim", "vi"]
92
- if os.name == "nt":
93
+ if is_windows():
93
94
  candidates.insert(0, "notepad")
94
95
 
95
96
  for candidate in candidates:
@@ -155,12 +155,34 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
155
155
  context_prompt += "): "
156
156
  context_window = parse_int(context_prompt, context_window_default)
157
157
 
158
+ # Vision support prompt
159
+ supports_vision_default = existing_profile.supports_vision if existing_profile else None
160
+ supports_vision = None
161
+ vision_default_display = (
162
+ "auto"
163
+ if supports_vision_default is None
164
+ else ("yes" if supports_vision_default else "no")
165
+ )
166
+ supports_vision_input = (
167
+ console.input(f"Supports vision (images)? [{vision_default_display}] (Y/n/auto): ")
168
+ .strip()
169
+ .lower()
170
+ )
171
+ if supports_vision_input in ("y", "yes"):
172
+ supports_vision = True
173
+ elif supports_vision_input in ("n", "no"):
174
+ supports_vision = False
175
+ elif supports_vision_input in ("auto", ""):
176
+ supports_vision = None
177
+ else:
178
+ supports_vision = supports_vision_default
179
+
158
180
  default_set_main = (
159
181
  not config.model_profiles
160
182
  or getattr(config.model_pointers, "main", "") not in config.model_profiles
161
183
  )
162
184
  set_main_input = (
163
- console.input(f"Set as main model? [{'Y' if default_set_main else 'y'}/N]: ")
185
+ console.input(f"Set as main model? ({'Y' if default_set_main else 'y'}/N): ")
164
186
  .strip()
165
187
  .lower()
166
188
  )
@@ -175,6 +197,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
175
197
  temperature=temperature,
176
198
  context_window=context_window,
177
199
  auth_token=auth_token,
200
+ supports_vision=supports_vision,
178
201
  )
179
202
 
180
203
  try:
@@ -276,6 +299,31 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
276
299
  existing_profile.context_window,
277
300
  )
278
301
 
302
+ # Vision support prompt
303
+ vision_default_display = (
304
+ "auto"
305
+ if existing_profile.supports_vision is None
306
+ else ("yes" if existing_profile.supports_vision else "no")
307
+ )
308
+ supports_vision_input = (
309
+ console.input(
310
+ f"Supports vision (images)? [{vision_default_display}] (Y/n/auto/C=clear): "
311
+ )
312
+ .strip()
313
+ .lower()
314
+ )
315
+ supports_vision = None
316
+ if supports_vision_input in ("y", "yes"):
317
+ supports_vision = True
318
+ elif supports_vision_input in ("n", "no"):
319
+ supports_vision = False
320
+ elif supports_vision_input in ("c", "clear", "-"):
321
+ supports_vision = None
322
+ elif supports_vision_input in ("auto", ""):
323
+ supports_vision = existing_profile.supports_vision
324
+ else:
325
+ supports_vision = existing_profile.supports_vision
326
+
279
327
  updated_profile = ModelProfile(
280
328
  provider=provider,
281
329
  model=model_name,
@@ -285,6 +333,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
285
333
  temperature=temperature,
286
334
  context_window=context_window,
287
335
  auth_token=auth_token,
336
+ supports_vision=supports_vision,
288
337
  )
289
338
 
290
339
  try:
@@ -353,10 +402,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
353
402
  pointer = "main"
354
403
  target = tokens[1]
355
404
  else:
356
- pointer = (
357
- console.input("Pointer (main/quick) [main]: ").strip().lower()
358
- or "main"
359
- )
405
+ pointer = console.input("Pointer (main/quick) [main]: ").strip().lower() or "main"
360
406
  if pointer not in valid_pointers:
361
407
  console.print(
362
408
  f"[red]Invalid pointer '{escape(pointer)}'. Valid pointers: {', '.join(valid_pointers)}[/red]"
@@ -415,6 +461,16 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
415
461
  )
416
462
  if profile.openai_tool_mode:
417
463
  console.print(f" openai_tool_mode: {profile.openai_tool_mode}", markup=False)
464
+ if profile.thinking_mode:
465
+ console.print(f" thinking_mode: {profile.thinking_mode}", markup=False)
466
+ # Display vision support
467
+ if profile.supports_vision is None:
468
+ vision_display = "auto-detect"
469
+ elif profile.supports_vision:
470
+ vision_display = "yes"
471
+ else:
472
+ vision_display = "no"
473
+ console.print(f" supports_vision: {vision_display}", markup=False)
418
474
  pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
419
475
  console.print(f"[dim]Pointers: {escape(pointer_labels)}[/dim]")
420
476
  return True
@@ -132,6 +132,7 @@ command = SlashCommand(
132
132
  name="resume",
133
133
  description="Resume a previous session conversation",
134
134
  handler=_handle,
135
+ aliases=("sessions",),
135
136
  )
136
137
 
137
138
 
@@ -0,0 +1,103 @@
1
+ from rich.markup import escape
2
+
3
+ from ripperdoc.core.skills import (
4
+ SkillDefinition,
5
+ SkillLoadResult,
6
+ SkillLocation,
7
+ load_all_skills,
8
+ skill_directories,
9
+ )
10
+
11
+ from typing import Any
12
+ from .base import SlashCommand
13
+
14
+
15
+ def _handle(ui: Any, _: str) -> bool:
16
+ console = ui.console
17
+ project_path = getattr(ui, "project_path", None)
18
+
19
+ result: SkillLoadResult = load_all_skills(project_path=project_path)
20
+
21
+ if not result.skills:
22
+ dirs = skill_directories(project_path=project_path)
23
+ dir_paths = [f"'{d}'" for d, _ in dirs]
24
+ console.print("[yellow]No skills found.[/yellow]")
25
+ console.print(
26
+ f"\n[bold]Create skills in:[/bold]\n"
27
+ f" • User: {dir_paths[0]}\n"
28
+ f" • Project: {dir_paths[1]}\n"
29
+ f"\n[dim]Each skill needs a SKILL.md file with YAML frontmatter:\n"
30
+ f"---\n"
31
+ f"name: my-skill\n"
32
+ f"description: A helpful skill\n"
33
+ f"---\n\n"
34
+ f"Skill content goes here...[/dim]"
35
+ )
36
+ if result.errors:
37
+ console.print("\n[bold red]Errors encountered while loading skills:[/bold red]")
38
+ for error in result.errors:
39
+ console.print(f" • {error.path}: {escape(error.reason)}", markup=False)
40
+ return True
41
+
42
+ console.print("\n[bold]Skills[/bold]")
43
+
44
+ # Group skills by location for better organization
45
+ user_skills = [s for s in result.skills if s.location == SkillLocation.USER]
46
+ project_skills = [s for s in result.skills if s.location == SkillLocation.PROJECT]
47
+ other_skills = [s for s in result.skills if s.location == SkillLocation.OTHER]
48
+
49
+ def print_skill(skill: SkillDefinition) -> None:
50
+ location_tag = f"[dim]({skill.location.value})[/dim]" if skill.location else ""
51
+ console.print(f"\n[bold cyan]{escape(skill.name)}[/bold cyan] {location_tag}")
52
+ console.print(f" {escape(skill.description)}")
53
+
54
+ if skill.allowed_tools:
55
+ tools_str = ", ".join(escape(t) for t in skill.allowed_tools)
56
+ console.print(f" Tools: {tools_str}")
57
+
58
+ if skill.model:
59
+ console.print(f" Model: {escape(skill.model)}")
60
+
61
+ if skill.max_thinking_tokens:
62
+ console.print(f" Max thinking tokens: {skill.max_thinking_tokens}")
63
+
64
+ if skill.skill_type != "prompt":
65
+ console.print(f" Type: {escape(skill.skill_type)}")
66
+
67
+ if skill.disable_model_invocation:
68
+ console.print(" [yellow]Model invocation disabled[/yellow]")
69
+
70
+ console.print(f" Path: {escape(str(skill.path))}", markup=False)
71
+
72
+ # Print project skills first (they have priority), then user skills
73
+ if project_skills:
74
+ console.print("\n[bold]Project skills:[/bold]")
75
+ for skill in project_skills:
76
+ print_skill(skill)
77
+
78
+ if user_skills:
79
+ console.print("\n[bold]User skills:[/bold]")
80
+ for skill in user_skills:
81
+ print_skill(skill)
82
+
83
+ if other_skills:
84
+ console.print("\n[bold]Other skills:[/bold]")
85
+ for skill in other_skills:
86
+ print_skill(skill)
87
+
88
+ if result.errors:
89
+ console.print("\n[bold red]Errors encountered while loading skills:[/bold red]")
90
+ for error in result.errors:
91
+ console.print(f" • {error.path}: {escape(error.reason)}", markup=False)
92
+
93
+ return True
94
+
95
+
96
+ command = SlashCommand(
97
+ name="skills",
98
+ description="List available skills",
99
+ handler=_handle,
100
+ )
101
+
102
+
103
+ __all__ = ["command"]
@@ -165,7 +165,9 @@ def _handle(ui: Any, _args: str) -> bool:
165
165
  )
166
166
 
167
167
  # Row 2: Total tokens | Longest session
168
- total_tokens_display = format_large_number(stats.total_tokens) if stats.total_tokens > 0 else "0"
168
+ total_tokens_display = (
169
+ format_large_number(stats.total_tokens) if stats.total_tokens > 0 else "0"
170
+ )
169
171
  longest_session_display = ""
170
172
  if stats.longest_session_duration.total_seconds() > 0:
171
173
  longest_session_display = format_duration(stats.longest_session_duration)
@@ -212,9 +214,7 @@ def _handle(ui: Any, _args: str) -> bool:
212
214
  # Create the main panel with statistics
213
215
  # Match heatmap width: WEEKDAY_LABEL_WIDTH (8) + weeks_count (52) = 60
214
216
  heatmap_width = 8 + weeks_count + 16
215
- console.print(
216
- Panel(table, title="Statistics", border_style="blue", width=heatmap_width)
217
- )
217
+ console.print(Panel(table, title="Statistics", border_style="blue", width=heatmap_width))
218
218
  console.print()
219
219
 
220
220
  # Fun comparisons