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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +5 -0
- ripperdoc/cli/commands/__init__.py +71 -6
- ripperdoc/cli/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +636 -0
- ripperdoc/cli/commands/permissions_cmd.py +36 -34
- ripperdoc/cli/commands/resume_cmd.py +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +276 -0
- ripperdoc/cli/ui/helpers.py +100 -3
- ripperdoc/cli/ui/interrupt_handler.py +175 -0
- ripperdoc/cli/ui/message_display.py +249 -0
- ripperdoc/cli/ui/panels.py +63 -0
- ripperdoc/cli/ui/rich_ui.py +233 -648
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- ripperdoc/core/custom_commands.py +411 -0
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +303 -0
- ripperdoc/core/hooks/events.py +540 -0
- ripperdoc/core/hooks/executor.py +498 -0
- ripperdoc/core/hooks/integration.py +353 -0
- ripperdoc/core/hooks/manager.py +720 -0
- ripperdoc/core/providers/anthropic.py +476 -69
- ripperdoc/core/query.py +61 -4
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +5 -5
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +2 -2
- ripperdoc/tools/multi_edit_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +476 -0
- ripperdoc/utils/message_compaction.py +109 -154
- ripperdoc/utils/message_formatting.py +216 -0
- ripperdoc/utils/messages.py +31 -9
- ripperdoc/utils/path_ignore.py +3 -4
- ripperdoc/utils/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
175
|
-
|
|
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.
|
|
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.
|
|
282
|
+
self._message_display.print_human_or_assistant(sender, content)
|
|
363
283
|
return
|
|
364
284
|
|
|
365
285
|
if tool_type == "call":
|
|
366
|
-
self.
|
|
286
|
+
self._message_display.print_tool_call(sender, content, tool_args)
|
|
367
287
|
return
|
|
368
288
|
|
|
369
289
|
if tool_type == "result":
|
|
370
|
-
self.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
742
|
-
|
|
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 ~{
|
|
747
|
-
f"Estimated usage: {
|
|
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":
|
|
754
|
-
"tokens_after":
|
|
755
|
-
"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
|
|
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
|
-
#
|
|
1054
|
-
_INTERRUPT_KEYS = {'\x1b', '\x03'} # ESC, Ctrl+C
|
|
1055
|
-
|
|
765
|
+
# Delegate to InterruptHandler
|
|
1056
766
|
def _pause_interrupt_listener(self) -> bool:
|
|
1057
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
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,
|
|
1231
|
-
self.
|
|
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
|
-
|
|
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=
|
|
1244
|
-
display_meta=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
1069
|
+
if len(self.conversation_messages) < 2:
|
|
1070
|
+
self.console.print("[yellow]Not enough conversation history to compact.[/yellow]")
|
|
1071
|
+
return
|
|
1410
1072
|
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
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:
|