aloop 0.1.0__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.

Potentially problematic release.


This version of aloop might be problematic. Click here for more details.

Files changed (62) hide show
  1. agent/__init__.py +0 -0
  2. agent/agent.py +182 -0
  3. agent/base.py +406 -0
  4. agent/context.py +126 -0
  5. agent/todo.py +149 -0
  6. agent/tool_executor.py +54 -0
  7. agent/verification.py +135 -0
  8. aloop-0.1.0.dist-info/METADATA +246 -0
  9. aloop-0.1.0.dist-info/RECORD +62 -0
  10. aloop-0.1.0.dist-info/WHEEL +5 -0
  11. aloop-0.1.0.dist-info/entry_points.txt +2 -0
  12. aloop-0.1.0.dist-info/licenses/LICENSE +21 -0
  13. aloop-0.1.0.dist-info/top_level.txt +9 -0
  14. cli.py +19 -0
  15. config.py +146 -0
  16. interactive.py +865 -0
  17. llm/__init__.py +51 -0
  18. llm/base.py +26 -0
  19. llm/compat.py +226 -0
  20. llm/content_utils.py +309 -0
  21. llm/litellm_adapter.py +450 -0
  22. llm/message_types.py +245 -0
  23. llm/model_manager.py +265 -0
  24. llm/retry.py +95 -0
  25. main.py +246 -0
  26. memory/__init__.py +20 -0
  27. memory/compressor.py +554 -0
  28. memory/manager.py +538 -0
  29. memory/serialization.py +82 -0
  30. memory/short_term.py +88 -0
  31. memory/token_tracker.py +203 -0
  32. memory/types.py +51 -0
  33. tools/__init__.py +6 -0
  34. tools/advanced_file_ops.py +557 -0
  35. tools/base.py +51 -0
  36. tools/calculator.py +50 -0
  37. tools/code_navigator.py +975 -0
  38. tools/explore.py +254 -0
  39. tools/file_ops.py +150 -0
  40. tools/git_tools.py +791 -0
  41. tools/notify.py +69 -0
  42. tools/parallel_execute.py +420 -0
  43. tools/session_manager.py +205 -0
  44. tools/shell.py +147 -0
  45. tools/shell_background.py +470 -0
  46. tools/smart_edit.py +491 -0
  47. tools/todo.py +130 -0
  48. tools/web_fetch.py +673 -0
  49. tools/web_search.py +61 -0
  50. utils/__init__.py +15 -0
  51. utils/logger.py +105 -0
  52. utils/model_pricing.py +49 -0
  53. utils/runtime.py +75 -0
  54. utils/terminal_ui.py +422 -0
  55. utils/tui/__init__.py +39 -0
  56. utils/tui/command_registry.py +49 -0
  57. utils/tui/components.py +306 -0
  58. utils/tui/input_handler.py +393 -0
  59. utils/tui/model_ui.py +204 -0
  60. utils/tui/progress.py +292 -0
  61. utils/tui/status_bar.py +178 -0
  62. utils/tui/theme.py +165 -0
@@ -0,0 +1,306 @@
1
+ """Reusable UI components for the TUI."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from rich import box
6
+ from rich.console import Console
7
+ from rich.markdown import Markdown
8
+ from rich.panel import Panel
9
+ from rich.text import Text
10
+
11
+ from utils.tui.theme import Theme
12
+
13
+
14
+ class Divider:
15
+ """A simple horizontal divider."""
16
+
17
+ def __init__(self, width: int = 60):
18
+ """Initialize divider.
19
+
20
+ Args:
21
+ width: Width of the divider in characters
22
+ """
23
+ self.width = width
24
+
25
+ def render(self, console: Console) -> None:
26
+ """Render the divider to the console."""
27
+ colors = Theme.get_colors()
28
+ console.print(Text("─" * self.width, style=colors.text_muted))
29
+
30
+
31
+ class MessageDisplay:
32
+ """Display messages in Claude Code style - clean and minimal."""
33
+
34
+ def __init__(self, console: Console):
35
+ """Initialize message display.
36
+
37
+ Args:
38
+ console: Rich console instance
39
+ """
40
+ self.console = console
41
+
42
+ def user_message(self, message: str) -> None:
43
+ """Display a user message with > prefix.
44
+
45
+ Args:
46
+ message: User message text
47
+ """
48
+ colors = Theme.get_colors()
49
+ # User input with cyan > prefix
50
+ prefix = Text("> ", style=f"bold {colors.user_input}")
51
+ content = Text(message, style=colors.user_input)
52
+ self.console.print(Text.assemble(prefix, content))
53
+ self.console.print()
54
+
55
+ def assistant_message(self, message: str, use_markdown: bool = True) -> None:
56
+ """Display an assistant message.
57
+
58
+ Args:
59
+ message: Assistant message text
60
+ use_markdown: Whether to render as markdown
61
+ """
62
+ colors = Theme.get_colors()
63
+ if use_markdown:
64
+ md = Markdown(message)
65
+ self.console.print(md)
66
+ else:
67
+ self.console.print(Text(message, style=colors.assistant_output))
68
+ self.console.print()
69
+
70
+ def turn_divider(self, turn_number: Optional[int] = None) -> None:
71
+ """Display a divider between conversation turns.
72
+
73
+ Args:
74
+ turn_number: Optional turn number to display
75
+ """
76
+ colors = Theme.get_colors()
77
+ if turn_number is not None:
78
+ # Divider with turn number
79
+ left_line = "─" * 25
80
+ right_line = "─" * 25
81
+ turn_text = f" Turn {turn_number} "
82
+ self.console.print(Text(f"{left_line}{turn_text}{right_line}", style=colors.text_muted))
83
+ else:
84
+ # Simple divider
85
+ self.console.print(Text("─" * 60, style=colors.text_muted))
86
+ self.console.print()
87
+
88
+
89
+ class ToolCallDisplay:
90
+ """Display tool calls with a clean, professional look."""
91
+
92
+ def __init__(self, console: Console):
93
+ """Initialize tool call display.
94
+
95
+ Args:
96
+ console: Rich console instance
97
+ """
98
+ self.console = console
99
+
100
+ def show_call(
101
+ self,
102
+ tool_name: str,
103
+ arguments: Dict[str, Any],
104
+ result: Optional[str] = None,
105
+ success: bool = True,
106
+ duration: Optional[float] = None,
107
+ size: Optional[str] = None,
108
+ ) -> None:
109
+ """Display a tool call with its arguments and optional result.
110
+
111
+ Args:
112
+ tool_name: Name of the tool
113
+ arguments: Tool arguments
114
+ result: Optional result summary
115
+ success: Whether the call succeeded
116
+ duration: Optional duration in seconds
117
+ size: Optional result size string
118
+ """
119
+ colors = Theme.get_colors()
120
+
121
+ # Build content
122
+ lines = []
123
+
124
+ # Arguments
125
+ for key, value in arguments.items():
126
+ value_str = str(value)
127
+ if len(value_str) > 60:
128
+ value_str = value_str[:57] + "..."
129
+ lines.append(f" [dim]{key}:[/dim] {value_str}")
130
+
131
+ # Status line if result provided
132
+ if result is not None:
133
+ lines.append(" " + "─" * 50)
134
+ status_icon = "✓" if success else "✗"
135
+ status_color = colors.success if success else colors.error
136
+ status_parts = [f"[{status_color}]{status_icon}[/{status_color}]"]
137
+ status_parts.append("Success" if success else "Failed")
138
+ if size:
139
+ status_parts.append(f"({size}")
140
+ if duration:
141
+ status_parts[-1] += f", {duration:.1f}s)"
142
+ else:
143
+ status_parts[-1] += ")"
144
+ elif duration:
145
+ status_parts.append(f"({duration:.1f}s)")
146
+ lines.append(" " + " ".join(status_parts))
147
+
148
+ content = "\n".join(lines) if lines else ""
149
+
150
+ # Create panel
151
+ panel = Panel(
152
+ content,
153
+ title=f"[{colors.tool_accent}]Tool: {tool_name}[/{colors.tool_accent}]",
154
+ title_align="left",
155
+ border_style=colors.text_muted,
156
+ box=box.ROUNDED,
157
+ padding=(0, 1),
158
+ )
159
+ self.console.print(panel)
160
+
161
+
162
+ class ThinkingDisplay:
163
+ """Display thinking/reasoning content."""
164
+
165
+ def __init__(self, console: Console, max_preview: int = 300):
166
+ """Initialize thinking display.
167
+
168
+ Args:
169
+ console: Rich console instance
170
+ max_preview: Maximum characters to show in preview
171
+ """
172
+ self.console = console
173
+ self.max_preview = max_preview
174
+
175
+ def show(
176
+ self,
177
+ thinking: str,
178
+ duration: Optional[float] = None,
179
+ expanded: bool = False,
180
+ ) -> None:
181
+ """Display thinking content.
182
+
183
+ Args:
184
+ thinking: Thinking text
185
+ duration: Optional duration in seconds
186
+ expanded: Whether to show full content or preview
187
+ """
188
+ if not thinking:
189
+ return
190
+
191
+ colors = Theme.get_colors()
192
+
193
+ # Truncate if not expanded
194
+ if not expanded and len(thinking) > self.max_preview:
195
+ display_text = thinking[: self.max_preview]
196
+ display_text += f"\n[dim]... ({len(thinking) - self.max_preview} more chars)[/dim]"
197
+ else:
198
+ display_text = thinking
199
+
200
+ # Build title
201
+ title = f"[{colors.thinking_accent}]Thinking[/{colors.thinking_accent}]"
202
+
203
+ # Build subtitle with duration
204
+ subtitle = None
205
+ if duration:
206
+ subtitle = f"[dim]Duration: {duration:.1f}s[/dim]"
207
+
208
+ panel = Panel(
209
+ display_text,
210
+ title=title,
211
+ subtitle=subtitle,
212
+ title_align="left",
213
+ subtitle_align="right",
214
+ border_style=colors.text_muted,
215
+ box=box.ROUNDED,
216
+ padding=(0, 1),
217
+ )
218
+ self.console.print(panel)
219
+
220
+
221
+ class MemoryStatsDisplay:
222
+ """Display memory statistics with visual progress bars."""
223
+
224
+ def __init__(self, console: Console):
225
+ """Initialize memory stats display.
226
+
227
+ Args:
228
+ console: Rich console instance
229
+ """
230
+ self.console = console
231
+
232
+ def _make_progress_bar(
233
+ self,
234
+ current: int,
235
+ total: int,
236
+ width: int = 16,
237
+ filled_char: str = "█",
238
+ empty_char: str = "░",
239
+ ) -> str:
240
+ """Create a text-based progress bar.
241
+
242
+ Args:
243
+ current: Current value
244
+ total: Maximum value
245
+ width: Width in characters
246
+ filled_char: Character for filled portion
247
+ empty_char: Character for empty portion
248
+
249
+ Returns:
250
+ Progress bar string
251
+ """
252
+ ratio = 0 if total == 0 else min(current / total, 1.0)
253
+ filled = int(width * ratio)
254
+ empty = width - filled
255
+ return filled_char * filled + empty_char * empty
256
+
257
+ def show(self, stats: Dict[str, Any], context_limit: int = 60000) -> None:
258
+ """Display memory statistics.
259
+
260
+ Args:
261
+ stats: Statistics dictionary from memory manager
262
+ context_limit: Maximum context window size
263
+ """
264
+ colors = Theme.get_colors()
265
+
266
+ # Calculate values
267
+ input_tokens = stats.get("total_input_tokens", 0)
268
+ output_tokens = stats.get("total_output_tokens", 0)
269
+ current_tokens = stats.get("current_tokens", 0)
270
+ compression_count = stats.get("compression_count", 0)
271
+ net_savings = stats.get("net_savings", 0)
272
+ total_cost = stats.get("total_cost", 0)
273
+
274
+ # Build content
275
+ lines = []
276
+ lines.append(" [bold]Tokens[/bold]")
277
+
278
+ # Input tokens bar
279
+ input_bar = self._make_progress_bar(input_tokens, context_limit)
280
+ lines.append(f" ├─ Input: {input_bar} {input_tokens:,}")
281
+
282
+ # Output tokens bar
283
+ output_bar = self._make_progress_bar(output_tokens, context_limit)
284
+ lines.append(f" ├─ Output: {output_bar} {output_tokens:,}")
285
+
286
+ # Context usage
287
+ lines.append(f" └─ Context: {current_tokens:,} / {context_limit // 1000}K")
288
+ lines.append("")
289
+
290
+ # Summary line
291
+ savings_str = f"+{net_savings:,}" if net_savings >= 0 else f"{net_savings:,}"
292
+ lines.append(
293
+ f" Cost: ${total_cost:.4f} │ Compressions: {compression_count} │ Saved: {savings_str}"
294
+ )
295
+
296
+ content = "\n".join(lines)
297
+
298
+ panel = Panel(
299
+ content,
300
+ title=f"[{colors.primary}]Memory Statistics[/{colors.primary}]",
301
+ title_align="left",
302
+ border_style=colors.text_muted,
303
+ box=box.ROUNDED,
304
+ padding=(0, 1),
305
+ )
306
+ self.console.print(panel)
@@ -0,0 +1,393 @@
1
+ """Enhanced input handling with auto-completion and keyboard shortcuts."""
2
+
3
+ from typing import Callable, List, Optional
4
+
5
+ from prompt_toolkit import PromptSession
6
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
7
+ from prompt_toolkit.history import FileHistory, InMemoryHistory
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.keys import Keys
10
+ from prompt_toolkit.styles import Style
11
+
12
+ from utils.tui.command_registry import CommandRegistry
13
+ from utils.tui.theme import Theme
14
+
15
+
16
+ def _normalize_command_tree(
17
+ commands: list[str] | None,
18
+ command_subcommands: dict[str, dict[str, str]] | None,
19
+ ) -> tuple[list[str], dict[str, dict[str, str]]]:
20
+ top_commands: list[str] = []
21
+ seen_top: set[str] = set()
22
+ subcommands: dict[str, dict[str, str]] = {}
23
+
24
+ def _add_top(cmd: str) -> None:
25
+ if cmd in seen_top:
26
+ return
27
+ seen_top.add(cmd)
28
+ top_commands.append(cmd)
29
+
30
+ for raw in commands or []:
31
+ parts = [p for p in raw.strip().split(" ") if p]
32
+ if not parts:
33
+ continue
34
+ top = parts[0]
35
+ _add_top(top)
36
+ if len(parts) > 1:
37
+ sub = " ".join(parts[1:])
38
+ subcommands.setdefault(top, {}).setdefault(sub, "")
39
+
40
+ for top, subs in (command_subcommands or {}).items():
41
+ _add_top(top)
42
+ subcommands.setdefault(top, {}).update(subs)
43
+
44
+ return top_commands, subcommands
45
+
46
+
47
+ class CommandCompleter(Completer):
48
+ """Auto-completer for slash commands and file paths."""
49
+
50
+ def __init__(
51
+ self,
52
+ commands: Optional[List[str]] = None,
53
+ help_texts: Optional[dict[str, str]] = None,
54
+ command_subcommands: Optional[dict[str, dict[str, str]]] = None,
55
+ display_texts: Optional[dict[str, str]] = None,
56
+ ):
57
+ """Initialize completer.
58
+
59
+ Args:
60
+ commands: List of available commands (without leading /)
61
+ help_texts: Optional help text per command (same keys as commands)
62
+ command_subcommands: Optional mapping like {"model": {"edit": "..."}}.
63
+ display_texts: Optional display text (with leading / and args hints).
64
+ """
65
+ default_commands = [
66
+ "help",
67
+ "reset",
68
+ "stats",
69
+ "resume",
70
+ "theme",
71
+ "verbose",
72
+ "compact",
73
+ "model",
74
+ "exit",
75
+ "quit",
76
+ ]
77
+ self.commands, self.command_subcommands = _normalize_command_tree(
78
+ commands or default_commands,
79
+ command_subcommands,
80
+ )
81
+ self.help_texts = help_texts or {}
82
+ self.display_texts = display_texts or {}
83
+
84
+ def get_completions(self, document, complete_event):
85
+ """Get completions for the current input.
86
+
87
+ Args:
88
+ document: Current document
89
+ complete_event: Completion event
90
+
91
+ Yields:
92
+ Completion objects
93
+ """
94
+ text = document.text_before_cursor
95
+
96
+ # Complete commands starting with /
97
+ if text.startswith("/"):
98
+ cmd_text = text[1:] # Remove leading /
99
+ if " " in cmd_text:
100
+ base, _, rest = cmd_text.partition(" ")
101
+ if base in self.command_subcommands and " " not in rest:
102
+ matching = [
103
+ sub for sub in self.command_subcommands[base] if sub.startswith(rest)
104
+ ]
105
+ for sub in matching:
106
+ key = f"{base} {sub}".strip()
107
+ display = self.display_texts.get(key, f"/{base} {sub}")
108
+ yield Completion(
109
+ sub,
110
+ start_position=-len(rest),
111
+ display=display,
112
+ display_meta=self._get_command_help(key),
113
+ )
114
+ return
115
+
116
+ matching_commands = [cmd for cmd in self.commands if cmd.startswith(cmd_text)]
117
+ for cmd in matching_commands:
118
+ yield Completion(
119
+ cmd,
120
+ start_position=-len(cmd_text),
121
+ display=self.display_texts.get(cmd, f"/{cmd}"),
122
+ display_meta=self._get_command_help(cmd),
123
+ )
124
+
125
+ def _get_command_help(self, cmd: str) -> str:
126
+ """Get help text for a command.
127
+
128
+ Args:
129
+ cmd: Command name
130
+
131
+ Returns:
132
+ Help text
133
+ """
134
+ help_texts = {
135
+ "help": "Show available commands",
136
+ "reset": "Clear conversation memory",
137
+ "stats": "Show token/memory stats",
138
+ "resume": "List and resume a previous session",
139
+ "theme": "Switch color theme",
140
+ "verbose": "Toggle verbose output",
141
+ "compact": "Compress conversation memory",
142
+ "model": "Manage models",
143
+ "model edit": "Edit `.aloop/models.yaml` (auto-reload on save)",
144
+ "exit": "Exit interactive mode",
145
+ "quit": "Same as /exit",
146
+ }
147
+ if cmd in self.help_texts:
148
+ return self.help_texts[cmd]
149
+ if " " in cmd:
150
+ base, _, rest = cmd.partition(" ")
151
+ sub_help = self.command_subcommands.get(base, {}).get(rest)
152
+ if sub_help:
153
+ return sub_help
154
+ return help_texts.get(cmd, "")
155
+
156
+
157
+ class InputHandler:
158
+ """Enhanced input handler with completion, history, and shortcuts."""
159
+
160
+ def __init__(
161
+ self,
162
+ history_file: Optional[str] = None,
163
+ commands: Optional[List[str]] = None,
164
+ command_help: Optional[dict[str, str]] = None,
165
+ command_subcommands: Optional[dict[str, dict[str, str]]] = None,
166
+ command_registry: CommandRegistry | None = None,
167
+ ):
168
+ """Initialize input handler.
169
+
170
+ Args:
171
+ history_file: Path to history file (None for in-memory)
172
+ commands: List of available commands
173
+ """
174
+ # Set up history
175
+ history = FileHistory(history_file) if history_file else InMemoryHistory()
176
+
177
+ display_texts: dict[str, str] | None = None
178
+ if command_registry is not None:
179
+ commands = [c.name for c in command_registry.commands]
180
+ command_help = command_registry.to_help_map()
181
+ command_subcommands = command_registry.to_subcommand_map()
182
+ display_texts = command_registry.to_display_map()
183
+
184
+ # Set up completer
185
+ self.completer = CommandCompleter(
186
+ commands,
187
+ help_texts=command_help,
188
+ command_subcommands=command_subcommands,
189
+ display_texts=display_texts,
190
+ )
191
+
192
+ # Set up key bindings
193
+ self.key_bindings = self._create_key_bindings()
194
+
195
+ # Callback handlers
196
+ self._on_clear_screen: Optional[Callable[[], None]] = None
197
+ self._on_toggle_thinking: Optional[Callable[[], None]] = None
198
+ self._on_show_stats: Optional[Callable[[], None]] = None
199
+
200
+ def bottom_toolbar():
201
+ text = self.session.default_buffer.text
202
+ suggestions = self._get_command_suggestions(text)
203
+ if not suggestions:
204
+ return ""
205
+
206
+ fragments: list[tuple[str, str]] = []
207
+ fragments.append(("class:toolbar.hint", "Commands: "))
208
+ for i, (display, help_text) in enumerate(suggestions[:6]):
209
+ if i:
210
+ fragments.append(("class:toolbar.hint", " "))
211
+ fragments.append(("class:toolbar.cmd", display))
212
+ if help_text:
213
+ fragments.append(("class:toolbar.hint", f" — {help_text}"))
214
+ return fragments
215
+
216
+ # Create prompt session with auto-complete while typing.
217
+ # The completer itself is responsible for returning results only for slash commands.
218
+ self.session: PromptSession = PromptSession(
219
+ history=history,
220
+ completer=self.completer,
221
+ key_bindings=self.key_bindings,
222
+ complete_while_typing=True,
223
+ enable_history_search=True,
224
+ bottom_toolbar=bottom_toolbar,
225
+ )
226
+
227
+ def _on_text_insert(_buffer) -> None:
228
+ # Best-effort: show completion menu right after typing "/" at the beginning.
229
+ buf = self.session.default_buffer
230
+ if buf.text == "/" and buf.cursor_position == 1:
231
+ buf.start_completion(
232
+ select_first=False,
233
+ complete_event=CompleteEvent(text_inserted=True),
234
+ )
235
+
236
+ self.session.default_buffer.on_text_insert += _on_text_insert
237
+
238
+ def _on_text_changed(_buffer) -> None:
239
+ # Codex-style: when the input starts with "/", keep the completion menu in sync
240
+ # with every keystroke. (Some terminals don't refresh completion state reliably
241
+ # unless we explicitly trigger it.)
242
+ buf = self.session.default_buffer
243
+ if buf.text.startswith("/"):
244
+ buf.start_completion(
245
+ select_first=False,
246
+ complete_event=CompleteEvent(text_inserted=True),
247
+ )
248
+ else:
249
+ buf.cancel_completion()
250
+
251
+ self.session.default_buffer.on_text_changed += _on_text_changed
252
+
253
+ def _get_command_suggestions(self, text: str) -> list[tuple[str, str]]:
254
+ if not text.startswith("/"):
255
+ return []
256
+ cmd_text = text[1:]
257
+ if " " in cmd_text:
258
+ base, _, rest = cmd_text.partition(" ")
259
+ if base in self.completer.command_subcommands and " " not in rest:
260
+ matches = [
261
+ f"{base} {sub}"
262
+ for sub in self.completer.command_subcommands[base]
263
+ if sub.startswith(rest)
264
+ ]
265
+ return [
266
+ (
267
+ self.completer.display_texts.get(cmd, f"/{cmd}"),
268
+ self.completer._get_command_help(cmd),
269
+ )
270
+ for cmd in matches
271
+ ]
272
+ return []
273
+
274
+ matches = [cmd for cmd in self.completer.commands if cmd.startswith(cmd_text)]
275
+ return [
276
+ (
277
+ self.completer.display_texts.get(cmd, f"/{cmd}"),
278
+ self.completer._get_command_help(cmd),
279
+ )
280
+ for cmd in matches
281
+ ]
282
+
283
+ def _create_key_bindings(self) -> KeyBindings:
284
+ """Create custom key bindings.
285
+
286
+ Returns:
287
+ KeyBindings instance
288
+ """
289
+ kb = KeyBindings()
290
+
291
+ @kb.add("/", eager=True)
292
+ def slash_command(event):
293
+ """Insert '/' and, when starting a command, show suggestions immediately."""
294
+ buffer = event.current_buffer
295
+ at_start = buffer.text == "" and buffer.cursor_position == 0
296
+ buffer.insert_text("/")
297
+ if at_start:
298
+ buffer.start_completion(select_first=False)
299
+
300
+ @kb.add(Keys.ControlL)
301
+ def clear_screen(event):
302
+ """Clear the screen."""
303
+ event.app.renderer.clear()
304
+ if self._on_clear_screen:
305
+ self._on_clear_screen()
306
+
307
+ @kb.add(Keys.ControlT)
308
+ def toggle_thinking(event):
309
+ """Toggle thinking display."""
310
+ if self._on_toggle_thinking:
311
+ self._on_toggle_thinking()
312
+
313
+ @kb.add(Keys.ControlS)
314
+ def show_stats(event):
315
+ """Show quick stats."""
316
+ if self._on_show_stats:
317
+ self._on_show_stats()
318
+
319
+ return kb
320
+
321
+ def set_callbacks(
322
+ self,
323
+ on_clear_screen: Optional[Callable[[], None]] = None,
324
+ on_toggle_thinking: Optional[Callable[[], None]] = None,
325
+ on_show_stats: Optional[Callable[[], None]] = None,
326
+ ) -> None:
327
+ """Set callback handlers for keyboard shortcuts.
328
+
329
+ Args:
330
+ on_clear_screen: Callback for Ctrl+L
331
+ on_toggle_thinking: Callback for Ctrl+T
332
+ on_show_stats: Callback for Ctrl+S
333
+ """
334
+ self._on_clear_screen = on_clear_screen
335
+ self._on_toggle_thinking = on_toggle_thinking
336
+ self._on_show_stats = on_show_stats
337
+
338
+ def get_style(self) -> Style:
339
+ """Get prompt_toolkit style based on current theme.
340
+
341
+ Returns:
342
+ Style instance
343
+ """
344
+ colors = Theme.get_colors()
345
+ return Style.from_dict(
346
+ {
347
+ "prompt": f"{colors.user_input} bold",
348
+ "": colors.text_primary,
349
+ "completion-menu": f"bg:{colors.bg_secondary} {colors.text_primary}",
350
+ "completion-menu.completion": f"bg:{colors.bg_secondary} {colors.text_primary}",
351
+ "completion-menu.completion.current": f"bg:{colors.primary} #000000",
352
+ "completion-menu.meta.completion": f"bg:{colors.bg_secondary} {colors.text_muted}",
353
+ "completion-menu.meta.completion.current": f"bg:{colors.primary} #000000",
354
+ "toolbar.hint": colors.text_muted,
355
+ "toolbar.cmd": f"{colors.primary} bold",
356
+ "scrollbar.background": colors.bg_secondary,
357
+ "scrollbar.button": colors.text_muted,
358
+ }
359
+ )
360
+
361
+ async def prompt_async(self, prompt_text: str = "> ") -> str:
362
+ """Get input from user asynchronously.
363
+
364
+ Args:
365
+ prompt_text: Prompt text to display
366
+
367
+ Returns:
368
+ User input string
369
+ """
370
+ style = self.get_style()
371
+
372
+ result = await self.session.prompt_async(
373
+ [("class:prompt", prompt_text)],
374
+ style=style,
375
+ )
376
+ return result.strip()
377
+
378
+ def prompt(self, prompt_text: str = "> ") -> str:
379
+ """Get input from user synchronously.
380
+
381
+ Args:
382
+ prompt_text: Prompt text to display
383
+
384
+ Returns:
385
+ User input string
386
+ """
387
+ style = self.get_style()
388
+
389
+ result = self.session.prompt(
390
+ [("class:prompt", prompt_text)],
391
+ style=style,
392
+ )
393
+ return result.strip()