aloop 0.1.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.
- agent/__init__.py +0 -0
- agent/agent.py +182 -0
- agent/base.py +406 -0
- agent/context.py +126 -0
- agent/prompts/__init__.py +1 -0
- agent/todo.py +149 -0
- agent/tool_executor.py +54 -0
- agent/verification.py +135 -0
- aloop-0.1.1.dist-info/METADATA +252 -0
- aloop-0.1.1.dist-info/RECORD +66 -0
- aloop-0.1.1.dist-info/WHEEL +5 -0
- aloop-0.1.1.dist-info/entry_points.txt +2 -0
- aloop-0.1.1.dist-info/licenses/LICENSE +21 -0
- aloop-0.1.1.dist-info/top_level.txt +9 -0
- cli.py +19 -0
- config.py +146 -0
- interactive.py +865 -0
- llm/__init__.py +51 -0
- llm/base.py +26 -0
- llm/compat.py +226 -0
- llm/content_utils.py +309 -0
- llm/litellm_adapter.py +450 -0
- llm/message_types.py +245 -0
- llm/model_manager.py +265 -0
- llm/retry.py +95 -0
- main.py +246 -0
- memory/__init__.py +20 -0
- memory/compressor.py +554 -0
- memory/manager.py +538 -0
- memory/serialization.py +82 -0
- memory/short_term.py +88 -0
- memory/store/__init__.py +6 -0
- memory/store/memory_store.py +100 -0
- memory/store/yaml_file_memory_store.py +414 -0
- memory/token_tracker.py +203 -0
- memory/types.py +51 -0
- tools/__init__.py +6 -0
- tools/advanced_file_ops.py +557 -0
- tools/base.py +51 -0
- tools/calculator.py +50 -0
- tools/code_navigator.py +975 -0
- tools/explore.py +254 -0
- tools/file_ops.py +150 -0
- tools/git_tools.py +791 -0
- tools/notify.py +69 -0
- tools/parallel_execute.py +420 -0
- tools/session_manager.py +205 -0
- tools/shell.py +147 -0
- tools/shell_background.py +470 -0
- tools/smart_edit.py +491 -0
- tools/todo.py +130 -0
- tools/web_fetch.py +673 -0
- tools/web_search.py +61 -0
- utils/__init__.py +15 -0
- utils/logger.py +105 -0
- utils/model_pricing.py +49 -0
- utils/runtime.py +75 -0
- utils/terminal_ui.py +422 -0
- utils/tui/__init__.py +39 -0
- utils/tui/command_registry.py +49 -0
- utils/tui/components.py +306 -0
- utils/tui/input_handler.py +393 -0
- utils/tui/model_ui.py +204 -0
- utils/tui/progress.py +292 -0
- utils/tui/status_bar.py +178 -0
- utils/tui/theme.py +165 -0
utils/tui/components.py
ADDED
|
@@ -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()
|