ripperdoc 0.2.10__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  17. ripperdoc/cli/ui/message_display.py +7 -0
  18. ripperdoc/cli/ui/panels.py +13 -8
  19. ripperdoc/cli/ui/rich_ui.py +513 -84
  20. ripperdoc/cli/ui/spinner.py +68 -5
  21. ripperdoc/cli/ui/tool_renderers.py +10 -9
  22. ripperdoc/cli/ui/wizard.py +18 -11
  23. ripperdoc/core/agents.py +4 -0
  24. ripperdoc/core/config.py +235 -0
  25. ripperdoc/core/default_tools.py +1 -0
  26. ripperdoc/core/hooks/llm_callback.py +0 -1
  27. ripperdoc/core/hooks/manager.py +6 -0
  28. ripperdoc/core/permissions.py +123 -39
  29. ripperdoc/core/providers/openai.py +55 -9
  30. ripperdoc/core/query.py +349 -108
  31. ripperdoc/core/query_utils.py +17 -14
  32. ripperdoc/core/skills.py +1 -0
  33. ripperdoc/core/theme.py +298 -0
  34. ripperdoc/core/tool.py +8 -3
  35. ripperdoc/protocol/__init__.py +14 -0
  36. ripperdoc/protocol/models.py +300 -0
  37. ripperdoc/protocol/stdio.py +1453 -0
  38. ripperdoc/tools/background_shell.py +49 -5
  39. ripperdoc/tools/bash_tool.py +75 -9
  40. ripperdoc/tools/file_edit_tool.py +98 -29
  41. ripperdoc/tools/file_read_tool.py +139 -8
  42. ripperdoc/tools/file_write_tool.py +46 -3
  43. ripperdoc/tools/grep_tool.py +98 -8
  44. ripperdoc/tools/lsp_tool.py +9 -15
  45. ripperdoc/tools/multi_edit_tool.py +26 -3
  46. ripperdoc/tools/skill_tool.py +52 -1
  47. ripperdoc/tools/task_tool.py +33 -8
  48. ripperdoc/utils/file_watch.py +12 -6
  49. ripperdoc/utils/image_utils.py +125 -0
  50. ripperdoc/utils/log.py +30 -3
  51. ripperdoc/utils/lsp.py +9 -3
  52. ripperdoc/utils/mcp.py +80 -18
  53. ripperdoc/utils/message_formatting.py +2 -2
  54. ripperdoc/utils/messages.py +177 -32
  55. ripperdoc/utils/pending_messages.py +50 -0
  56. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  57. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  58. ripperdoc/utils/platform.py +198 -0
  59. ripperdoc/utils/session_heatmap.py +1 -3
  60. ripperdoc/utils/session_history.py +2 -2
  61. ripperdoc/utils/session_stats.py +1 -0
  62. ripperdoc/utils/shell_utils.py +8 -5
  63. ripperdoc/utils/todo.py +0 -6
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/METADATA +49 -17
  65. ripperdoc-0.3.1.dist-info/RECORD +136 -0
  66. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/WHEEL +1 -1
  67. ripperdoc/cli/ui/interrupt_handler.py +0 -174
  68. ripperdoc/sdk/__init__.py +0 -9
  69. ripperdoc/sdk/client.py +0 -408
  70. ripperdoc-0.2.10.dist-info/RECORD +0 -129
  71. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/entry_points.txt +0 -0
  72. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/licenses/LICENSE +0 -0
  73. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.1.dist-info}/top_level.txt +0 -0
@@ -22,6 +22,12 @@ def _auth_token_display(profile: Optional[ModelProfile]) -> Tuple[str, Optional[
22
22
  if not profile:
23
23
  return ("Not configured", None)
24
24
 
25
+ # 首先检查全局 RIPPERDOC_AUTH_TOKEN
26
+ if os.getenv("RIPPERDOC_AUTH_TOKEN"):
27
+ return ("RIPPERDOC_AUTH_TOKEN (env)", "RIPPERDOC_AUTH_TOKEN")
28
+ if os.getenv("RIPPERDOC_API_KEY"):
29
+ return ("RIPPERDOC_API_KEY (env)", "RIPPERDOC_API_KEY")
30
+
25
31
  provider_value = (
26
32
  profile.provider.value if hasattr(profile.provider, "value") else str(profile.provider)
27
33
  )
@@ -44,6 +50,10 @@ def _api_base_display(profile: Optional[ModelProfile]) -> str:
44
50
  if not profile:
45
51
  return "API base URL: Not configured"
46
52
 
53
+ # 首先检查全局 RIPPERDOC_BASE_URL
54
+ if base_url := os.getenv("RIPPERDOC_BASE_URL"):
55
+ return f"API base URL: {base_url} (RIPPERDOC_BASE_URL env)"
56
+
47
57
  label_map = {
48
58
  ProviderType.ANTHROPIC: "Anthropic base URL",
49
59
  ProviderType.OPENAI_COMPATIBLE: "OpenAI-compatible base URL",
@@ -100,13 +100,14 @@ def _list_tasks(ui: Any) -> bool:
100
100
  table.add_column("ID", style="cyan", no_wrap=True)
101
101
  table.add_column("Status", style="magenta", no_wrap=True)
102
102
  table.add_column("Command", style="white")
103
- table.add_column("Duration", style="dim", no_wrap=True)
103
+ table.add_column("Runtime", style="dim", no_wrap=True)
104
+ table.add_column("Age", style="dim", no_wrap=True)
104
105
 
105
106
  for task_id in sorted(task_ids):
106
107
  try:
107
108
  status = get_background_status(task_id, consume=False)
108
109
  except (KeyError, ValueError, RuntimeError, OSError) as exc:
109
- table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-")
110
+ table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-", "-")
110
111
  logger.warning(
111
112
  "[tasks_cmd] Failed to read background task status: %s: %s",
112
113
  type(exc).__name__,
@@ -122,6 +123,7 @@ def _list_tasks(ui: Any) -> bool:
122
123
  _format_status(status),
123
124
  escape(command_display),
124
125
  _format_duration(status.get("duration_ms")),
126
+ _format_duration(status.get("age_ms")),
125
127
  )
126
128
 
127
129
  console.print(
@@ -210,7 +212,8 @@ def _show_task(ui: Any, task_id: str) -> bool:
210
212
  details.add_row("ID", escape(task_id))
211
213
  details.add_row("Status", _format_status(status))
212
214
  details.add_row("Command", escape(status.get("command") or ""))
213
- details.add_row("Duration", _format_duration(status.get("duration_ms")))
215
+ details.add_row("Runtime", _format_duration(status.get("duration_ms")))
216
+ details.add_row("Age", _format_duration(status.get("age_ms")))
214
217
  exit_code = status.get("exit_code")
215
218
  details.add_row("Exit code", str(exit_code) if exit_code is not None else "running")
216
219
 
@@ -0,0 +1,139 @@
1
+ """Theme management command for Ripperdoc.
2
+
3
+ Allows users to list, preview, and switch UI themes.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from rich.markup import escape
9
+ from rich.table import Table
10
+
11
+ from ripperdoc.core.config import get_global_config, save_global_config
12
+ from ripperdoc.core.theme import (
13
+ BUILTIN_THEMES,
14
+ Theme,
15
+ get_theme_manager,
16
+ )
17
+
18
+ from .base import SlashCommand
19
+
20
+
21
+ def _show_current_theme(ui: Any) -> None:
22
+ """Display current theme information."""
23
+ manager = get_theme_manager()
24
+ theme = manager.current
25
+ primary = manager.get_color("primary")
26
+ ui.console.print(f"\nCurrent theme: [bold {primary}]{theme.display_name}[/]")
27
+ ui.console.print(f" {theme.description}")
28
+
29
+
30
+ def _list_themes(ui: Any) -> None:
31
+ """List all available themes."""
32
+ manager = get_theme_manager()
33
+ current_name = manager.current.name
34
+
35
+ ui.console.print("\n[bold]Available Themes:[/bold]")
36
+
37
+ table = Table(show_header=False, box=None, padding=(0, 2))
38
+ table.add_column("Marker", width=2)
39
+ table.add_column("Name", width=16)
40
+ table.add_column("Description")
41
+
42
+ for name, theme in BUILTIN_THEMES.items():
43
+ is_current = name == current_name
44
+ marker = f"[{manager.get_color('primary')}]→[/]" if is_current else " "
45
+ display = (
46
+ f"[bold {manager.get_color('primary')}]{theme.display_name}[/]"
47
+ if is_current
48
+ else theme.display_name
49
+ )
50
+ table.add_row(marker, display, theme.description)
51
+
52
+ ui.console.print(table)
53
+ ui.console.print("\n[dim]Usage: /themes <name> to switch[/dim]")
54
+
55
+
56
+ def _preview_theme(ui: Any, theme: Theme) -> None:
57
+ """Preview a theme's color palette."""
58
+ colors = theme.colors
59
+ ui.console.print(f"\n[bold]Theme Preview:[/bold] {theme.display_name}\n")
60
+
61
+ samples = [
62
+ ("Primary", colors.primary, "Brand color, borders"),
63
+ ("Secondary", colors.secondary, "Success, ready status"),
64
+ ("Error", colors.error, "Error messages"),
65
+ ("Warning", colors.warning, "Warning messages"),
66
+ ("Info", colors.info, "Info messages"),
67
+ ("Tool Call", colors.tool_call, "Tool invocations"),
68
+ ("Spinner", colors.spinner, "Loading indicator"),
69
+ ("Emphasis", colors.emphasis, "Highlighted text"),
70
+ ]
71
+
72
+ for label, color, desc in samples:
73
+ ui.console.print(f" [{color}]■[/] {label}: {desc}")
74
+
75
+
76
+ def _switch_theme(ui: Any, theme_name: str, preview_only: bool = False) -> None:
77
+ """Switch to a different theme."""
78
+ manager = get_theme_manager()
79
+
80
+ if theme_name not in BUILTIN_THEMES:
81
+ ui.console.print(f"[{manager.get_color('error')}]Unknown theme: {escape(theme_name)}[/]")
82
+ available = ", ".join(BUILTIN_THEMES.keys())
83
+ ui.console.print(f"[dim]Available themes: {available}[/dim]")
84
+ return
85
+
86
+ theme = BUILTIN_THEMES[theme_name]
87
+
88
+ if preview_only:
89
+ _preview_theme(ui, theme)
90
+ return
91
+
92
+ # Apply theme
93
+ manager.set_theme(theme_name)
94
+
95
+ # Persist to config
96
+ try:
97
+ config = get_global_config()
98
+ config.theme = theme_name
99
+ save_global_config(config)
100
+ except Exception as e:
101
+ ui.console.print(
102
+ f"[{manager.get_color('warning')}]Theme applied but failed to save: {e}[/]"
103
+ )
104
+ return
105
+
106
+ ui.console.print(f"[{manager.get_color('success')}]✓ Theme switched to {theme.display_name}[/]")
107
+
108
+
109
+ def _handle(ui: Any, arg: str) -> bool:
110
+ """Handle the /themes command."""
111
+ arg = arg.strip().lower()
112
+
113
+ if not arg:
114
+ _show_current_theme(ui)
115
+ _list_themes(ui)
116
+ return True
117
+
118
+ parts = arg.split()
119
+
120
+ if parts[0] == "preview" and len(parts) > 1:
121
+ _switch_theme(ui, parts[1], preview_only=True)
122
+ return True
123
+
124
+ if parts[0] == "list":
125
+ _list_themes(ui)
126
+ return True
127
+
128
+ # Direct theme switch
129
+ _switch_theme(ui, parts[0])
130
+ return True
131
+
132
+
133
+ command = SlashCommand(
134
+ name="themes",
135
+ description="List and switch UI themes",
136
+ handler=_handle,
137
+ )
138
+
139
+ __all__ = ["command"]
@@ -10,6 +10,7 @@ from typing import Any, Iterable, List, Set
10
10
  from prompt_toolkit.completion import Completer, Completion
11
11
 
12
12
  from ripperdoc.utils.path_ignore import should_skip_path, IgnoreFilter
13
+ from ripperdoc.utils.image_utils import is_image_file
13
14
 
14
15
 
15
16
  class FileMentionCompleter(Completer):
@@ -28,9 +29,33 @@ class FileMentionCompleter(Completer):
28
29
  self.project_path = project_path
29
30
  self.ignore_filter = ignore_filter
30
31
 
32
+ def _should_skip_for_completion(self, item: Path) -> bool:
33
+ """Check if an item should be skipped during completion.
34
+
35
+ Image files are never skipped since we support image input.
36
+
37
+ Args:
38
+ item: Path to check
39
+
40
+ Returns:
41
+ True if the item should be skipped
42
+ """
43
+ # Don't skip image files (we support them now)
44
+ if item.is_file() and is_image_file(item):
45
+ return False
46
+ # Use the project's ignore filter for other files
47
+ return should_skip_path(
48
+ item,
49
+ self.project_path,
50
+ ignore_filter=self.ignore_filter,
51
+ skip_hidden=True,
52
+ )
53
+
31
54
  def _collect_files_recursive(self, root_dir: Path, max_depth: int = 5) -> List[Path]:
32
55
  """Recursively collect all files from root_dir, respecting ignore rules.
33
56
 
57
+ Note: Image files are NOT filtered out since we support image input.
58
+
34
59
  Args:
35
60
  root_dir: Directory to search from
36
61
  max_depth: Maximum directory depth to search
@@ -46,13 +71,13 @@ class FileMentionCompleter(Completer):
46
71
 
47
72
  try:
48
73
  for item in current_dir.iterdir():
49
- # Use the project's ignore filter to skip files
50
- if should_skip_path(
51
- item,
52
- self.project_path,
53
- ignore_filter=self.ignore_filter,
54
- skip_hidden=True,
55
- ):
74
+ # Don't skip image files (we support them now)
75
+ if item.is_file() and is_image_file(item):
76
+ files.append(item)
77
+ continue
78
+
79
+ # Use the project's ignore filter to skip other files
80
+ if self._should_skip_for_completion(item):
56
81
  continue
57
82
 
58
83
  if item.is_file():
@@ -122,7 +147,12 @@ class FileMentionCompleter(Completer):
122
147
  display_path += "/"
123
148
 
124
149
  # Right side: show type only
125
- meta = "📁 directory" if item.is_dir() else "📄 file"
150
+ meta = "📁 directory"
151
+ if item.is_file():
152
+ if is_image_file(item):
153
+ meta = "🖼️ image"
154
+ else:
155
+ meta = "📄 file"
126
156
 
127
157
  _add_match(display_path, item, meta, 0)
128
158
  except ValueError:
@@ -152,7 +182,12 @@ class FileMentionCompleter(Completer):
152
182
  display_path += "/"
153
183
 
154
184
  # Right side: show type only
155
- meta = "📁 directory" if item.is_dir() else "📄 file"
185
+ meta = "📁 directory"
186
+ if item.is_file():
187
+ if is_image_file(item):
188
+ meta = "🖼️ image"
189
+ else:
190
+ meta = "📄 file"
156
191
 
157
192
  _add_match(display_path, item, meta, 0)
158
193
  except ValueError:
@@ -177,7 +212,12 @@ class FileMentionCompleter(Completer):
177
212
  display_path += "/"
178
213
 
179
214
  # Right side: show type only
180
- meta = "📁 directory" if item.is_dir() else "📄 file"
215
+ meta = "📁 directory"
216
+ if item.is_file():
217
+ if is_image_file(item):
218
+ meta = "🖼️ image"
219
+ else:
220
+ meta = "📄 file"
181
221
  _add_match(display_path, item, meta, 0)
182
222
  except ValueError:
183
223
  continue
@@ -206,7 +246,12 @@ class FileMentionCompleter(Completer):
206
246
  if item.is_dir():
207
247
  display_path += "/"
208
248
 
209
- meta = "📁 directory" if item.is_dir() else "📄 file"
249
+ meta = "📁 directory"
250
+ if item.is_file():
251
+ if is_image_file(item):
252
+ meta = "🖼️ image"
253
+ else:
254
+ meta = "📄 file"
210
255
  _add_match(display_path, item, meta, score)
211
256
 
212
257
  # If the query exactly matches a directory, also surface its children for quicker drilling
@@ -226,7 +271,12 @@ class FileMentionCompleter(Completer):
226
271
  display_path = str(rel_path)
227
272
  if item.is_dir():
228
273
  display_path += "/"
229
- meta = "📁 directory" if item.is_dir() else "📄 file"
274
+ meta = "📁 directory"
275
+ if item.is_file():
276
+ if is_image_file(item):
277
+ meta = "🖼️ image"
278
+ else:
279
+ meta = "📄 file"
230
280
  _add_match(display_path, item, meta, 400)
231
281
  except ValueError:
232
282
  continue
@@ -267,7 +317,7 @@ class FileMentionCompleter(Completer):
267
317
  for display_path, item, meta, score in matches:
268
318
  yield Completion(
269
319
  display_path,
270
- start_position=-(len(query) + 1), # +1 to include the @ symbol
320
+ start_position=-len(query),
271
321
  display=display_path,
272
322
  display_meta=meta,
273
323
  )
@@ -3,7 +3,7 @@
3
3
  import random
4
4
  from typing import List, Optional
5
5
 
6
- from ripperdoc.core.config import get_current_model_profile, get_global_config, ModelProfile
6
+ from ripperdoc.core.config import get_effective_model_profile, get_global_config, ModelProfile
7
7
 
8
8
 
9
9
  # Fun words to display while the AI is "thinking"
@@ -103,8 +103,11 @@ def get_random_thinking_word() -> str:
103
103
 
104
104
 
105
105
  def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
106
- """Return the configured ModelProfile for a logical pointer or default."""
107
- profile = get_current_model_profile(pointer)
106
+ """Return the configured ModelProfile for a logical pointer or default.
107
+
108
+ 此函数现在尊重 RIPPERDOC_* 环境变量的覆盖。
109
+ """
110
+ profile = get_effective_model_profile(pointer)
108
111
  if profile:
109
112
  return profile
110
113
  config = get_global_config()
@@ -0,0 +1,233 @@
1
+ """ESC key interrupt listener for the Rich UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import threading
8
+ import time
9
+ from typing import Any, Callable, Optional
10
+
11
+ from ripperdoc.utils.log import get_logger
12
+
13
+ if os.name != "nt":
14
+ import select
15
+ import termios
16
+ import tty
17
+
18
+
19
+ class EscInterruptListener:
20
+ """Listen for ESC keypresses in a background thread and invoke a callback."""
21
+
22
+ def __init__(self, on_interrupt: Callable[[], None], *, logger: Optional[Any] = None) -> None:
23
+ self._on_interrupt = on_interrupt
24
+ self._logger = logger or get_logger()
25
+ self._thread: Optional[threading.Thread] = None
26
+ self._stop_event = threading.Event()
27
+ self._lock = threading.Lock()
28
+ self._pause_depth = 0
29
+ self._interrupt_sent = False
30
+ self._fd: Optional[int] = None
31
+ self._owns_fd = False
32
+ self._orig_termios = None
33
+ self._cbreak_active = False
34
+ self._availability_checked = False
35
+ self._available = True
36
+
37
+ @property
38
+ def is_running(self) -> bool:
39
+ return self._thread is not None and self._thread.is_alive()
40
+
41
+ def start(self) -> None:
42
+ if self.is_running or not self._available:
43
+ return
44
+ if os.name != "nt" and not self._setup_posix_input():
45
+ return
46
+ self._stop_event.clear()
47
+ with self._lock:
48
+ self._pause_depth = 0
49
+ self._interrupt_sent = False
50
+ self._thread = threading.Thread(
51
+ target=self._run,
52
+ name="ripperdoc-esc-listener",
53
+ daemon=True,
54
+ )
55
+ self._thread.start()
56
+
57
+ def stop(self) -> None:
58
+ self._stop_event.set()
59
+ if self._thread is not None:
60
+ self._thread.join(timeout=0.25)
61
+ self._thread = None
62
+ if os.name != "nt":
63
+ self._restore_posix_input()
64
+
65
+ def pause(self) -> None:
66
+ if os.name == "nt":
67
+ return
68
+ with self._lock:
69
+ self._pause_depth += 1
70
+ if self._pause_depth == 1:
71
+ self._restore_termios_locked()
72
+
73
+ def resume(self) -> None:
74
+ if os.name == "nt":
75
+ return
76
+ with self._lock:
77
+ if self._pause_depth == 0:
78
+ return
79
+ self._pause_depth -= 1
80
+ if self._pause_depth == 0:
81
+ self._apply_cbreak_locked()
82
+
83
+ def _run(self) -> None:
84
+ if os.name == "nt":
85
+ self._run_windows()
86
+ else:
87
+ self._run_posix()
88
+
89
+ def _run_windows(self) -> None:
90
+ import msvcrt
91
+
92
+ while not self._stop_event.is_set():
93
+ with self._lock:
94
+ paused = self._pause_depth > 0
95
+ if paused:
96
+ time.sleep(0.05)
97
+ continue
98
+ if msvcrt.kbhit():
99
+ ch = msvcrt.getwch()
100
+ if ch == "\x1b":
101
+ self._signal_interrupt()
102
+ time.sleep(0.02)
103
+
104
+ def _run_posix(self) -> None:
105
+ while not self._stop_event.is_set():
106
+ with self._lock:
107
+ paused = self._pause_depth > 0
108
+ fd = self._fd
109
+ if paused or fd is None:
110
+ time.sleep(0.05)
111
+ continue
112
+ try:
113
+ readable, _, _ = select.select([fd], [], [], 0.1)
114
+ except (OSError, ValueError):
115
+ time.sleep(0.05)
116
+ continue
117
+ if not readable:
118
+ continue
119
+ try:
120
+ ch = os.read(fd, 1)
121
+ except OSError:
122
+ continue
123
+ if ch == b"\x1b":
124
+ if self._is_escape_sequence(fd):
125
+ continue
126
+ self._signal_interrupt()
127
+
128
+ def _is_escape_sequence(self, fd: int) -> bool:
129
+ try:
130
+ readable, _, _ = select.select([fd], [], [], 0.02)
131
+ except (OSError, ValueError):
132
+ return False
133
+ if not readable:
134
+ return False
135
+ self._drain_pending_bytes(fd)
136
+ return True
137
+
138
+ def _drain_pending_bytes(self, fd: int) -> None:
139
+ while True:
140
+ try:
141
+ readable, _, _ = select.select([fd], [], [], 0)
142
+ except (OSError, ValueError):
143
+ return
144
+ if not readable:
145
+ return
146
+ try:
147
+ os.read(fd, 32)
148
+ except OSError:
149
+ return
150
+
151
+ def _signal_interrupt(self) -> None:
152
+ with self._lock:
153
+ if self._interrupt_sent:
154
+ return
155
+ self._interrupt_sent = True
156
+ try:
157
+ self._on_interrupt()
158
+ except (RuntimeError, ValueError, OSError) as exc:
159
+ self._logger.debug(
160
+ "[ui] ESC interrupt callback failed: %s: %s",
161
+ type(exc).__name__,
162
+ exc,
163
+ )
164
+
165
+ def _setup_posix_input(self) -> bool:
166
+ if self._fd is not None:
167
+ return True
168
+ fd: Optional[int] = None
169
+ owns = False
170
+ try:
171
+ if sys.stdin.isatty():
172
+ fd = sys.stdin.fileno()
173
+ elif os.path.exists("/dev/tty"):
174
+ fd = os.open("/dev/tty", os.O_RDONLY)
175
+ owns = True
176
+ except OSError as exc:
177
+ self._disable_listener(f"input error: {exc}")
178
+ return False
179
+ if fd is None:
180
+ self._disable_listener("no TTY available")
181
+ return False
182
+ try:
183
+ self._orig_termios = termios.tcgetattr(fd)
184
+ except (termios.error, OSError) as exc:
185
+ if owns:
186
+ try:
187
+ os.close(fd)
188
+ except OSError:
189
+ pass
190
+ self._disable_listener(f"termios unavailable: {exc}")
191
+ return False
192
+ self._fd = fd
193
+ self._owns_fd = owns
194
+ self._apply_cbreak_locked()
195
+ return True
196
+
197
+ def _restore_posix_input(self) -> None:
198
+ with self._lock:
199
+ self._restore_termios_locked()
200
+ if self._fd is not None and self._owns_fd:
201
+ try:
202
+ os.close(self._fd)
203
+ except OSError:
204
+ pass
205
+ self._fd = None
206
+ self._owns_fd = False
207
+ self._orig_termios = None
208
+ self._cbreak_active = False
209
+
210
+ def _apply_cbreak_locked(self) -> None:
211
+ if self._fd is None or self._orig_termios is None or self._cbreak_active:
212
+ return
213
+ try:
214
+ tty.setcbreak(self._fd)
215
+ self._cbreak_active = True
216
+ except (termios.error, OSError):
217
+ self._disable_listener("failed to enter cbreak mode")
218
+
219
+ def _restore_termios_locked(self) -> None:
220
+ if self._fd is None or self._orig_termios is None or not self._cbreak_active:
221
+ return
222
+ try:
223
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._orig_termios)
224
+ except (termios.error, OSError):
225
+ pass
226
+ self._cbreak_active = False
227
+
228
+ def _disable_listener(self, reason: str) -> None:
229
+ if self._availability_checked:
230
+ return
231
+ self._availability_checked = True
232
+ self._available = False
233
+ self._logger.debug("[ui] ESC interrupt listener disabled: %s", reason)
@@ -218,6 +218,13 @@ class MessageDisplay:
218
218
  if preview:
219
219
  self.console.print(f"[dim italic]Thinking: {escape(preview)}[/]")
220
220
 
221
+ def print_interrupt_notice(self) -> None:
222
+ """Display an interrupt notice when the user cancels with ESC."""
223
+ self.console.print(
224
+ "\n[red]■ Conversation interrupted[/red] · "
225
+ "[dim]Tell the model what to do differently.[/dim]"
226
+ )
227
+
221
228
 
222
229
  def parse_bash_output_sections(content: str) -> Tuple[List[str], List[str]]:
223
230
  """Parse stdout/stderr sections from a bash output text block."""
@@ -13,23 +13,27 @@ from rich import box
13
13
 
14
14
  from ripperdoc import __version__
15
15
  from ripperdoc.cli.ui.helpers import get_profile_for_pointer
16
+ from ripperdoc.core.theme import theme_color
16
17
 
17
18
 
18
19
  def create_welcome_panel() -> Panel:
19
20
  """Create a welcome panel for the CLI startup."""
20
- welcome_content = """
21
- [bold cyan]Welcome to Ripperdoc![/bold cyan]
21
+ primary = theme_color("primary")
22
+ muted = theme_color("text_secondary")
23
+
24
+ welcome_content = f"""
25
+ [bold {primary}]Welcome to Ripperdoc![/bold {primary}]
22
26
 
23
27
  Ripperdoc is an AI-powered coding assistant that helps with software development tasks.
24
28
  You can read files, edit code, run commands, and help with various programming tasks.
25
29
 
26
- [dim]Type your questions below. Press Ctrl+C to exit.[/dim]
30
+ [{muted}]Type your questions below. Press Ctrl+C twice to exit.[/{muted}]
27
31
  """
28
32
 
29
33
  return Panel(
30
34
  welcome_content,
31
35
  title=f"Ripperdoc v{__version__}",
32
- border_style="cyan",
36
+ border_style=theme_color("border"),
33
37
  box=box.ROUNDED,
34
38
  padding=(1, 2),
35
39
  )
@@ -41,11 +45,11 @@ def create_status_bar() -> Text:
41
45
  model_name = profile.model if profile else "Not configured"
42
46
 
43
47
  status_text = Text()
44
- status_text.append("Ripperdoc", style="bold cyan")
48
+ status_text.append("Ripperdoc", style=f"bold {theme_color('primary')}")
45
49
  status_text.append(" • ")
46
- status_text.append(model_name, style="dim")
50
+ status_text.append(model_name, style=theme_color("text_secondary"))
47
51
  status_text.append(" • ")
48
- status_text.append("Ready", style="green")
52
+ status_text.append("Ready", style=theme_color("secondary"))
49
53
 
50
54
  return status_text
51
55
 
@@ -57,7 +61,8 @@ def print_shortcuts(console: Console) -> None:
57
61
  ("/ for commands", "@ for file mention"),
58
62
  ("Alt+Enter for newline", "Enter to submit"),
59
63
  ]
60
- console.print("[dim]Shortcuts[/dim]")
64
+ muted = theme_color("text_secondary")
65
+ console.print(f"[{muted}]Shortcuts[/{muted}]")
61
66
  for left, right in pairs:
62
67
  left_text = f" {left}".ljust(32)
63
68
  right_text = f"{right}" if right else ""