ripperdoc 0.2.6__py3-none-any.whl → 0.2.7__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/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/resume_cmd.py +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +221 -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 +60 -0
- ripperdoc/cli/ui/rich_ui.py +147 -630
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +1 -1
- 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/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/METADATA +1 -1
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/RECORD +29 -23
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -6,6 +6,7 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
|
|
|
6
6
|
import asyncio
|
|
7
7
|
import contextlib
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import sys
|
|
10
11
|
import uuid
|
|
11
12
|
import re
|
|
@@ -13,19 +14,16 @@ from typing import List, Dict, Any, Optional, Union, Iterable
|
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
from rich.console import Console
|
|
16
|
-
from rich.panel import Panel
|
|
17
17
|
from rich.markdown import Markdown
|
|
18
|
-
from rich.text import Text
|
|
19
|
-
from rich import box
|
|
20
18
|
from rich.markup import escape
|
|
21
19
|
|
|
22
20
|
from prompt_toolkit import PromptSession
|
|
23
|
-
from prompt_toolkit.completion import Completer, Completion
|
|
21
|
+
from prompt_toolkit.completion import Completer, Completion, merge_completers
|
|
24
22
|
from prompt_toolkit.shortcuts.prompt import CompleteStyle
|
|
25
23
|
from prompt_toolkit.history import InMemoryHistory
|
|
26
24
|
from prompt_toolkit.key_binding import KeyBindings
|
|
25
|
+
from prompt_toolkit.document import Document
|
|
27
26
|
|
|
28
|
-
from ripperdoc import __version__
|
|
29
27
|
from ripperdoc.core.config import get_global_config, provider_protocol
|
|
30
28
|
from ripperdoc.core.default_tools import get_default_tools
|
|
31
29
|
from ripperdoc.core.query import query, QueryContext
|
|
@@ -36,17 +34,27 @@ from ripperdoc.cli.commands import (
|
|
|
36
34
|
list_slash_commands,
|
|
37
35
|
slash_command_completions,
|
|
38
36
|
)
|
|
39
|
-
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
37
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer, THINKING_WORDS
|
|
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,7 +67,6 @@ 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,
|
|
@@ -68,143 +75,21 @@ from ripperdoc.utils.messages import (
|
|
|
68
75
|
create_assistant_message,
|
|
69
76
|
)
|
|
70
77
|
from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
71
|
-
from ripperdoc.
|
|
78
|
+
from ripperdoc.utils.path_ignore import build_ignore_filter
|
|
79
|
+
from ripperdoc.cli.ui.file_mention_completer import FileMentionCompleter
|
|
80
|
+
from ripperdoc.utils.message_formatting import stringify_message_content
|
|
72
81
|
|
|
73
82
|
|
|
74
83
|
# Type alias for conversation messages
|
|
75
84
|
ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage]
|
|
76
85
|
|
|
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
86
|
console = Console()
|
|
167
87
|
logger = get_logger()
|
|
168
88
|
|
|
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
|
-
|
|
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
89
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
90
|
+
# Legacy aliases for backward compatibility with tests
|
|
91
|
+
_extract_tool_ids_from_message = extract_tool_ids_from_message
|
|
92
|
+
_get_complete_tool_pairs_tail = get_complete_tool_pairs_tail
|
|
208
93
|
|
|
209
94
|
|
|
210
95
|
class RichUI:
|
|
@@ -227,12 +112,6 @@ class RichUI:
|
|
|
227
112
|
self.query_context: Optional[QueryContext] = None
|
|
228
113
|
self._current_tool: Optional[str] = None
|
|
229
114
|
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
115
|
self.command_list = list_slash_commands()
|
|
237
116
|
self._command_completions = slash_command_completions()
|
|
238
117
|
self._prompt_session: Optional[PromptSession] = None
|
|
@@ -258,6 +137,21 @@ class RichUI:
|
|
|
258
137
|
self._permission_checker = (
|
|
259
138
|
make_permission_checker(self.project_path, safe_mode) if safe_mode else None
|
|
260
139
|
)
|
|
140
|
+
# Build ignore filter for file completion
|
|
141
|
+
from ripperdoc.utils.path_ignore import get_project_ignore_patterns
|
|
142
|
+
project_patterns = get_project_ignore_patterns()
|
|
143
|
+
self._ignore_filter = build_ignore_filter(
|
|
144
|
+
self.project_path,
|
|
145
|
+
project_patterns=project_patterns,
|
|
146
|
+
include_defaults=True,
|
|
147
|
+
include_gitignore=True,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Initialize component handlers
|
|
151
|
+
self._message_display = MessageDisplay(self.console, self.verbose)
|
|
152
|
+
self._interrupt_handler = InterruptHandler()
|
|
153
|
+
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
154
|
+
|
|
261
155
|
# Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
|
|
262
156
|
try:
|
|
263
157
|
self._run_async(ensure_mcp_runtime(self.project_path))
|
|
@@ -268,6 +162,22 @@ class RichUI:
|
|
|
268
162
|
extra={"session_id": self.session_id},
|
|
269
163
|
)
|
|
270
164
|
|
|
165
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
# Properties for backward compatibility with interrupt handler
|
|
167
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def _query_interrupted(self) -> bool:
|
|
171
|
+
return self._interrupt_handler.was_interrupted
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def _esc_listener_paused(self) -> bool:
|
|
175
|
+
return self._interrupt_handler._esc_listener_paused
|
|
176
|
+
|
|
177
|
+
@_esc_listener_paused.setter
|
|
178
|
+
def _esc_listener_paused(self, value: bool) -> None:
|
|
179
|
+
self._interrupt_handler._esc_listener_paused = value
|
|
180
|
+
|
|
271
181
|
def _context_usage_lines(
|
|
272
182
|
self, breakdown: Any, model_label: str, auto_compact_enabled: bool
|
|
273
183
|
) -> List[str]:
|
|
@@ -359,287 +269,46 @@ class RichUI:
|
|
|
359
269
|
) -> None:
|
|
360
270
|
"""Display a message in the conversation."""
|
|
361
271
|
if not is_tool:
|
|
362
|
-
self.
|
|
272
|
+
self._message_display.print_human_or_assistant(sender, content)
|
|
363
273
|
return
|
|
364
274
|
|
|
365
275
|
if tool_type == "call":
|
|
366
|
-
self.
|
|
276
|
+
self._message_display.print_tool_call(sender, content, tool_args)
|
|
367
277
|
return
|
|
368
278
|
|
|
369
279
|
if tool_type == "result":
|
|
370
|
-
self.
|
|
280
|
+
self._message_display.print_tool_result(
|
|
281
|
+
sender, content, tool_data, tool_error, parse_bash_output_sections
|
|
282
|
+
)
|
|
371
283
|
return
|
|
372
284
|
|
|
373
|
-
self.
|
|
285
|
+
self._message_display.print_generic_tool(sender, content)
|
|
374
286
|
|
|
287
|
+
# Delegate to MessageDisplay for backward compatibility
|
|
375
288
|
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
|
|
289
|
+
return self._message_display.format_tool_args(tool_name, tool_args)
|
|
439
290
|
|
|
440
291
|
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)}[/]")
|
|
292
|
+
self._message_display.print_tool_call(sender, content, tool_args)
|
|
465
293
|
|
|
466
294
|
def _print_tool_result(
|
|
467
295
|
self, sender: str, content: str, tool_data: Any, tool_error: bool = False
|
|
468
296
|
) -> 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
|
|
297
|
+
self._message_display.print_tool_result(
|
|
298
|
+
sender, content, tool_data, tool_error, parse_bash_output_sections
|
|
513
299
|
)
|
|
514
|
-
if registry.render(sender, content, tool_data):
|
|
515
|
-
return
|
|
516
|
-
|
|
517
|
-
# Fallback for unhandled tools
|
|
518
|
-
self.console.print(" ⎿ [dim]Tool completed[/]")
|
|
519
300
|
|
|
520
301
|
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)}[/]")
|
|
302
|
+
self._message_display.print_generic_tool(sender, content)
|
|
530
303
|
|
|
531
304
|
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
|
|
305
|
+
self._message_display.print_human_or_assistant(sender, content)
|
|
574
306
|
|
|
575
307
|
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
|
|
308
|
+
return stringify_message_content(content)
|
|
607
309
|
|
|
608
310
|
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 ""
|
|
311
|
+
self._message_display.print_reasoning(reasoning)
|
|
643
312
|
|
|
644
313
|
async def _prepare_query_context(self, user_input: str) -> tuple[str, Dict[str, str]]:
|
|
645
314
|
"""Load MCP servers, skills, and build system prompt.
|
|
@@ -698,7 +367,7 @@ class RichUI:
|
|
|
698
367
|
|
|
699
368
|
return system_prompt, context
|
|
700
369
|
|
|
701
|
-
def _check_and_compact_messages(
|
|
370
|
+
async def _check_and_compact_messages(
|
|
702
371
|
self,
|
|
703
372
|
messages: List[ConversationMessage],
|
|
704
373
|
max_context_tokens: int,
|
|
@@ -710,6 +379,26 @@ class RichUI:
|
|
|
710
379
|
Returns:
|
|
711
380
|
Possibly compacted list of messages.
|
|
712
381
|
"""
|
|
382
|
+
micro = micro_compact_messages(
|
|
383
|
+
messages,
|
|
384
|
+
context_limit=max_context_tokens,
|
|
385
|
+
auto_compact_enabled=auto_compact_enabled,
|
|
386
|
+
protocol=protocol,
|
|
387
|
+
)
|
|
388
|
+
if micro.was_compacted:
|
|
389
|
+
messages = micro.messages # type: ignore[assignment]
|
|
390
|
+
logger.info(
|
|
391
|
+
"[ui] Micro-compacted conversation",
|
|
392
|
+
extra={
|
|
393
|
+
"session_id": self.session_id,
|
|
394
|
+
"tokens_before": micro.tokens_before,
|
|
395
|
+
"tokens_after": micro.tokens_after,
|
|
396
|
+
"tokens_saved": micro.tokens_saved,
|
|
397
|
+
"tools_compacted": micro.tools_compacted,
|
|
398
|
+
"trigger": micro.trigger_type,
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
|
|
713
402
|
used_tokens = estimate_used_tokens(messages, protocol=protocol) # type: ignore[arg-type]
|
|
714
403
|
usage_status = get_context_usage_status(
|
|
715
404
|
used_tokens, max_context_tokens, auto_compact_enabled
|
|
@@ -738,25 +427,38 @@ class RichUI:
|
|
|
738
427
|
|
|
739
428
|
if usage_status.should_auto_compact:
|
|
740
429
|
original_messages = list(messages)
|
|
741
|
-
|
|
742
|
-
|
|
430
|
+
spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
|
|
431
|
+
try:
|
|
432
|
+
spinner.start()
|
|
433
|
+
result = await compact_conversation(
|
|
434
|
+
messages, custom_instructions="", protocol=protocol
|
|
435
|
+
)
|
|
436
|
+
finally:
|
|
437
|
+
spinner.stop()
|
|
438
|
+
|
|
439
|
+
if isinstance(result, CompactionResult):
|
|
743
440
|
if self._saved_conversation is None:
|
|
744
441
|
self._saved_conversation = original_messages # type: ignore[assignment]
|
|
745
442
|
console.print(
|
|
746
|
-
f"[yellow]Auto-compacted conversation (saved ~{
|
|
747
|
-
f"Estimated usage: {
|
|
443
|
+
f"[yellow]Auto-compacted conversation (saved ~{result.tokens_saved} tokens). "
|
|
444
|
+
f"Estimated usage: {result.tokens_after}/{max_context_tokens} tokens.[/yellow]"
|
|
748
445
|
)
|
|
749
446
|
logger.info(
|
|
750
447
|
"[ui] Auto-compacted conversation",
|
|
751
448
|
extra={
|
|
752
449
|
"session_id": self.session_id,
|
|
753
|
-
"tokens_before":
|
|
754
|
-
"tokens_after":
|
|
755
|
-
"tokens_saved":
|
|
756
|
-
"cleared_tool_ids": list(compaction.cleared_tool_ids),
|
|
450
|
+
"tokens_before": result.tokens_before,
|
|
451
|
+
"tokens_after": result.tokens_after,
|
|
452
|
+
"tokens_saved": result.tokens_saved,
|
|
757
453
|
},
|
|
758
454
|
)
|
|
759
|
-
return
|
|
455
|
+
return result.messages # type: ignore[return-value]
|
|
456
|
+
elif isinstance(result, CompactionError):
|
|
457
|
+
logger.warning(
|
|
458
|
+
"[ui] Auto-compaction failed: %s",
|
|
459
|
+
result.message,
|
|
460
|
+
extra={"session_id": self.session_id},
|
|
461
|
+
)
|
|
760
462
|
|
|
761
463
|
return messages
|
|
762
464
|
|
|
@@ -924,7 +626,7 @@ class RichUI:
|
|
|
924
626
|
protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
|
|
925
627
|
|
|
926
628
|
# Check and potentially compact messages
|
|
927
|
-
messages = self._check_and_compact_messages(
|
|
629
|
+
messages = await self._check_and_compact_messages(
|
|
928
630
|
messages, max_context_tokens, auto_compact_enabled, protocol
|
|
929
631
|
)
|
|
930
632
|
|
|
@@ -1050,90 +752,12 @@ class RichUI:
|
|
|
1050
752
|
# ESC Key Interrupt Support
|
|
1051
753
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1052
754
|
|
|
1053
|
-
#
|
|
1054
|
-
_INTERRUPT_KEYS = {'\x1b', '\x03'} # ESC, Ctrl+C
|
|
1055
|
-
|
|
755
|
+
# Delegate to InterruptHandler
|
|
1056
756
|
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
|
|
757
|
+
return self._interrupt_handler.pause_listener()
|
|
1074
758
|
|
|
1075
759
|
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
|
|
760
|
+
self._interrupt_handler.resume_listener(previous_state)
|
|
1137
761
|
|
|
1138
762
|
def _trigger_abort(self) -> None:
|
|
1139
763
|
"""Signal the query to abort."""
|
|
@@ -1141,42 +765,8 @@ class RichUI:
|
|
|
1141
765
|
self.query_context.abort_controller.set()
|
|
1142
766
|
|
|
1143
767
|
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)
|
|
768
|
+
"""Run a query with ESC key interrupt support."""
|
|
769
|
+
return await self._interrupt_handler.run_with_interrupt(query_coro)
|
|
1180
770
|
|
|
1181
771
|
def _run_async(self, coro: Any) -> Any:
|
|
1182
772
|
"""Run a coroutine on the persistent event loop."""
|
|
@@ -1244,8 +834,13 @@ class RichUI:
|
|
|
1244
834
|
display_meta=cmd.description,
|
|
1245
835
|
)
|
|
1246
836
|
|
|
837
|
+
# Merge both completers
|
|
838
|
+
slash_completer = SlashCommandCompleter(self._command_completions)
|
|
839
|
+
file_completer = FileMentionCompleter(self.project_path, self._ignore_filter)
|
|
840
|
+
combined_completer = merge_completers([slash_completer, file_completer])
|
|
841
|
+
|
|
1247
842
|
self._prompt_session = PromptSession(
|
|
1248
|
-
completer=
|
|
843
|
+
completer=combined_completer,
|
|
1249
844
|
complete_style=CompleteStyle.COLUMN,
|
|
1250
845
|
complete_while_typing=True,
|
|
1251
846
|
history=InMemoryHistory(),
|
|
@@ -1262,7 +857,7 @@ class RichUI:
|
|
|
1262
857
|
# Display status
|
|
1263
858
|
console.print(create_status_bar())
|
|
1264
859
|
console.print()
|
|
1265
|
-
console.print("[dim]Tip: type '/' then press Tab to see available commands. Press ESC to interrupt a running query.[/dim]\n")
|
|
860
|
+
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
861
|
|
|
1267
862
|
session = self.get_prompt_session()
|
|
1268
863
|
logger.info(
|
|
@@ -1398,124 +993,46 @@ class RichUI:
|
|
|
1398
993
|
|
|
1399
994
|
async def _run_manual_compact(self, custom_instructions: str) -> None:
|
|
1400
995
|
"""Manual compaction: clear bulky tool output and summarize conversation."""
|
|
1401
|
-
|
|
1402
|
-
console.print("[yellow]Not enough conversation history to compact.[/yellow]")
|
|
1403
|
-
return
|
|
996
|
+
from rich.markup import escape
|
|
1404
997
|
|
|
1405
998
|
model_profile = get_profile_for_pointer("main")
|
|
1406
999
|
protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
|
|
1407
1000
|
|
|
1408
|
-
|
|
1409
|
-
|
|
1001
|
+
if len(self.conversation_messages) < 2:
|
|
1002
|
+
self.console.print("[yellow]Not enough conversation history to compact.[/yellow]")
|
|
1003
|
+
return
|
|
1410
1004
|
|
|
1411
|
-
|
|
1412
|
-
|
|
1005
|
+
original_messages = list(self.conversation_messages)
|
|
1006
|
+
spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
|
|
1413
1007
|
|
|
1414
|
-
spinner = Spinner(console, "Summarizing conversation...", spinner="dots")
|
|
1415
|
-
summary_text = ""
|
|
1416
1008
|
try:
|
|
1417
1009
|
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},
|
|
1010
|
+
result = await compact_conversation(
|
|
1011
|
+
self.conversation_messages,
|
|
1012
|
+
custom_instructions,
|
|
1013
|
+
protocol=protocol,
|
|
1427
1014
|
)
|
|
1015
|
+
except Exception as exc:
|
|
1016
|
+
import traceback
|
|
1017
|
+
self.console.print(f"[red]Error during compaction: {escape(str(exc))}[/red]")
|
|
1018
|
+
self.console.print(f"[dim red]{traceback.format_exc()}[/dim red]")
|
|
1428
1019
|
return
|
|
1429
1020
|
finally:
|
|
1430
1021
|
spinner.stop()
|
|
1431
1022
|
|
|
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)
|
|
1023
|
+
if isinstance(result, CompactionResult):
|
|
1024
|
+
self._saved_conversation = original_messages
|
|
1025
|
+
self.conversation_messages = result.messages
|
|
1026
|
+
self.console.print(
|
|
1027
|
+
f"[green]✓ Conversation compacted[/green] "
|
|
1028
|
+
f"(saved ~{result.tokens_saved} tokens). Use /resume to restore full history."
|
|
1029
|
+
)
|
|
1030
|
+
elif isinstance(result, CompactionError):
|
|
1031
|
+
self.console.print(f"[red]{escape(result.message)}[/red]")
|
|
1501
1032
|
|
|
1502
1033
|
def _print_shortcuts(self) -> None:
|
|
1503
1034
|
"""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}")
|
|
1035
|
+
print_shortcuts(self.console)
|
|
1519
1036
|
|
|
1520
1037
|
|
|
1521
1038
|
def check_onboarding_rich() -> bool:
|