ripperdoc 0.2.6__py3-none-any.whl → 0.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +5 -0
  3. ripperdoc/cli/commands/__init__.py +71 -6
  4. ripperdoc/cli/commands/clear_cmd.py +1 -0
  5. ripperdoc/cli/commands/exit_cmd.py +1 -1
  6. ripperdoc/cli/commands/help_cmd.py +11 -1
  7. ripperdoc/cli/commands/hooks_cmd.py +636 -0
  8. ripperdoc/cli/commands/permissions_cmd.py +36 -34
  9. ripperdoc/cli/commands/resume_cmd.py +71 -37
  10. ripperdoc/cli/ui/file_mention_completer.py +276 -0
  11. ripperdoc/cli/ui/helpers.py +100 -3
  12. ripperdoc/cli/ui/interrupt_handler.py +175 -0
  13. ripperdoc/cli/ui/message_display.py +249 -0
  14. ripperdoc/cli/ui/panels.py +63 -0
  15. ripperdoc/cli/ui/rich_ui.py +233 -648
  16. ripperdoc/cli/ui/tool_renderers.py +2 -2
  17. ripperdoc/core/agents.py +4 -4
  18. ripperdoc/core/custom_commands.py +411 -0
  19. ripperdoc/core/hooks/__init__.py +99 -0
  20. ripperdoc/core/hooks/config.py +303 -0
  21. ripperdoc/core/hooks/events.py +540 -0
  22. ripperdoc/core/hooks/executor.py +498 -0
  23. ripperdoc/core/hooks/integration.py +353 -0
  24. ripperdoc/core/hooks/manager.py +720 -0
  25. ripperdoc/core/providers/anthropic.py +476 -69
  26. ripperdoc/core/query.py +61 -4
  27. ripperdoc/core/query_utils.py +1 -1
  28. ripperdoc/core/tool.py +1 -1
  29. ripperdoc/tools/bash_tool.py +5 -5
  30. ripperdoc/tools/file_edit_tool.py +2 -2
  31. ripperdoc/tools/file_read_tool.py +2 -2
  32. ripperdoc/tools/multi_edit_tool.py +1 -1
  33. ripperdoc/utils/conversation_compaction.py +476 -0
  34. ripperdoc/utils/message_compaction.py +109 -154
  35. ripperdoc/utils/message_formatting.py +216 -0
  36. ripperdoc/utils/messages.py +31 -9
  37. ripperdoc/utils/path_ignore.py +3 -4
  38. ripperdoc/utils/session_history.py +19 -7
  39. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
  40. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
  41. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
  42. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
  43. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
  44. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
@@ -4,49 +4,57 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
4
4
  """
5
5
 
6
6
  import asyncio
7
- import contextlib
8
7
  import json
9
8
  import sys
10
9
  import uuid
11
- import re
12
10
  from typing import List, Dict, Any, Optional, Union, Iterable
13
11
  from pathlib import Path
14
12
 
15
13
  from rich.console import Console
16
- from rich.panel import Panel
17
- from rich.markdown import Markdown
18
- from rich.text import Text
19
- from rich import box
20
14
  from rich.markup import escape
21
15
 
22
16
  from prompt_toolkit import PromptSession
23
- from prompt_toolkit.completion import Completer, Completion
24
- from prompt_toolkit.shortcuts.prompt import CompleteStyle
17
+ from prompt_toolkit.completion import Completer, Completion, merge_completers
25
18
  from prompt_toolkit.history import InMemoryHistory
26
19
  from prompt_toolkit.key_binding import KeyBindings
20
+ from prompt_toolkit.shortcuts.prompt import CompleteStyle
27
21
 
28
- from ripperdoc import __version__
29
22
  from ripperdoc.core.config import get_global_config, provider_protocol
30
23
  from ripperdoc.core.default_tools import get_default_tools
31
24
  from ripperdoc.core.query import query, QueryContext
32
25
  from ripperdoc.core.system_prompt import build_system_prompt
33
26
  from ripperdoc.core.skills import build_skill_summary, load_all_skills
27
+ from ripperdoc.core.hooks.manager import hook_manager
34
28
  from ripperdoc.cli.commands import (
35
29
  get_slash_command,
30
+ get_custom_command,
36
31
  list_slash_commands,
32
+ list_custom_commands,
37
33
  slash_command_completions,
34
+ expand_command_content,
35
+ CustomCommandDefinition,
38
36
  )
39
37
  from ripperdoc.cli.ui.helpers import get_profile_for_pointer
40
38
  from ripperdoc.core.permissions import make_permission_checker
41
39
  from ripperdoc.cli.ui.spinner import Spinner
42
40
  from ripperdoc.cli.ui.thinking_spinner import ThinkingSpinner
43
41
  from ripperdoc.cli.ui.context_display import context_usage_lines
42
+ from ripperdoc.cli.ui.panels import create_welcome_panel, create_status_bar, print_shortcuts
43
+ from ripperdoc.cli.ui.message_display import MessageDisplay, parse_bash_output_sections
44
+ from ripperdoc.cli.ui.interrupt_handler import InterruptHandler
45
+ from ripperdoc.utils.conversation_compaction import (
46
+ compact_conversation,
47
+ CompactionResult,
48
+ CompactionError,
49
+ extract_tool_ids_from_message,
50
+ get_complete_tool_pairs_tail,
51
+ )
44
52
  from ripperdoc.utils.message_compaction import (
45
- compact_messages,
46
53
  estimate_conversation_tokens,
47
54
  estimate_used_tokens,
48
55
  get_context_usage_status,
49
56
  get_remaining_context_tokens,
57
+ micro_compact_messages,
50
58
  resolve_auto_compact_enabled,
51
59
  )
52
60
  from ripperdoc.utils.token_estimation import estimate_tokens
@@ -59,152 +67,28 @@ from ripperdoc.utils.mcp import (
59
67
  from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
60
68
  from ripperdoc.utils.session_history import SessionHistory
61
69
  from ripperdoc.utils.memory import build_memory_instructions
62
- from ripperdoc.core.query import query_llm
63
70
  from ripperdoc.utils.messages import (
64
71
  UserMessage,
65
72
  AssistantMessage,
66
73
  ProgressMessage,
67
74
  create_user_message,
68
- create_assistant_message,
69
75
  )
70
76
  from ripperdoc.utils.log import enable_session_file_logging, get_logger
71
- from ripperdoc.cli.ui.tool_renderers import ToolResultRendererRegistry
77
+ from ripperdoc.utils.path_ignore import build_ignore_filter
78
+ from ripperdoc.cli.ui.file_mention_completer import FileMentionCompleter
79
+ from ripperdoc.utils.message_formatting import stringify_message_content
72
80
 
73
81
 
74
82
  # Type alias for conversation messages
75
83
  ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage]
76
84
 
77
- THINKING_WORDS: list[str] = [
78
- "Accomplishing",
79
- "Actioning",
80
- "Actualizing",
81
- "Baking",
82
- "Booping",
83
- "Brewing",
84
- "Calculating",
85
- "Cerebrating",
86
- "Channelling",
87
- "Churning",
88
- "Coalescing",
89
- "Cogitating",
90
- "Computing",
91
- "Combobulating",
92
- "Concocting",
93
- "Conjuring",
94
- "Considering",
95
- "Contemplating",
96
- "Cooking",
97
- "Crafting",
98
- "Creating",
99
- "Crunching",
100
- "Deciphering",
101
- "Deliberating",
102
- "Determining",
103
- "Discombobulating",
104
- "Divining",
105
- "Doing",
106
- "Effecting",
107
- "Elucidating",
108
- "Enchanting",
109
- "Envisioning",
110
- "Finagling",
111
- "Flibbertigibbeting",
112
- "Forging",
113
- "Forming",
114
- "Frolicking",
115
- "Generating",
116
- "Germinating",
117
- "Hatching",
118
- "Herding",
119
- "Honking",
120
- "Ideating",
121
- "Imagining",
122
- "Incubating",
123
- "Inferring",
124
- "Manifesting",
125
- "Marinating",
126
- "Meandering",
127
- "Moseying",
128
- "Mulling",
129
- "Mustering",
130
- "Musing",
131
- "Noodling",
132
- "Percolating",
133
- "Perusing",
134
- "Philosophising",
135
- "Pontificating",
136
- "Pondering",
137
- "Processing",
138
- "Puttering",
139
- "Puzzling",
140
- "Reticulating",
141
- "Ruminating",
142
- "Scheming",
143
- "Schlepping",
144
- "Shimmying",
145
- "Simmering",
146
- "Smooshing",
147
- "Spelunking",
148
- "Spinning",
149
- "Stewing",
150
- "Sussing",
151
- "Synthesizing",
152
- "Thinking",
153
- "Tinkering",
154
- "Transmuting",
155
- "Unfurling",
156
- "Unravelling",
157
- "Vibing",
158
- "Wandering",
159
- "Whirring",
160
- "Wibbling",
161
- "Wizarding",
162
- "Working",
163
- "Wrangling",
164
- ]
165
-
166
85
  console = Console()
167
86
  logger = get_logger()
168
87
 
169
- # Keep a small window of recent messages alongside the summary after /compact so
170
- # the model retains immediate context.
171
- RECENT_MESSAGES_AFTER_COMPACT = 8
172
-
173
88
 
174
- def create_welcome_panel() -> Panel:
175
- """Create a welcome panel."""
176
-
177
- welcome_content = """
178
- [bold cyan]Welcome to Ripperdoc![/bold cyan]
179
-
180
- Ripperdoc is an AI-powered coding assistant that helps with software development tasks.
181
- You can read files, edit code, run commands, and help with various programming tasks.
182
-
183
- [dim]Type your questions below. Press Ctrl+C to exit.[/dim]
184
- """
185
-
186
- return Panel(
187
- welcome_content,
188
- title=f"Ripperdoc v{__version__}",
189
- border_style="cyan",
190
- box=box.ROUNDED,
191
- padding=(1, 2),
192
- )
193
-
194
-
195
- def create_status_bar() -> Text:
196
- """Create a status bar with current information."""
197
- profile = get_profile_for_pointer("main")
198
- model_name = profile.model if profile else "Not configured"
199
-
200
- status_text = Text()
201
- status_text.append("Ripperdoc", style="bold cyan")
202
- status_text.append(" • ")
203
- status_text.append(model_name, style="dim")
204
- status_text.append(" • ")
205
- status_text.append("Ready", style="green")
206
-
207
- return status_text
89
+ # Legacy aliases for backward compatibility with tests
90
+ _extract_tool_ids_from_message = extract_tool_ids_from_message
91
+ _get_complete_tool_pairs_tail = get_complete_tool_pairs_tail
208
92
 
209
93
 
210
94
  class RichUI:
@@ -227,14 +111,8 @@ class RichUI:
227
111
  self.query_context: Optional[QueryContext] = None
228
112
  self._current_tool: Optional[str] = None
229
113
  self._should_exit: bool = False
230
- self._query_interrupted: bool = False # Track if query was interrupted by ESC
231
- self._esc_listener_active: bool = False # Track if ESC listener is active
232
- self._esc_listener_paused: bool = False # Pause ESC listener during blocking prompts
233
- self._stdin_fd: Optional[int] = None # Track stdin for raw mode restoration
234
- self._stdin_old_settings: Optional[list] = None # Original terminal settings
235
- self._stdin_in_raw_mode: bool = False # Whether we currently own raw mode
236
114
  self.command_list = list_slash_commands()
237
- self._command_completions = slash_command_completions()
115
+ self._custom_command_list = list_custom_commands()
238
116
  self._prompt_session: Optional[PromptSession] = None
239
117
  self.project_path = Path.cwd()
240
118
  # Track a stable session identifier for the current UI run.
@@ -258,6 +136,21 @@ class RichUI:
258
136
  self._permission_checker = (
259
137
  make_permission_checker(self.project_path, safe_mode) if safe_mode else None
260
138
  )
139
+ # Build ignore filter for file completion
140
+ from ripperdoc.utils.path_ignore import get_project_ignore_patterns
141
+ project_patterns = get_project_ignore_patterns()
142
+ self._ignore_filter = build_ignore_filter(
143
+ self.project_path,
144
+ project_patterns=project_patterns,
145
+ include_defaults=True,
146
+ include_gitignore=True,
147
+ )
148
+
149
+ # Initialize component handlers
150
+ self._message_display = MessageDisplay(self.console, self.verbose)
151
+ self._interrupt_handler = InterruptHandler()
152
+ self._interrupt_handler.set_abort_callback(self._trigger_abort)
153
+
261
154
  # Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
262
155
  try:
263
156
  self._run_async(ensure_mcp_runtime(self.project_path))
@@ -268,6 +161,33 @@ class RichUI:
268
161
  extra={"session_id": self.session_id},
269
162
  )
270
163
 
164
+ # Initialize hook manager with project context
165
+ hook_manager.set_project_dir(self.project_path)
166
+ hook_manager.set_session_id(self.session_id)
167
+ logger.debug(
168
+ "[ui] Initialized hook manager",
169
+ extra={
170
+ "session_id": self.session_id,
171
+ "project_path": str(self.project_path),
172
+ },
173
+ )
174
+
175
+ # ─────────────────────────────────────────────────────────────────────────────
176
+ # Properties for backward compatibility with interrupt handler
177
+ # ─────────────────────────────────────────────────────────────────────────────
178
+
179
+ @property
180
+ def _query_interrupted(self) -> bool:
181
+ return self._interrupt_handler.was_interrupted
182
+
183
+ @property
184
+ def _esc_listener_paused(self) -> bool:
185
+ return self._interrupt_handler._esc_listener_paused
186
+
187
+ @_esc_listener_paused.setter
188
+ def _esc_listener_paused(self, value: bool) -> None:
189
+ self._interrupt_handler._esc_listener_paused = value
190
+
271
191
  def _context_usage_lines(
272
192
  self, breakdown: Any, model_label: str, auto_compact_enabled: bool
273
193
  ) -> List[str]:
@@ -359,287 +279,46 @@ class RichUI:
359
279
  ) -> None:
360
280
  """Display a message in the conversation."""
361
281
  if not is_tool:
362
- self._print_human_or_assistant(sender, content)
282
+ self._message_display.print_human_or_assistant(sender, content)
363
283
  return
364
284
 
365
285
  if tool_type == "call":
366
- self._print_tool_call(sender, content, tool_args)
286
+ self._message_display.print_tool_call(sender, content, tool_args)
367
287
  return
368
288
 
369
289
  if tool_type == "result":
370
- self._print_tool_result(sender, content, tool_data, tool_error)
290
+ self._message_display.print_tool_result(
291
+ sender, content, tool_data, tool_error, parse_bash_output_sections
292
+ )
371
293
  return
372
294
 
373
- self._print_generic_tool(sender, content)
295
+ self._message_display.print_generic_tool(sender, content)
374
296
 
297
+ # Delegate to MessageDisplay for backward compatibility
375
298
  def _format_tool_args(self, tool_name: str, tool_args: Optional[dict]) -> list[str]:
376
- """Render tool arguments into concise display-friendly parts."""
377
- if not tool_args:
378
- return []
379
-
380
- args_parts: list[str] = []
381
-
382
- def _format_arg(arg_key: str, arg_value: Any) -> str:
383
- if arg_key == "todos" and isinstance(arg_value, list):
384
- counts = {"pending": 0, "in_progress": 0, "completed": 0}
385
- for item in arg_value:
386
- status = ""
387
- if isinstance(item, dict):
388
- status = item.get("status", "")
389
- elif hasattr(item, "get"):
390
- status = item.get("status", "")
391
- elif hasattr(item, "status"):
392
- status = getattr(item, "status")
393
- if status in counts:
394
- counts[status] += 1
395
- total = len(arg_value)
396
- return f"{arg_key}: {total} items"
397
- if isinstance(arg_value, (list, dict)):
398
- return f"{arg_key}: {len(arg_value)} items"
399
- if isinstance(arg_value, str) and len(arg_value) > 50:
400
- return f'{arg_key}: "{arg_value[:50]}..."'
401
- return f"{arg_key}: {arg_value}"
402
-
403
- if tool_name == "Bash":
404
- command_value = tool_args.get("command")
405
- if command_value is not None:
406
- args_parts.append(_format_arg("command", command_value))
407
-
408
- background_value = tool_args.get("run_in_background", tool_args.get("runInBackground"))
409
- background_value = bool(background_value) if background_value is not None else False
410
- args_parts.append(f"background: {background_value}")
411
-
412
- sandbox_value = tool_args.get("sandbox")
413
- sandbox_value = bool(sandbox_value) if sandbox_value is not None else False
414
- args_parts.append(f"sandbox: {sandbox_value}")
415
-
416
- for key, value in tool_args.items():
417
- if key in {"command", "run_in_background", "runInBackground", "sandbox"}:
418
- continue
419
- args_parts.append(_format_arg(key, value))
420
- return args_parts
421
-
422
- # Special handling for Edit and MultiEdit tools - don't show old_string
423
- if tool_name in ["Edit", "MultiEdit"]:
424
- for key, value in tool_args.items():
425
- if key == "new_string":
426
- continue # Skip new_string for Edit/MultiEdit tools
427
- if key == "old_string":
428
- continue # Skip old_string for Edit/MultiEdit tools
429
- # For MultiEdit, also handle edits array
430
- if key == "edits" and isinstance(value, list):
431
- args_parts.append(f"edits: {len(value)} operations")
432
- continue
433
- args_parts.append(_format_arg(key, value))
434
- return args_parts
435
-
436
- for key, value in tool_args.items():
437
- args_parts.append(_format_arg(key, value))
438
- return args_parts
299
+ return self._message_display.format_tool_args(tool_name, tool_args)
439
300
 
440
301
  def _print_tool_call(self, sender: str, content: str, tool_args: Optional[dict]) -> None:
441
- """Render a tool invocation line."""
442
- if sender == "Task":
443
- subagent = ""
444
- if isinstance(tool_args, dict):
445
- subagent = tool_args.get("subagent_type") or tool_args.get("subagent") or ""
446
- desc = ""
447
- if isinstance(tool_args, dict):
448
- raw_desc = tool_args.get("description") or tool_args.get("prompt") or ""
449
- desc = raw_desc if len(str(raw_desc)) <= 120 else str(raw_desc)[:117] + "..."
450
- label = f"-> Launching subagent: {subagent or 'unknown'}"
451
- if desc:
452
- label += f" — {desc}"
453
- self.console.print(f"[cyan]{escape(label)}[/cyan]")
454
- return
455
-
456
- tool_name = sender if sender != "Ripperdoc" else content
457
- tool_display = f"● {tool_name}("
458
-
459
- args_parts = self._format_tool_args(tool_name, tool_args)
460
- if args_parts:
461
- tool_display += ", ".join(args_parts)
462
- tool_display += ")"
463
-
464
- self.console.print(f"[dim cyan]{escape(tool_display)}[/]")
302
+ self._message_display.print_tool_call(sender, content, tool_args)
465
303
 
466
304
  def _print_tool_result(
467
305
  self, sender: str, content: str, tool_data: Any, tool_error: bool = False
468
306
  ) -> None:
469
- """Render a tool result summary using the renderer registry."""
470
- # Check for failure states
471
- failed = tool_error
472
- if tool_data is not None:
473
- if isinstance(tool_data, dict):
474
- failed = failed or (tool_data.get("success") is False)
475
- else:
476
- success = getattr(tool_data, "success", None)
477
- failed = failed or (success is False)
478
- failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
479
-
480
- # Extract warning/token info
481
- warning_text = None
482
- token_estimate = None
483
- if tool_data is not None:
484
- warning_text = self._get_tool_field(tool_data, "warning")
485
- token_estimate = self._get_tool_field(tool_data, "token_estimate")
486
-
487
- # Handle failure case
488
- if failed:
489
- if content:
490
- self.console.print(f" ⎿ [red]{escape(content)}[/red]")
491
- else:
492
- self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
493
- return
494
-
495
- # Display warnings and token estimates
496
- if warning_text:
497
- self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
498
- if token_estimate:
499
- self.console.print(
500
- f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
501
- )
502
- elif token_estimate and self.verbose:
503
- self.console.print(f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]")
504
-
505
- # Handle empty content
506
- if not content:
507
- self.console.print(" ⎿ [dim]Tool completed[/]")
508
- return
509
-
510
- # Use renderer registry for tool-specific rendering
511
- registry = ToolResultRendererRegistry(
512
- self.console, self.verbose, self._parse_bash_output_sections
307
+ self._message_display.print_tool_result(
308
+ sender, content, tool_data, tool_error, parse_bash_output_sections
513
309
  )
514
- if registry.render(sender, content, tool_data):
515
- return
516
-
517
- # Fallback for unhandled tools
518
- self.console.print(" ⎿ [dim]Tool completed[/]")
519
310
 
520
311
  def _print_generic_tool(self, sender: str, content: str) -> None:
521
- """Fallback rendering for miscellaneous tool messages."""
522
- if sender == "Task" and isinstance(content, str) and content.startswith("[subagent:"):
523
- agent_label = content.split("]", 1)[0].replace("[subagent:", "").strip()
524
- summary = content.split("]", 1)[1].strip() if "]" in content else ""
525
- self.console.print(f"[green]↳ Subagent {escape(agent_label)} finished[/green]")
526
- if summary:
527
- self.console.print(f" {summary}", markup=False)
528
- return
529
- self.console.print(f"[dim cyan][Tool] {escape(sender)}: {escape(content)}[/]")
312
+ self._message_display.print_generic_tool(sender, content)
530
313
 
531
314
  def _print_human_or_assistant(self, sender: str, content: str) -> None:
532
- """Render messages from the user or assistant."""
533
- if sender.lower() == "you":
534
- self.console.print(f"[bold green]{escape(sender)}:[/] {escape(content)}")
535
- return
536
- self.console.print(Markdown(content))
537
-
538
- def _get_tool_field(self, data: Any, key: str, default: Any = None) -> Any:
539
- """Safely fetch a field from either an object or a dict."""
540
- if isinstance(data, dict):
541
- return data.get(key, default)
542
- return getattr(data, key, default)
543
-
544
- def _parse_bash_output_sections(self, content: str) -> tuple[List[str], List[str]]:
545
- """Fallback parser to pull stdout/stderr sections from a text block."""
546
- stdout_lines: List[str] = []
547
- stderr_lines: List[str] = []
548
- if not content:
549
- return stdout_lines, stderr_lines
550
-
551
- current: Optional[str] = None
552
- for line in content.splitlines():
553
- stripped = line.strip()
554
- if stripped.startswith("stdout:"):
555
- current = "stdout"
556
- remainder = line.split("stdout:", 1)[1].strip()
557
- if remainder:
558
- stdout_lines.append(remainder)
559
- continue
560
- if stripped.startswith("stderr:"):
561
- current = "stderr"
562
- remainder = line.split("stderr:", 1)[1].strip()
563
- if remainder:
564
- stderr_lines.append(remainder)
565
- continue
566
- if stripped.startswith("exit code:"):
567
- break
568
- if current == "stdout":
569
- stdout_lines.append(line)
570
- elif current == "stderr":
571
- stderr_lines.append(line)
572
-
573
- return stdout_lines, stderr_lines
315
+ self._message_display.print_human_or_assistant(sender, content)
574
316
 
575
317
  def _stringify_message_content(self, content: Any) -> str:
576
- """Extract readable text from a message content payload."""
577
- if isinstance(content, str):
578
- return content
579
- if isinstance(content, list):
580
- parts: List[str] = []
581
- for block in content:
582
- text = getattr(block, "text", None)
583
- if text is None:
584
- text = getattr(block, "thinking", None)
585
- if not text and isinstance(block, dict):
586
- text = block.get("text") or block.get("thinking") or block.get("data")
587
- if text:
588
- parts.append(str(text))
589
- return "\n".join(parts)
590
- return ""
591
-
592
- def _format_reasoning_preview(self, reasoning: Any) -> str:
593
- """Best-effort stringify for reasoning/thinking traces."""
594
- if reasoning is None:
595
- return ""
596
- if isinstance(reasoning, str):
597
- preview = reasoning.strip()
598
- else:
599
- try:
600
- preview = json.dumps(reasoning, ensure_ascii=False)
601
- except (TypeError, ValueError, OverflowError):
602
- preview = str(reasoning)
603
- preview = preview.strip()
604
- if len(preview) > 4000:
605
- preview = preview[:4000] + "…"
606
- return preview
318
+ return stringify_message_content(content)
607
319
 
608
320
  def _print_reasoning(self, reasoning: Any) -> None:
609
- """Display thinking traces in a dim style."""
610
- preview = self._format_reasoning_preview(reasoning)
611
- if not preview:
612
- return
613
- # Collapse excessive blank lines to keep the thinking block compact.
614
- preview = re.sub(r"\n{2,}", "\n", preview)
615
- self.console.print(f"[dim]🧠 Thinking: {escape(preview)}[/]")
616
-
617
- def _render_transcript(self, messages: List[ConversationMessage]) -> str:
618
- """Render a simple transcript for summarization."""
619
- lines: List[str] = []
620
- for msg in messages:
621
- role = getattr(msg, "type", "") or getattr(msg, "role", "")
622
- message_payload = getattr(msg, "message", None) or getattr(msg, "content", None)
623
- if hasattr(message_payload, "content"):
624
- message_payload = getattr(message_payload, "content")
625
- text = self._stringify_message_content(message_payload)
626
- if not text:
627
- continue
628
- label = "User" if role == "user" else "Assistant" if role == "assistant" else "Other"
629
- lines.append(f"{label}: {text}")
630
- return "\n".join(lines)
631
-
632
- def _extract_assistant_text(self, assistant_message: Any) -> str:
633
- """Extract plain text from an AssistantMessage."""
634
- if isinstance(assistant_message.message.content, str):
635
- return assistant_message.message.content
636
- if isinstance(assistant_message.message.content, list):
637
- parts: List[str] = []
638
- for block in assistant_message.message.content:
639
- if getattr(block, "type", None) == "text" and getattr(block, "text", None):
640
- parts.append(str(block.text))
641
- return "\n".join(parts)
642
- return ""
321
+ self._message_display.print_reasoning(reasoning)
643
322
 
644
323
  async def _prepare_query_context(self, user_input: str) -> tuple[str, Dict[str, str]]:
645
324
  """Load MCP servers, skills, and build system prompt.
@@ -698,7 +377,7 @@ class RichUI:
698
377
 
699
378
  return system_prompt, context
700
379
 
701
- def _check_and_compact_messages(
380
+ async def _check_and_compact_messages(
702
381
  self,
703
382
  messages: List[ConversationMessage],
704
383
  max_context_tokens: int,
@@ -710,6 +389,26 @@ class RichUI:
710
389
  Returns:
711
390
  Possibly compacted list of messages.
712
391
  """
392
+ micro = micro_compact_messages(
393
+ messages,
394
+ context_limit=max_context_tokens,
395
+ auto_compact_enabled=auto_compact_enabled,
396
+ protocol=protocol,
397
+ )
398
+ if micro.was_compacted:
399
+ messages = micro.messages # type: ignore[assignment]
400
+ logger.info(
401
+ "[ui] Micro-compacted conversation",
402
+ extra={
403
+ "session_id": self.session_id,
404
+ "tokens_before": micro.tokens_before,
405
+ "tokens_after": micro.tokens_after,
406
+ "tokens_saved": micro.tokens_saved,
407
+ "tools_compacted": micro.tools_compacted,
408
+ "trigger": micro.trigger_type,
409
+ },
410
+ )
411
+
713
412
  used_tokens = estimate_used_tokens(messages, protocol=protocol) # type: ignore[arg-type]
714
413
  usage_status = get_context_usage_status(
715
414
  used_tokens, max_context_tokens, auto_compact_enabled
@@ -738,25 +437,38 @@ class RichUI:
738
437
 
739
438
  if usage_status.should_auto_compact:
740
439
  original_messages = list(messages)
741
- compaction = compact_messages(messages, protocol=protocol) # type: ignore[arg-type]
742
- if compaction.was_compacted:
440
+ spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
441
+ try:
442
+ spinner.start()
443
+ result = await compact_conversation(
444
+ messages, custom_instructions="", protocol=protocol
445
+ )
446
+ finally:
447
+ spinner.stop()
448
+
449
+ if isinstance(result, CompactionResult):
743
450
  if self._saved_conversation is None:
744
451
  self._saved_conversation = original_messages # type: ignore[assignment]
745
452
  console.print(
746
- f"[yellow]Auto-compacted conversation (saved ~{compaction.tokens_saved} tokens). "
747
- f"Estimated usage: {compaction.tokens_after}/{max_context_tokens} tokens.[/yellow]"
453
+ f"[yellow]Auto-compacted conversation (saved ~{result.tokens_saved} tokens). "
454
+ f"Estimated usage: {result.tokens_after}/{max_context_tokens} tokens.[/yellow]"
748
455
  )
749
456
  logger.info(
750
457
  "[ui] Auto-compacted conversation",
751
458
  extra={
752
459
  "session_id": self.session_id,
753
- "tokens_before": compaction.tokens_before,
754
- "tokens_after": compaction.tokens_after,
755
- "tokens_saved": compaction.tokens_saved,
756
- "cleared_tool_ids": list(compaction.cleared_tool_ids),
460
+ "tokens_before": result.tokens_before,
461
+ "tokens_after": result.tokens_after,
462
+ "tokens_saved": result.tokens_saved,
757
463
  },
758
464
  )
759
- return compaction.messages # type: ignore[return-value]
465
+ return result.messages
466
+ elif isinstance(result, CompactionError):
467
+ logger.warning(
468
+ "[ui] Auto-compaction failed: %s",
469
+ result.message,
470
+ extra={"session_id": self.session_id},
471
+ )
760
472
 
761
473
  return messages
762
474
 
@@ -924,7 +636,7 @@ class RichUI:
924
636
  protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
925
637
 
926
638
  # Check and potentially compact messages
927
- messages = self._check_and_compact_messages(
639
+ messages = await self._check_and_compact_messages(
928
640
  messages, max_context_tokens, auto_compact_enabled, protocol
929
641
  )
930
642
 
@@ -1050,90 +762,12 @@ class RichUI:
1050
762
  # ESC Key Interrupt Support
1051
763
  # ─────────────────────────────────────────────────────────────────────────────
1052
764
 
1053
- # Keys that trigger interrupt
1054
- _INTERRUPT_KEYS = {'\x1b', '\x03'} # ESC, Ctrl+C
1055
-
765
+ # Delegate to InterruptHandler
1056
766
  def _pause_interrupt_listener(self) -> bool:
1057
- """Pause ESC listener and restore cooked terminal mode if we own raw mode."""
1058
- prev = self._esc_listener_paused
1059
- self._esc_listener_paused = True
1060
- try:
1061
- import termios
1062
- except ImportError:
1063
- return prev
1064
-
1065
- if (
1066
- self._stdin_fd is not None
1067
- and self._stdin_old_settings is not None
1068
- and self._stdin_in_raw_mode
1069
- ):
1070
- with contextlib.suppress(OSError, termios.error, ValueError):
1071
- termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._stdin_old_settings)
1072
- self._stdin_in_raw_mode = False
1073
- return prev
767
+ return self._interrupt_handler.pause_listener()
1074
768
 
1075
769
  def _resume_interrupt_listener(self, previous_state: bool) -> None:
1076
- """Restore paused state to what it was before a blocking prompt."""
1077
- self._esc_listener_paused = previous_state
1078
-
1079
- async def _listen_for_interrupt_key(self) -> bool:
1080
- """Listen for interrupt keys (ESC/Ctrl+C) during query execution.
1081
-
1082
- Uses raw terminal mode for immediate key detection without waiting
1083
- for escape sequences to complete.
1084
- """
1085
- import sys
1086
- import select
1087
- import termios
1088
- import tty
1089
-
1090
- try:
1091
- fd = sys.stdin.fileno()
1092
- old_settings = termios.tcgetattr(fd)
1093
- except (OSError, termios.error, ValueError):
1094
- return False
1095
-
1096
- self._stdin_fd = fd
1097
- self._stdin_old_settings = old_settings
1098
- raw_enabled = False
1099
- try:
1100
- while self._esc_listener_active:
1101
- if self._esc_listener_paused:
1102
- if raw_enabled:
1103
- with contextlib.suppress(OSError, termios.error, ValueError):
1104
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1105
- raw_enabled = False
1106
- self._stdin_in_raw_mode = False
1107
- await asyncio.sleep(0.05)
1108
- continue
1109
-
1110
- if not raw_enabled:
1111
- tty.setraw(fd)
1112
- raw_enabled = True
1113
- self._stdin_in_raw_mode = True
1114
-
1115
- await asyncio.sleep(0.02)
1116
- if select.select([sys.stdin], [], [], 0)[0]:
1117
- if sys.stdin.read(1) in self._INTERRUPT_KEYS:
1118
- return True
1119
- except (OSError, ValueError):
1120
- pass
1121
- finally:
1122
- self._stdin_in_raw_mode = False
1123
- with contextlib.suppress(OSError, termios.error, ValueError):
1124
- if raw_enabled:
1125
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1126
- self._stdin_fd = None
1127
- self._stdin_old_settings = None
1128
-
1129
- return False
1130
-
1131
- async def _cancel_task(self, task: asyncio.Task) -> None:
1132
- """Cancel a task and wait for it to finish."""
1133
- if not task.done():
1134
- task.cancel()
1135
- with contextlib.suppress(asyncio.CancelledError):
1136
- await task
770
+ self._interrupt_handler.resume_listener(previous_state)
1137
771
 
1138
772
  def _trigger_abort(self) -> None:
1139
773
  """Signal the query to abort."""
@@ -1141,42 +775,8 @@ class RichUI:
1141
775
  self.query_context.abort_controller.set()
1142
776
 
1143
777
  async def _run_query_with_esc_interrupt(self, query_coro: Any) -> bool:
1144
- """Run a query with ESC key interrupt support.
1145
-
1146
- Returns True if interrupted, False if completed normally.
1147
- """
1148
- self._query_interrupted = False
1149
- self._esc_listener_active = True
1150
-
1151
- query_task = asyncio.create_task(query_coro)
1152
- interrupt_task = asyncio.create_task(self._listen_for_interrupt_key())
1153
-
1154
- try:
1155
- done, _ = await asyncio.wait(
1156
- {query_task, interrupt_task},
1157
- return_when=asyncio.FIRST_COMPLETED
1158
- )
1159
-
1160
- # Check if interrupted
1161
- if interrupt_task in done and interrupt_task.result():
1162
- self._query_interrupted = True
1163
- self._trigger_abort()
1164
- await self._cancel_task(query_task)
1165
- return True
1166
-
1167
- # Query completed normally
1168
- if query_task in done:
1169
- await self._cancel_task(interrupt_task)
1170
- with contextlib.suppress(Exception):
1171
- query_task.result()
1172
- return False
1173
-
1174
- return False
1175
-
1176
- finally:
1177
- self._esc_listener_active = False
1178
- await self._cancel_task(query_task)
1179
- await self._cancel_task(interrupt_task)
778
+ """Run a query with ESC key interrupt support."""
779
+ return await self._interrupt_handler.run_with_interrupt(query_coro)
1180
780
 
1181
781
  def _run_async(self, coro: Any) -> Any:
1182
782
  """Run a coroutine on the persistent event loop."""
@@ -1199,8 +799,9 @@ class RichUI:
1199
799
  """Public wrapper for running coroutines on the UI event loop."""
1200
800
  return self._run_async(coro)
1201
801
 
1202
- def handle_slash_command(self, user_input: str) -> bool:
1203
- """Handle slash commands. Returns True if the input was handled."""
802
+ def handle_slash_command(self, user_input: str) -> bool | str:
803
+ """Handle slash commands. Returns True if handled as built-in, False if not a command,
804
+ or a string if it's a custom command that should be sent to the AI."""
1204
805
 
1205
806
  if not user_input.startswith("/"):
1206
807
  return False
@@ -1212,12 +813,32 @@ class RichUI:
1212
813
 
1213
814
  command_name = parts[0].lower()
1214
815
  trimmed_arg = " ".join(parts[1:]).strip()
816
+
817
+ # First, try built-in commands
1215
818
  command = get_slash_command(command_name)
1216
- if command is None:
1217
- self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
1218
- return True
819
+ if command is not None:
820
+ return command.handler(self, trimmed_arg)
821
+
822
+ # Then, try custom commands
823
+ custom_cmd = get_custom_command(command_name, self.project_path)
824
+ if custom_cmd is not None:
825
+ # Expand the custom command content
826
+ expanded_content = expand_command_content(
827
+ custom_cmd, trimmed_arg, self.project_path
828
+ )
829
+
830
+ # Show a hint that this is from a custom command
831
+ self.console.print(
832
+ f"[dim]Running custom command: /{command_name}[/dim]"
833
+ )
834
+ if custom_cmd.argument_hint and trimmed_arg:
835
+ self.console.print(f"[dim]Arguments: {trimmed_arg}[/dim]")
836
+
837
+ # Return the expanded content to be processed as a query
838
+ return expanded_content
1219
839
 
1220
- return command.handler(self, trimmed_arg)
840
+ self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
841
+ return True
1221
842
 
1222
843
  def get_prompt_session(self) -> PromptSession:
1223
844
  """Create (or return) the prompt session with command completion."""
@@ -1225,30 +846,68 @@ class RichUI:
1225
846
  return self._prompt_session
1226
847
 
1227
848
  class SlashCommandCompleter(Completer):
1228
- """Autocomplete for slash commands."""
849
+ """Autocomplete for slash commands including custom commands."""
1229
850
 
1230
- def __init__(self, completions: List):
1231
- self.completions = completions
851
+ def __init__(self, project_path: Path):
852
+ self.project_path = project_path
1232
853
 
1233
854
  def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
1234
855
  text = document.text_before_cursor
1235
856
  if not text.startswith("/"):
1236
857
  return
1237
858
  query = text[1:]
1238
- for name, cmd in self.completions:
859
+ # Get completions including custom commands
860
+ completions = slash_command_completions(self.project_path)
861
+ for name, cmd in completions:
1239
862
  if name.startswith(query):
863
+ # Handle both SlashCommand and CustomCommandDefinition
864
+ description = cmd.description
865
+ # Add hint for custom commands
866
+ if isinstance(cmd, CustomCommandDefinition):
867
+ hint = cmd.argument_hint or ""
868
+ display = f"{name} {hint}".strip() if hint else name
869
+ display_meta = f"[custom] {description}"
870
+ else:
871
+ display = name
872
+ display_meta = description
1240
873
  yield Completion(
1241
874
  name,
1242
875
  start_position=-len(query),
1243
- display=name,
1244
- display_meta=cmd.description,
876
+ display=display,
877
+ display_meta=display_meta,
1245
878
  )
1246
879
 
880
+ # Merge both completers
881
+ slash_completer = SlashCommandCompleter(self.project_path)
882
+ file_completer = FileMentionCompleter(self.project_path, self._ignore_filter)
883
+ combined_completer = merge_completers([slash_completer, file_completer])
884
+
885
+ key_bindings = KeyBindings()
886
+
887
+ @key_bindings.add("enter")
888
+ def _(event: Any) -> None:
889
+ """Accept completion if menu is open; otherwise submit line."""
890
+ buf = event.current_buffer
891
+ if buf.complete_state and buf.complete_state.current_completion:
892
+ buf.apply_completion(buf.complete_state.current_completion)
893
+ return
894
+ buf.validate_and_handle()
895
+
896
+ @key_bindings.add("tab")
897
+ def _(event: Any) -> None:
898
+ """Use Tab to accept the highlighted completion when visible."""
899
+ buf = event.current_buffer
900
+ if buf.complete_state and buf.complete_state.current_completion:
901
+ buf.apply_completion(buf.complete_state.current_completion)
902
+ else:
903
+ buf.start_completion(select_first=True)
904
+
1247
905
  self._prompt_session = PromptSession(
1248
- completer=SlashCommandCompleter(self._command_completions),
906
+ completer=combined_completer,
1249
907
  complete_style=CompleteStyle.COLUMN,
1250
908
  complete_while_typing=True,
1251
909
  history=InMemoryHistory(),
910
+ key_bindings=key_bindings,
1252
911
  )
1253
912
  return self._prompt_session
1254
913
 
@@ -1262,7 +921,7 @@ class RichUI:
1262
921
  # Display status
1263
922
  console.print(create_status_bar())
1264
923
  console.print()
1265
- console.print("[dim]Tip: type '/' then press Tab to see available commands. Press ESC to interrupt a running query.[/dim]\n")
924
+ console.print("[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. Press ESC to interrupt a running query.[/dim]\n")
1266
925
 
1267
926
  session = self.get_prompt_session()
1268
927
  logger.info(
@@ -1293,7 +952,11 @@ class RichUI:
1293
952
  handled = self.handle_slash_command(user_input)
1294
953
  if self._should_exit:
1295
954
  break
1296
- if handled:
955
+ # If handled is a string, it's expanded custom command content
956
+ if isinstance(handled, str):
957
+ # Process the expanded custom command as a query
958
+ user_input = handled
959
+ elif handled:
1297
960
  console.print() # spacing
1298
961
  continue
1299
962
 
@@ -1398,124 +1061,46 @@ class RichUI:
1398
1061
 
1399
1062
  async def _run_manual_compact(self, custom_instructions: str) -> None:
1400
1063
  """Manual compaction: clear bulky tool output and summarize conversation."""
1401
- if len(self.conversation_messages) < 2:
1402
- console.print("[yellow]Not enough conversation history to compact.[/yellow]")
1403
- return
1064
+ from rich.markup import escape
1404
1065
 
1405
1066
  model_profile = get_profile_for_pointer("main")
1406
1067
  protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
1407
1068
 
1408
- original_messages = list(self.conversation_messages)
1409
- tokens_before = estimate_conversation_tokens(original_messages, protocol=protocol)
1069
+ if len(self.conversation_messages) < 2:
1070
+ self.console.print("[yellow]Not enough conversation history to compact.[/yellow]")
1071
+ return
1410
1072
 
1411
- compaction = compact_messages(original_messages, protocol=protocol)
1412
- messages_for_summary = compaction.messages
1073
+ original_messages = list(self.conversation_messages)
1074
+ spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
1413
1075
 
1414
- spinner = Spinner(console, "Summarizing conversation...", spinner="dots")
1415
- summary_text = ""
1416
1076
  try:
1417
1077
  spinner.start()
1418
- summary_text = await self._summarize_conversation(
1419
- messages_for_summary, custom_instructions
1420
- )
1421
- except (OSError, RuntimeError, ConnectionError, ValueError, KeyError) as e:
1422
- console.print(f"[red]Error during compaction: {escape(str(e))}[/red]")
1423
- logger.warning(
1424
- "[ui] Error during manual compaction: %s: %s",
1425
- type(e).__name__, e,
1426
- extra={"session_id": self.session_id},
1078
+ result = await compact_conversation(
1079
+ self.conversation_messages,
1080
+ custom_instructions,
1081
+ protocol=protocol,
1427
1082
  )
1083
+ except Exception as exc:
1084
+ import traceback
1085
+ self.console.print(f"[red]Error during compaction: {escape(str(exc))}[/red]")
1086
+ self.console.print(f"[dim red]{traceback.format_exc()}[/dim red]")
1428
1087
  return
1429
1088
  finally:
1430
1089
  spinner.stop()
1431
1090
 
1432
- if not summary_text:
1433
- console.print("[red]Failed to summarize conversation for compaction.[/red]")
1434
- return
1435
-
1436
- if summary_text.strip() == "":
1437
- console.print("[red]Summarization returned empty content; aborting compaction.[/red]")
1438
- return
1439
-
1440
- self._saved_conversation = original_messages
1441
- summary_message = create_assistant_message(
1442
- f"Conversation summary (generated by /compact):\n{summary_text}"
1443
- )
1444
- non_progress_messages = [
1445
- m for m in messages_for_summary if getattr(m, "type", "") != "progress"
1446
- ]
1447
- recent_tail = (
1448
- non_progress_messages[-RECENT_MESSAGES_AFTER_COMPACT:]
1449
- if RECENT_MESSAGES_AFTER_COMPACT > 0
1450
- else []
1451
- )
1452
- new_conversation = [
1453
- create_user_message(
1454
- "Conversation compacted. Summary plus recent turns are kept; older tool output may "
1455
- "be cleared."
1456
- ),
1457
- summary_message,
1458
- *recent_tail,
1459
- ]
1460
- self.conversation_messages = new_conversation
1461
- tokens_after = estimate_conversation_tokens(new_conversation, protocol=protocol)
1462
- tokens_saved = max(0, tokens_before - tokens_after)
1463
- console.print(
1464
- f"[green]✓ Conversation compacted[/green] "
1465
- f"(saved ~{tokens_saved} tokens). Use /resume to restore full history."
1466
- )
1467
-
1468
- async def _summarize_conversation(
1469
- self,
1470
- messages: List[ConversationMessage],
1471
- custom_instructions: str,
1472
- ) -> str:
1473
- """Summarize the given conversation using the configured model."""
1474
- # Keep transcript bounded to recent turns to avoid blowing context.
1475
- recent_messages = messages[-40:]
1476
- transcript = self._render_transcript(recent_messages)
1477
- if not transcript.strip():
1478
- return ""
1479
-
1480
- instructions = (
1481
- "You are a helpful assistant summarizing the prior conversation. "
1482
- "Produce a concise bullet-list summary covering key decisions, important context, "
1483
- "commands run, files touched, and pending TODOs. Include blockers or open questions. "
1484
- "Keep it brief."
1485
- )
1486
- if custom_instructions.strip():
1487
- instructions += f"\nCustom instructions: {custom_instructions.strip()}"
1488
-
1489
- user_content = (
1490
- f"Summarize the following conversation between a user and an assistant:\n\n{transcript}"
1491
- )
1492
-
1493
- assistant_response = await query_llm(
1494
- messages=[{"role": "user", "content": user_content}], # type: ignore[list-item]
1495
- system_prompt=instructions,
1496
- tools=[],
1497
- max_thinking_tokens=0,
1498
- model="main",
1499
- )
1500
- return self._extract_assistant_text(assistant_response)
1091
+ if isinstance(result, CompactionResult):
1092
+ self._saved_conversation = original_messages
1093
+ self.conversation_messages = result.messages
1094
+ self.console.print(
1095
+ f"[green]✓ Conversation compacted[/green] "
1096
+ f"(saved ~{result.tokens_saved} tokens). Use /resume to restore full history."
1097
+ )
1098
+ elif isinstance(result, CompactionError):
1099
+ self.console.print(f"[red]{escape(result.message)}[/red]")
1501
1100
 
1502
1101
  def _print_shortcuts(self) -> None:
1503
1102
  """Show common keyboard shortcuts and prefixes."""
1504
- pairs = [
1505
- ("? for shortcuts", "! for bash mode"),
1506
- ("/ for commands", "shift + tab to auto-accept edits"),
1507
- # "@ for file paths", "ctrl + o for verbose output"),
1508
- # "# to memorize", "ctrl + v to paste images"),
1509
- # "& for background", "ctrl + t to show todos"),
1510
- # "double tap esc to clear input", "tab to toggle thinking"),
1511
- # "ctrl + _ to undo", "ctrl + z to suspend"),
1512
- # "shift + enter for newline", ""),
1513
- ]
1514
- console.print("[dim]Shortcuts[/dim]")
1515
- for left, right in pairs:
1516
- left_text = f" {left}".ljust(32)
1517
- right_text = f"{right}" if right else ""
1518
- console.print(f"{left_text}{right_text}")
1103
+ print_shortcuts(self.console)
1519
1104
 
1520
1105
 
1521
1106
  def check_onboarding_rich() -> bool: