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.
@@ -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.cli.ui.tool_renderers import ToolResultRendererRegistry
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
- 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
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._print_human_or_assistant(sender, content)
272
+ self._message_display.print_human_or_assistant(sender, content)
363
273
  return
364
274
 
365
275
  if tool_type == "call":
366
- self._print_tool_call(sender, content, tool_args)
276
+ self._message_display.print_tool_call(sender, content, tool_args)
367
277
  return
368
278
 
369
279
  if tool_type == "result":
370
- self._print_tool_result(sender, content, tool_data, tool_error)
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._print_generic_tool(sender, content)
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
- """Render tool arguments into concise display-friendly parts."""
377
- if not tool_args:
378
- return []
379
-
380
- args_parts: list[str] = []
381
-
382
- def _format_arg(arg_key: str, arg_value: Any) -> str:
383
- if arg_key == "todos" and isinstance(arg_value, list):
384
- counts = {"pending": 0, "in_progress": 0, "completed": 0}
385
- for item in arg_value:
386
- status = ""
387
- if isinstance(item, dict):
388
- status = item.get("status", "")
389
- elif hasattr(item, "get"):
390
- status = item.get("status", "")
391
- elif hasattr(item, "status"):
392
- status = getattr(item, "status")
393
- if status in counts:
394
- counts[status] += 1
395
- total = len(arg_value)
396
- return f"{arg_key}: {total} items"
397
- if isinstance(arg_value, (list, dict)):
398
- return f"{arg_key}: {len(arg_value)} items"
399
- if isinstance(arg_value, str) and len(arg_value) > 50:
400
- return f'{arg_key}: "{arg_value[:50]}..."'
401
- return f"{arg_key}: {arg_value}"
402
-
403
- if tool_name == "Bash":
404
- command_value = tool_args.get("command")
405
- if command_value is not None:
406
- args_parts.append(_format_arg("command", command_value))
407
-
408
- background_value = tool_args.get("run_in_background", tool_args.get("runInBackground"))
409
- background_value = bool(background_value) if background_value is not None else False
410
- args_parts.append(f"background: {background_value}")
411
-
412
- sandbox_value = tool_args.get("sandbox")
413
- sandbox_value = bool(sandbox_value) if sandbox_value is not None else False
414
- args_parts.append(f"sandbox: {sandbox_value}")
415
-
416
- for key, value in tool_args.items():
417
- if key in {"command", "run_in_background", "runInBackground", "sandbox"}:
418
- continue
419
- args_parts.append(_format_arg(key, value))
420
- return args_parts
421
-
422
- # Special handling for Edit and MultiEdit tools - don't show old_string
423
- if tool_name in ["Edit", "MultiEdit"]:
424
- for key, value in tool_args.items():
425
- if key == "new_string":
426
- continue # Skip new_string for Edit/MultiEdit tools
427
- if key == "old_string":
428
- continue # Skip old_string for Edit/MultiEdit tools
429
- # For MultiEdit, also handle edits array
430
- if key == "edits" and isinstance(value, list):
431
- args_parts.append(f"edits: {len(value)} operations")
432
- continue
433
- args_parts.append(_format_arg(key, value))
434
- return args_parts
435
-
436
- for key, value in tool_args.items():
437
- args_parts.append(_format_arg(key, value))
438
- return args_parts
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
- """Render a tool invocation line."""
442
- if sender == "Task":
443
- subagent = ""
444
- if isinstance(tool_args, dict):
445
- subagent = tool_args.get("subagent_type") or tool_args.get("subagent") or ""
446
- desc = ""
447
- if isinstance(tool_args, dict):
448
- raw_desc = tool_args.get("description") or tool_args.get("prompt") or ""
449
- desc = raw_desc if len(str(raw_desc)) <= 120 else str(raw_desc)[:117] + "..."
450
- label = f"-> Launching subagent: {subagent or 'unknown'}"
451
- if desc:
452
- label += f" — {desc}"
453
- self.console.print(f"[cyan]{escape(label)}[/cyan]")
454
- return
455
-
456
- tool_name = sender if sender != "Ripperdoc" else content
457
- tool_display = f"● {tool_name}("
458
-
459
- args_parts = self._format_tool_args(tool_name, tool_args)
460
- if args_parts:
461
- tool_display += ", ".join(args_parts)
462
- tool_display += ")"
463
-
464
- self.console.print(f"[dim cyan]{escape(tool_display)}[/]")
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
- """Render a tool result summary using the renderer registry."""
470
- # Check for failure states
471
- failed = tool_error
472
- if tool_data is not None:
473
- if isinstance(tool_data, dict):
474
- failed = failed or (tool_data.get("success") is False)
475
- else:
476
- success = getattr(tool_data, "success", None)
477
- failed = failed or (success is False)
478
- failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
479
-
480
- # Extract warning/token info
481
- warning_text = None
482
- token_estimate = None
483
- if tool_data is not None:
484
- warning_text = self._get_tool_field(tool_data, "warning")
485
- token_estimate = self._get_tool_field(tool_data, "token_estimate")
486
-
487
- # Handle failure case
488
- if failed:
489
- if content:
490
- self.console.print(f" ⎿ [red]{escape(content)}[/red]")
491
- else:
492
- self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
493
- return
494
-
495
- # Display warnings and token estimates
496
- if warning_text:
497
- self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
498
- if token_estimate:
499
- self.console.print(
500
- f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
501
- )
502
- elif token_estimate and self.verbose:
503
- self.console.print(f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]")
504
-
505
- # Handle empty content
506
- if not content:
507
- self.console.print(" ⎿ [dim]Tool completed[/]")
508
- return
509
-
510
- # Use renderer registry for tool-specific rendering
511
- registry = ToolResultRendererRegistry(
512
- self.console, self.verbose, self._parse_bash_output_sections
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
- """Fallback rendering for miscellaneous tool messages."""
522
- if sender == "Task" and isinstance(content, str) and content.startswith("[subagent:"):
523
- agent_label = content.split("]", 1)[0].replace("[subagent:", "").strip()
524
- summary = content.split("]", 1)[1].strip() if "]" in content else ""
525
- self.console.print(f"[green]↳ Subagent {escape(agent_label)} finished[/green]")
526
- if summary:
527
- self.console.print(f" {summary}", markup=False)
528
- return
529
- self.console.print(f"[dim cyan][Tool] {escape(sender)}: {escape(content)}[/]")
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
- """Render messages from the user or assistant."""
533
- if sender.lower() == "you":
534
- self.console.print(f"[bold green]{escape(sender)}:[/] {escape(content)}")
535
- return
536
- self.console.print(Markdown(content))
537
-
538
- def _get_tool_field(self, data: Any, key: str, default: Any = None) -> Any:
539
- """Safely fetch a field from either an object or a dict."""
540
- if isinstance(data, dict):
541
- return data.get(key, default)
542
- return getattr(data, key, default)
543
-
544
- def _parse_bash_output_sections(self, content: str) -> tuple[List[str], List[str]]:
545
- """Fallback parser to pull stdout/stderr sections from a text block."""
546
- stdout_lines: List[str] = []
547
- stderr_lines: List[str] = []
548
- if not content:
549
- return stdout_lines, stderr_lines
550
-
551
- current: Optional[str] = None
552
- for line in content.splitlines():
553
- stripped = line.strip()
554
- if stripped.startswith("stdout:"):
555
- current = "stdout"
556
- remainder = line.split("stdout:", 1)[1].strip()
557
- if remainder:
558
- stdout_lines.append(remainder)
559
- continue
560
- if stripped.startswith("stderr:"):
561
- current = "stderr"
562
- remainder = line.split("stderr:", 1)[1].strip()
563
- if remainder:
564
- stderr_lines.append(remainder)
565
- continue
566
- if stripped.startswith("exit code:"):
567
- break
568
- if current == "stdout":
569
- stdout_lines.append(line)
570
- elif current == "stderr":
571
- stderr_lines.append(line)
572
-
573
- return stdout_lines, stderr_lines
305
+ self._message_display.print_human_or_assistant(sender, content)
574
306
 
575
307
  def _stringify_message_content(self, content: Any) -> str:
576
- """Extract readable text from a message content payload."""
577
- if isinstance(content, str):
578
- return content
579
- if isinstance(content, list):
580
- parts: List[str] = []
581
- for block in content:
582
- text = getattr(block, "text", None)
583
- if text is None:
584
- text = getattr(block, "thinking", None)
585
- if not text and isinstance(block, dict):
586
- text = block.get("text") or block.get("thinking") or block.get("data")
587
- if text:
588
- parts.append(str(text))
589
- return "\n".join(parts)
590
- return ""
591
-
592
- def _format_reasoning_preview(self, reasoning: Any) -> str:
593
- """Best-effort stringify for reasoning/thinking traces."""
594
- if reasoning is None:
595
- return ""
596
- if isinstance(reasoning, str):
597
- preview = reasoning.strip()
598
- else:
599
- try:
600
- preview = json.dumps(reasoning, ensure_ascii=False)
601
- except (TypeError, ValueError, OverflowError):
602
- preview = str(reasoning)
603
- preview = preview.strip()
604
- if len(preview) > 4000:
605
- preview = preview[:4000] + "…"
606
- return preview
308
+ return stringify_message_content(content)
607
309
 
608
310
  def _print_reasoning(self, reasoning: Any) -> None:
609
- """Display thinking traces in a dim style."""
610
- preview = self._format_reasoning_preview(reasoning)
611
- if not preview:
612
- return
613
- # Collapse excessive blank lines to keep the thinking block compact.
614
- preview = re.sub(r"\n{2,}", "\n", preview)
615
- self.console.print(f"[dim]🧠 Thinking: {escape(preview)}[/]")
616
-
617
- def _render_transcript(self, messages: List[ConversationMessage]) -> str:
618
- """Render a simple transcript for summarization."""
619
- lines: List[str] = []
620
- for msg in messages:
621
- role = getattr(msg, "type", "") or getattr(msg, "role", "")
622
- message_payload = getattr(msg, "message", None) or getattr(msg, "content", None)
623
- if hasattr(message_payload, "content"):
624
- message_payload = getattr(message_payload, "content")
625
- text = self._stringify_message_content(message_payload)
626
- if not text:
627
- continue
628
- label = "User" if role == "user" else "Assistant" if role == "assistant" else "Other"
629
- lines.append(f"{label}: {text}")
630
- return "\n".join(lines)
631
-
632
- def _extract_assistant_text(self, assistant_message: Any) -> str:
633
- """Extract plain text from an AssistantMessage."""
634
- if isinstance(assistant_message.message.content, str):
635
- return assistant_message.message.content
636
- if isinstance(assistant_message.message.content, list):
637
- parts: List[str] = []
638
- for block in assistant_message.message.content:
639
- if getattr(block, "type", None) == "text" and getattr(block, "text", None):
640
- parts.append(str(block.text))
641
- return "\n".join(parts)
642
- return ""
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
- compaction = compact_messages(messages, protocol=protocol) # type: ignore[arg-type]
742
- if compaction.was_compacted:
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 ~{compaction.tokens_saved} tokens). "
747
- f"Estimated usage: {compaction.tokens_after}/{max_context_tokens} tokens.[/yellow]"
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": compaction.tokens_before,
754
- "tokens_after": compaction.tokens_after,
755
- "tokens_saved": compaction.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 compaction.messages # type: ignore[return-value]
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
- # Keys that trigger interrupt
1054
- _INTERRUPT_KEYS = {'\x1b', '\x03'} # ESC, Ctrl+C
1055
-
755
+ # Delegate to InterruptHandler
1056
756
  def _pause_interrupt_listener(self) -> bool:
1057
- """Pause ESC listener and restore cooked terminal mode if we own raw mode."""
1058
- prev = self._esc_listener_paused
1059
- self._esc_listener_paused = True
1060
- try:
1061
- import termios
1062
- except ImportError:
1063
- return prev
1064
-
1065
- if (
1066
- self._stdin_fd is not None
1067
- and self._stdin_old_settings is not None
1068
- and self._stdin_in_raw_mode
1069
- ):
1070
- with contextlib.suppress(OSError, termios.error, ValueError):
1071
- termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._stdin_old_settings)
1072
- self._stdin_in_raw_mode = False
1073
- return prev
757
+ return self._interrupt_handler.pause_listener()
1074
758
 
1075
759
  def _resume_interrupt_listener(self, previous_state: bool) -> None:
1076
- """Restore paused state to what it was before a blocking prompt."""
1077
- self._esc_listener_paused = previous_state
1078
-
1079
- async def _listen_for_interrupt_key(self) -> bool:
1080
- """Listen for interrupt keys (ESC/Ctrl+C) during query execution.
1081
-
1082
- Uses raw terminal mode for immediate key detection without waiting
1083
- for escape sequences to complete.
1084
- """
1085
- import sys
1086
- import select
1087
- import termios
1088
- import tty
1089
-
1090
- try:
1091
- fd = sys.stdin.fileno()
1092
- old_settings = termios.tcgetattr(fd)
1093
- except (OSError, termios.error, ValueError):
1094
- return False
1095
-
1096
- self._stdin_fd = fd
1097
- self._stdin_old_settings = old_settings
1098
- raw_enabled = False
1099
- try:
1100
- while self._esc_listener_active:
1101
- if self._esc_listener_paused:
1102
- if raw_enabled:
1103
- with contextlib.suppress(OSError, termios.error, ValueError):
1104
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1105
- raw_enabled = False
1106
- self._stdin_in_raw_mode = False
1107
- await asyncio.sleep(0.05)
1108
- continue
1109
-
1110
- if not raw_enabled:
1111
- tty.setraw(fd)
1112
- raw_enabled = True
1113
- self._stdin_in_raw_mode = True
1114
-
1115
- await asyncio.sleep(0.02)
1116
- if select.select([sys.stdin], [], [], 0)[0]:
1117
- if sys.stdin.read(1) in self._INTERRUPT_KEYS:
1118
- return True
1119
- except (OSError, ValueError):
1120
- pass
1121
- finally:
1122
- self._stdin_in_raw_mode = False
1123
- with contextlib.suppress(OSError, termios.error, ValueError):
1124
- if raw_enabled:
1125
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1126
- self._stdin_fd = None
1127
- self._stdin_old_settings = None
1128
-
1129
- return False
1130
-
1131
- async def _cancel_task(self, task: asyncio.Task) -> None:
1132
- """Cancel a task and wait for it to finish."""
1133
- if not task.done():
1134
- task.cancel()
1135
- with contextlib.suppress(asyncio.CancelledError):
1136
- await task
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=SlashCommandCompleter(self._command_completions),
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
- if len(self.conversation_messages) < 2:
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
- original_messages = list(self.conversation_messages)
1409
- tokens_before = estimate_conversation_tokens(original_messages, protocol=protocol)
1001
+ if len(self.conversation_messages) < 2:
1002
+ self.console.print("[yellow]Not enough conversation history to compact.[/yellow]")
1003
+ return
1410
1004
 
1411
- compaction = compact_messages(original_messages, protocol=protocol)
1412
- messages_for_summary = compaction.messages
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
- summary_text = await self._summarize_conversation(
1419
- messages_for_summary, custom_instructions
1420
- )
1421
- except (OSError, RuntimeError, ConnectionError, ValueError, KeyError) as e:
1422
- console.print(f"[red]Error during compaction: {escape(str(e))}[/red]")
1423
- logger.warning(
1424
- "[ui] Error during manual compaction: %s: %s",
1425
- type(e).__name__, e,
1426
- extra={"session_id": self.session_id},
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 not summary_text:
1433
- console.print("[red]Failed to summarize conversation for compaction.[/red]")
1434
- return
1435
-
1436
- if summary_text.strip() == "":
1437
- console.print("[red]Summarization returned empty content; aborting compaction.[/red]")
1438
- return
1439
-
1440
- self._saved_conversation = original_messages
1441
- summary_message = create_assistant_message(
1442
- f"Conversation summary (generated by /compact):\n{summary_text}"
1443
- )
1444
- non_progress_messages = [
1445
- m for m in messages_for_summary if getattr(m, "type", "") != "progress"
1446
- ]
1447
- recent_tail = (
1448
- non_progress_messages[-RECENT_MESSAGES_AFTER_COMPACT:]
1449
- if RECENT_MESSAGES_AFTER_COMPACT > 0
1450
- else []
1451
- )
1452
- new_conversation = [
1453
- create_user_message(
1454
- "Conversation compacted. Summary plus recent turns are kept; older tool output may "
1455
- "be cleared."
1456
- ),
1457
- summary_message,
1458
- *recent_tail,
1459
- ]
1460
- self.conversation_messages = new_conversation
1461
- tokens_after = estimate_conversation_tokens(new_conversation, protocol=protocol)
1462
- tokens_saved = max(0, tokens_before - tokens_after)
1463
- console.print(
1464
- f"[green]✓ Conversation compacted[/green] "
1465
- f"(saved ~{tokens_saved} tokens). Use /resume to restore full history."
1466
- )
1467
-
1468
- async def _summarize_conversation(
1469
- self,
1470
- messages: List[ConversationMessage],
1471
- custom_instructions: str,
1472
- ) -> str:
1473
- """Summarize the given conversation using the configured model."""
1474
- # Keep transcript bounded to recent turns to avoid blowing context.
1475
- recent_messages = messages[-40:]
1476
- transcript = self._render_transcript(recent_messages)
1477
- if not transcript.strip():
1478
- return ""
1479
-
1480
- instructions = (
1481
- "You are a helpful assistant summarizing the prior conversation. "
1482
- "Produce a concise bullet-list summary covering key decisions, important context, "
1483
- "commands run, files touched, and pending TODOs. Include blockers or open questions. "
1484
- "Keep it brief."
1485
- )
1486
- if custom_instructions.strip():
1487
- instructions += f"\nCustom instructions: {custom_instructions.strip()}"
1488
-
1489
- user_content = (
1490
- f"Summarize the following conversation between a user and an assistant:\n\n{transcript}"
1491
- )
1492
-
1493
- assistant_response = await query_llm(
1494
- messages=[{"role": "user", "content": user_content}], # type: ignore[list-item]
1495
- system_prompt=instructions,
1496
- tools=[],
1497
- max_thinking_tokens=0,
1498
- model="main",
1499
- )
1500
- return self._extract_assistant_text(assistant_response)
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
- pairs = [
1505
- ("? for shortcuts", "! for bash mode"),
1506
- ("/ for commands", "shift + tab to auto-accept edits"),
1507
- # "@ for file paths", "ctrl + o for verbose output"),
1508
- # "# to memorize", "ctrl + v to paste images"),
1509
- # "& for background", "ctrl + t to show todos"),
1510
- # "double tap esc to clear input", "tab to toggle thinking"),
1511
- # "ctrl + _ to undo", "ctrl + z to suspend"),
1512
- # "shift + enter for newline", ""),
1513
- ]
1514
- console.print("[dim]Shortcuts[/dim]")
1515
- for left, right in pairs:
1516
- left_text = f" {left}".ljust(32)
1517
- right_text = f"{right}" if right else ""
1518
- console.print(f"{left_text}{right_text}")
1035
+ print_shortcuts(self.console)
1519
1036
 
1520
1037
 
1521
1038
  def check_onboarding_rich() -> bool: