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
interactive.py ADDED
@@ -0,0 +1,865 @@
1
+ """Interactive multi-turn conversation mode for the agent."""
2
+
3
+ import asyncio
4
+ import shlex
5
+ import signal
6
+
7
+ from rich.table import Table
8
+
9
+ from config import Config
10
+ from llm import ModelManager
11
+ from memory import MemoryManager
12
+ from utils import get_log_file_path, terminal_ui
13
+ from utils.runtime import get_history_file
14
+ from utils.tui.command_registry import CommandRegistry, CommandSpec
15
+ from utils.tui.input_handler import InputHandler
16
+ from utils.tui.model_ui import (
17
+ mask_secret,
18
+ open_config_and_wait_for_save,
19
+ parse_kv_args,
20
+ pick_model_id,
21
+ )
22
+ from utils.tui.status_bar import StatusBar
23
+ from utils.tui.theme import Theme, set_theme
24
+
25
+
26
+ class InteractiveSession:
27
+ """Manages an interactive conversation session with the agent."""
28
+
29
+ def __init__(self, agent):
30
+ """Initialize interactive session.
31
+
32
+ Args:
33
+ agent: The agent instance
34
+ """
35
+ self.agent = agent
36
+ self.conversation_count = 0
37
+ self.show_thinking = Config.TUI_SHOW_THINKING
38
+
39
+ # Use the agent's model manager to avoid divergence
40
+ self.model_manager = getattr(agent, "model_manager", None) or ModelManager()
41
+
42
+ # Track current task for interruption support
43
+ self.current_task = None
44
+
45
+ # Initialize TUI components
46
+ self.command_registry = CommandRegistry(
47
+ commands=[
48
+ CommandSpec("help", "Show this help message"),
49
+ CommandSpec("reset", "Clear conversation memory and start fresh"),
50
+ CommandSpec("stats", "Show memory and token usage statistics"),
51
+ CommandSpec(
52
+ "resume",
53
+ "List and resume a previous session",
54
+ args_hint="[session_id]",
55
+ ),
56
+ CommandSpec("theme", "Toggle between dark and light theme"),
57
+ CommandSpec("verbose", "Toggle verbose thinking display"),
58
+ CommandSpec("compact", "Compress conversation memory"),
59
+ CommandSpec(
60
+ "model",
61
+ "Manage models",
62
+ subcommands={
63
+ "edit": CommandSpec(
64
+ "edit",
65
+ "Edit `.aloop/models.yaml` (auto-reload on save)",
66
+ )
67
+ },
68
+ ),
69
+ CommandSpec("exit", "Exit interactive mode"),
70
+ ]
71
+ )
72
+ self.input_handler = InputHandler(
73
+ history_file=get_history_file(),
74
+ command_registry=self.command_registry,
75
+ )
76
+
77
+ # Set up keyboard shortcut callbacks
78
+ self.input_handler.set_callbacks(
79
+ on_clear_screen=self._on_clear_screen,
80
+ on_toggle_thinking=self._on_toggle_thinking,
81
+ on_show_stats=self._on_show_stats,
82
+ )
83
+
84
+ # Initialize status bar
85
+ self.status_bar = StatusBar(terminal_ui.console)
86
+ self.status_bar.update(mode="LOOP")
87
+
88
+ # Set up signal handler for graceful interruption
89
+ self._setup_signal_handler()
90
+
91
+ def _setup_signal_handler(self) -> None:
92
+ """Set up SIGINT handler for graceful task interruption."""
93
+
94
+ def handle_sigint(sig, frame):
95
+ """Handle SIGINT (Ctrl+C) by canceling the current task."""
96
+ if self.current_task and not self.current_task.done():
97
+ colors = Theme.get_colors()
98
+ terminal_ui.console.print(
99
+ f"\n[bold {colors.warning}]Interrupting...[/bold {colors.warning}]"
100
+ )
101
+ self.current_task.cancel()
102
+ # Don't raise - let the asyncio.CancelledError propagate
103
+
104
+ signal.signal(signal.SIGINT, handle_sigint)
105
+
106
+ def _on_clear_screen(self) -> None:
107
+ """Handle Ctrl+L - clear screen."""
108
+ terminal_ui.console.clear()
109
+
110
+ def _on_toggle_thinking(self) -> None:
111
+ """Handle Ctrl+T - toggle thinking display."""
112
+ self.show_thinking = not self.show_thinking
113
+ status = "enabled" if self.show_thinking else "disabled"
114
+ terminal_ui.print_info(f"Thinking display {status}")
115
+
116
+ def _on_show_stats(self) -> None:
117
+ """Handle Ctrl+S - show quick stats."""
118
+ self._show_stats()
119
+
120
+ def _show_help(self) -> None:
121
+ """Display help message with available commands."""
122
+ colors = Theme.get_colors()
123
+ terminal_ui.console.print(
124
+ f"\n[bold {colors.primary}]Available Commands:[/bold {colors.primary}]"
125
+ )
126
+ for cmd in self.command_registry.commands:
127
+ terminal_ui.console.print(
128
+ f" [{colors.primary}]{cmd.display}[/{colors.primary}] - {cmd.description}"
129
+ )
130
+ if cmd.subcommands:
131
+ for sub_name, sub in cmd.subcommands.items():
132
+ extra = f" {sub.args_hint}" if sub.args_hint else ""
133
+ terminal_ui.console.print(
134
+ f" [{colors.text_muted}]/{cmd.name} {sub_name}{extra} - {sub.description}[/{colors.text_muted}]"
135
+ )
136
+
137
+ terminal_ui.console.print(
138
+ f"\n[bold {colors.primary}]Keyboard Shortcuts:[/bold {colors.primary}]"
139
+ )
140
+ terminal_ui.console.print(
141
+ f" [{colors.secondary}]/[/{colors.secondary}] - Show command suggestions"
142
+ )
143
+ terminal_ui.console.print(
144
+ f" [{colors.secondary}]Ctrl+C[/{colors.secondary}] - Cancel current operation"
145
+ )
146
+ terminal_ui.console.print(
147
+ f" [{colors.secondary}]Ctrl+L[/{colors.secondary}] - Clear screen"
148
+ )
149
+ terminal_ui.console.print(
150
+ f" [{colors.secondary}]Ctrl+T[/{colors.secondary}] - Toggle thinking display"
151
+ )
152
+ terminal_ui.console.print(
153
+ f" [{colors.secondary}]Ctrl+S[/{colors.secondary}] - Show quick stats"
154
+ )
155
+ terminal_ui.console.print(
156
+ f" [{colors.secondary}]Up/Down[/{colors.secondary}] - Navigate command history\n"
157
+ )
158
+
159
+ def _show_stats(self) -> None:
160
+ """Display current memory and token statistics."""
161
+ terminal_ui.console.print()
162
+ stats = self.agent.memory.get_stats()
163
+ terminal_ui.print_memory_stats(stats)
164
+ terminal_ui.console.print()
165
+
166
+ async def _resume_session(self, session_id: str | None = None) -> None:
167
+ """Resume a previous session.
168
+
169
+ Args:
170
+ session_id: Optional session ID or prefix. If None, shows recent sessions.
171
+ """
172
+ colors = Theme.get_colors()
173
+ try:
174
+ if session_id is None:
175
+ # Show recent sessions for user to pick
176
+ sessions = await MemoryManager.list_sessions(limit=10)
177
+ if not sessions:
178
+ terminal_ui.console.print(
179
+ f"\n[{colors.warning}]No saved sessions found.[/{colors.warning}]\n"
180
+ )
181
+ return
182
+
183
+ terminal_ui.console.print(
184
+ f"\n[bold {colors.primary}]Recent Sessions:[/bold {colors.primary}]\n"
185
+ )
186
+
187
+ table = Table(show_header=True, header_style=f"bold {colors.primary}", box=None)
188
+ table.add_column("#", style=colors.text_muted, width=4)
189
+ table.add_column("ID", style=colors.text_muted, width=38)
190
+ table.add_column("Updated", width=20)
191
+ table.add_column("Msgs", justify="right", width=6)
192
+ table.add_column("Preview", width=50)
193
+
194
+ for i, session in enumerate(sessions, 1):
195
+ sid = session["id"]
196
+ updated = session.get("updated_at", session.get("created_at", ""))[:19]
197
+ msg_count = str(session["message_count"])
198
+ preview = session.get("preview", "")[:50]
199
+ table.add_row(str(i), sid, updated, msg_count, preview)
200
+
201
+ terminal_ui.console.print(table)
202
+ terminal_ui.console.print(
203
+ f"\n[{colors.text_muted}]Usage: /resume <session_id or prefix>[/{colors.text_muted}]\n"
204
+ )
205
+ return
206
+
207
+ # Resolve session ID (prefix match)
208
+ resolved_id = await MemoryManager.find_session_by_prefix(session_id)
209
+ if not resolved_id:
210
+ terminal_ui.print_error(f"Session '{session_id}' not found")
211
+ return
212
+
213
+ # Load session via agent (agent owns memory lifecycle)
214
+ await self.agent.load_session(resolved_id)
215
+
216
+ msg_count = self.agent.memory.short_term.count()
217
+ terminal_ui.print_success(
218
+ f"Resumed session {resolved_id} ({msg_count} messages, "
219
+ f"{self.agent.memory.current_tokens} tokens)"
220
+ )
221
+ terminal_ui.console.print()
222
+
223
+ self._print_session_history()
224
+ self._update_status_bar()
225
+
226
+ except Exception as e:
227
+ terminal_ui.print_error(str(e), title="Error resuming session")
228
+
229
+ def _print_session_history(self) -> None:
230
+ """Print conversation history from a resumed session."""
231
+ messages = self.agent.memory.short_term.get_messages()
232
+ if not messages:
233
+ return
234
+
235
+ colors = Theme.get_colors()
236
+ terminal_ui.console.print(
237
+ f"[bold {colors.primary}]Session History:[/bold {colors.primary}]"
238
+ )
239
+ terminal_ui.console.print(f"[{colors.text_muted}]{'─' * 60}[/{colors.text_muted}]")
240
+
241
+ for msg in messages:
242
+ if msg.role == "user":
243
+ content = str(msg.content or "")
244
+ if len(content) > 200:
245
+ content = content[:200] + "..."
246
+ terminal_ui.console.print(
247
+ f"\n[bold {colors.primary}]You:[/bold {colors.primary}] {content}"
248
+ )
249
+ elif msg.role == "assistant" and msg.content:
250
+ content = str(msg.content)
251
+ if len(content) > 300:
252
+ content = content[:300] + "..."
253
+ terminal_ui.console.print(
254
+ f"[bold {colors.secondary}]Assistant:[/bold {colors.secondary}] {content}"
255
+ )
256
+ elif msg.role == "assistant" and msg.tool_calls:
257
+ tool_names = ", ".join(
258
+ (
259
+ tc.get("function", {}).get("name", "?")
260
+ if isinstance(tc, dict)
261
+ else getattr(getattr(tc, "function", None), "name", "?")
262
+ )
263
+ for tc in msg.tool_calls
264
+ )
265
+ terminal_ui.console.print(
266
+ f"[{colors.text_muted}] (used tools: {tool_names})[/{colors.text_muted}]"
267
+ )
268
+ # Skip tool result messages — they are verbose
269
+
270
+ terminal_ui.console.print(f"\n[{colors.text_muted}]{'─' * 60}[/{colors.text_muted}]\n")
271
+
272
+ def _toggle_theme(self) -> None:
273
+ """Toggle between dark and light theme."""
274
+ current = Theme.get_theme_name()
275
+ new_theme = "light" if current == "dark" else "dark"
276
+ set_theme(new_theme)
277
+ terminal_ui.print_success(f"Switched to {new_theme} theme")
278
+
279
+ def _toggle_verbose(self) -> None:
280
+ """Toggle verbose thinking display."""
281
+ self.show_thinking = not self.show_thinking
282
+ status = "enabled" if self.show_thinking else "disabled"
283
+ terminal_ui.print_info(f"Verbose thinking display {status}")
284
+
285
+ async def _compact_memory(self) -> None:
286
+ """Compress conversation memory."""
287
+ result = await self.agent.memory.compress()
288
+ if result is None:
289
+ terminal_ui.print_info("Nothing to compress.")
290
+ else:
291
+ terminal_ui.print_success(
292
+ f"Compressed {result.original_message_count} messages: "
293
+ f"{result.original_tokens} → {result.compressed_tokens} tokens "
294
+ f"({result.savings_percentage:.0f}% saved)"
295
+ )
296
+ self._update_status_bar()
297
+
298
+ def _update_status_bar(self) -> None:
299
+ """Update status bar with current stats."""
300
+ stats = self.agent.memory.get_stats()
301
+ model_info = self.agent.get_current_model_info()
302
+ model_name = model_info["name"] if model_info else ""
303
+ self.status_bar.update(
304
+ input_tokens=stats.get("total_input_tokens", 0),
305
+ output_tokens=stats.get("total_output_tokens", 0),
306
+ context_tokens=stats.get("current_tokens", 0),
307
+ cost=stats.get("total_cost", 0),
308
+ model_name=model_name,
309
+ )
310
+
311
+ async def _handle_command(self, user_input: str) -> bool:
312
+ """Handle a slash command.
313
+
314
+ Args:
315
+ user_input: User input starting with /
316
+
317
+ Returns:
318
+ True if should continue loop, False if should exit
319
+ """
320
+ command_parts = user_input.split()
321
+ command = command_parts[0].lower()
322
+
323
+ if command in ("/exit", "/quit"):
324
+ colors = Theme.get_colors()
325
+ terminal_ui.console.print(
326
+ f"\n[bold {colors.warning}]Exiting interactive mode. Goodbye![/bold {colors.warning}]"
327
+ )
328
+ return False
329
+
330
+ elif command == "/help":
331
+ self._show_help()
332
+
333
+ elif command == "/reset":
334
+ self.agent.memory.reset()
335
+ self.conversation_count = 0
336
+ self._update_status_bar()
337
+ terminal_ui.print_success("Memory cleared. Starting fresh conversation.")
338
+ terminal_ui.console.print()
339
+
340
+ elif command == "/stats":
341
+ self._show_stats()
342
+
343
+ elif command == "/resume":
344
+ session_id = command_parts[1] if len(command_parts) >= 2 else None
345
+ await self._resume_session(session_id)
346
+
347
+ elif command == "/theme":
348
+ self._toggle_theme()
349
+
350
+ elif command == "/verbose":
351
+ self._toggle_verbose()
352
+
353
+ elif command == "/compact":
354
+ await self._compact_memory()
355
+
356
+ elif command == "/model":
357
+ await self._handle_model_command(user_input)
358
+
359
+ else:
360
+ colors = Theme.get_colors()
361
+ terminal_ui.console.print(
362
+ f"[bold {colors.error}]Unknown command: {command}[/bold {colors.error}]"
363
+ )
364
+ terminal_ui.console.print(
365
+ f"[{colors.text_muted}]Type /help to see available commands[/{colors.text_muted}]\n"
366
+ )
367
+
368
+ return True
369
+
370
+ def _show_models(self) -> None:
371
+ """Display available models and current selection."""
372
+ colors = Theme.get_colors()
373
+ profiles = self.model_manager.list_models()
374
+ current = self.model_manager.get_current_model()
375
+ default_model_id = self.model_manager.get_default_model_id()
376
+
377
+ terminal_ui.console.print(
378
+ f"\n[bold {colors.primary}]Available Models:[/bold {colors.primary}]\n"
379
+ )
380
+
381
+ if not profiles:
382
+ terminal_ui.print_error("No models configured.")
383
+ terminal_ui.console.print(
384
+ f"[{colors.text_muted}]Use /model edit (recommended) or edit `.aloop/models.yaml` manually.[/{colors.text_muted}]\n"
385
+ )
386
+ return
387
+
388
+ for i, profile in enumerate(profiles, start=1):
389
+ markers: list[str] = []
390
+ if current and profile.model_id == current.model_id:
391
+ markers.append(f"[{colors.success}]CURRENT[/{colors.success}]")
392
+ if default_model_id and profile.model_id == default_model_id:
393
+ markers.append(f"[{colors.primary}]DEFAULT[/{colors.primary}]")
394
+ marker = (
395
+ " ".join(markers)
396
+ if markers
397
+ else f"[{colors.text_muted}] [/{colors.text_muted}]"
398
+ )
399
+
400
+ terminal_ui.console.print(
401
+ f" {marker} [{colors.text_muted}]{i:>2}[/] {profile.model_id}"
402
+ )
403
+
404
+ terminal_ui.console.print(
405
+ f"\n[{colors.text_muted}]Tip: run /model to pick; /model edit to change config.[/]\n"
406
+ )
407
+
408
+ def _switch_model(self, model_id: str) -> None:
409
+ """Switch to a different model.
410
+
411
+ Args:
412
+ model_id: LiteLLM model ID to switch to
413
+ """
414
+ colors = Theme.get_colors()
415
+
416
+ # Validate the profile
417
+ profile = self.model_manager.get_model(model_id)
418
+ if profile is None:
419
+ terminal_ui.print_error(f"Model '{model_id}' not found")
420
+ available = ", ".join(self.model_manager.get_model_ids())
421
+ if available:
422
+ terminal_ui.console.print(
423
+ f"[{colors.text_muted}]Available: {available}[/{colors.text_muted}]\n"
424
+ )
425
+ return
426
+
427
+ is_valid, error_msg = self.model_manager.validate_model(profile)
428
+ if not is_valid:
429
+ terminal_ui.print_error(error_msg)
430
+ return
431
+
432
+ # Perform the switch
433
+ if self.agent.switch_model(model_id):
434
+ new_profile = self.model_manager.get_current_model()
435
+ if new_profile:
436
+ terminal_ui.print_success(f"Switched to model: {new_profile.model_id}")
437
+ self._update_status_bar()
438
+ else:
439
+ terminal_ui.print_error("Failed to get current model after switch")
440
+ else:
441
+ terminal_ui.print_error(f"Failed to switch to model '{model_id}'")
442
+
443
+ def _parse_kv_args(self, tokens: list[str]) -> tuple[dict[str, str], list[str]]:
444
+ return parse_kv_args(tokens)
445
+
446
+ def _mask_secret(self, value: str | None) -> str:
447
+ return mask_secret(value)
448
+
449
+ async def _handle_model_command(self, user_input: str) -> None:
450
+ """Handle the /model command.
451
+
452
+ Args:
453
+ user_input: Full user input string
454
+ """
455
+ colors = Theme.get_colors()
456
+
457
+ try:
458
+ parts = shlex.split(user_input)
459
+ except ValueError as e:
460
+ terminal_ui.print_error(str(e), title="Invalid /model command")
461
+ return
462
+
463
+ if len(parts) == 1:
464
+ if not self.model_manager.list_models():
465
+ terminal_ui.print_error("No models configured yet.")
466
+ terminal_ui.console.print(
467
+ f"[{colors.text_muted}]Run /model edit to configure `.aloop/models.yaml`.[/{colors.text_muted}]\n"
468
+ )
469
+ return
470
+ picked = await pick_model_id(self.model_manager, title="Select Model")
471
+ if picked:
472
+ self._switch_model(picked)
473
+ return
474
+ return
475
+
476
+ sub = parts[1]
477
+
478
+ if sub == "edit":
479
+ if len(parts) != 2:
480
+ terminal_ui.print_error("Usage: /model edit")
481
+ terminal_ui.console.print(
482
+ f"[{colors.text_muted}]Edit the YAML directly instead of using subcommands.[/{colors.text_muted}]\n"
483
+ )
484
+ return
485
+
486
+ terminal_ui.console.print(
487
+ f"[{colors.text_muted}]Save the file to auto-reload (Ctrl+C to cancel)...[/]\n"
488
+ )
489
+ ok = await open_config_and_wait_for_save(self.model_manager.config_path)
490
+ if not ok:
491
+ terminal_ui.print_error(
492
+ f"Could not open editor. Please edit `{self.model_manager.config_path}` manually."
493
+ )
494
+ terminal_ui.console.print(
495
+ f"[{colors.text_muted}]Tip: set EDITOR='code' (or similar) for /model edit.[/{colors.text_muted}]\n"
496
+ )
497
+ return
498
+
499
+ self.model_manager.reload()
500
+ terminal_ui.print_success("Reloaded `.aloop/models.yaml`")
501
+ current_after = self.model_manager.get_current_model()
502
+ if not current_after:
503
+ terminal_ui.print_error(
504
+ "No models configured after reload. Edit `.aloop/models.yaml` and set `default`."
505
+ )
506
+ return
507
+
508
+ # Reinitialize LLM adapter to pick up updated api_key/api_base/timeout/drop_params.
509
+ self.agent.switch_model(current_after.model_id)
510
+ terminal_ui.print_info(f"Reload applied (current: {current_after.model_id}).")
511
+ return
512
+ terminal_ui.print_error("Unknown /model command.")
513
+ terminal_ui.console.print(
514
+ f"[{colors.text_muted}]Use /model to pick, or /model edit to configure.[/{colors.text_muted}]\n"
515
+ )
516
+
517
+ async def run(self) -> None:
518
+ """Run the interactive session loop."""
519
+ # Print header
520
+ terminal_ui.print_banner()
521
+
522
+ # Display configuration
523
+ current = self.model_manager.get_current_model()
524
+ config_dict = {
525
+ "Model": current.model_id if current else "NOT CONFIGURED",
526
+ "Theme": Theme.get_theme_name(),
527
+ "Commands": "/help for all commands",
528
+ }
529
+ terminal_ui.print_config(config_dict)
530
+
531
+ colors = Theme.get_colors()
532
+
533
+ # If session was loaded via --resume, print history
534
+ if self.agent.memory.short_term.count() > 0:
535
+ terminal_ui.print_info(
536
+ f"Resumed session: {self.agent.memory.session_id} "
537
+ f"({self.agent.memory.short_term.count()} messages)"
538
+ )
539
+ terminal_ui.console.print()
540
+ self._print_session_history()
541
+
542
+ terminal_ui.console.print(
543
+ f"[bold {colors.success}]Interactive mode started. Type your message or use commands.[/bold {colors.success}]"
544
+ )
545
+ terminal_ui.console.print(
546
+ f"[{colors.text_muted}]Tip: Type '/' for command suggestions, Ctrl+T to toggle thinking display[/{colors.text_muted}]\n"
547
+ )
548
+
549
+ # Show initial status bar
550
+ if Config.TUI_STATUS_BAR:
551
+ self.status_bar.show()
552
+
553
+ while True:
554
+ try:
555
+ # Get user input
556
+ user_input = await self.input_handler.prompt_async("> ")
557
+
558
+ # Handle empty input
559
+ if not user_input:
560
+ continue
561
+
562
+ # Handle commands
563
+ if user_input.startswith("/"):
564
+ should_continue = await self._handle_command(user_input)
565
+ if not should_continue:
566
+ break
567
+ continue
568
+
569
+ # Process user message
570
+ self.conversation_count += 1
571
+
572
+ # Show turn divider
573
+ terminal_ui.print_turn_divider(self.conversation_count)
574
+
575
+ # Echo user input in Claude Code style
576
+ terminal_ui.print_user_message(user_input)
577
+
578
+ # Update status bar to show processing
579
+ if Config.TUI_STATUS_BAR:
580
+ self.status_bar.update(is_processing=True)
581
+
582
+ try:
583
+ # Create a task for the agent run so it can be cancelled
584
+ self.current_task = asyncio.create_task(
585
+ self.agent.run(user_input, verify=False)
586
+ )
587
+ result = await self.current_task
588
+ self.current_task = None
589
+
590
+ # Display agent response
591
+ terminal_ui.console.print(
592
+ f"[bold {colors.secondary}]Assistant:[/bold {colors.secondary}]"
593
+ )
594
+ terminal_ui.print_assistant_message(result)
595
+
596
+ # Update status bar
597
+ self._update_status_bar()
598
+ if Config.TUI_STATUS_BAR:
599
+ self.status_bar.update(is_processing=False)
600
+ self.status_bar.show()
601
+
602
+ except asyncio.CancelledError:
603
+ terminal_ui.console.print(
604
+ f"\n[bold {colors.warning}]Task interrupted by user.[/bold {colors.warning}]\n"
605
+ )
606
+ if Config.TUI_STATUS_BAR:
607
+ self.status_bar.update(is_processing=False)
608
+ self.current_task = None
609
+
610
+ # Rollback incomplete exchange to prevent API errors on next turn
611
+ self.agent.memory.rollback_incomplete_exchange()
612
+
613
+ continue
614
+ except KeyboardInterrupt:
615
+ terminal_ui.console.print(
616
+ f"\n[bold {colors.warning}]Task interrupted by user.[/bold {colors.warning}]\n"
617
+ )
618
+ if Config.TUI_STATUS_BAR:
619
+ self.status_bar.update(is_processing=False)
620
+ self.current_task = None
621
+ continue
622
+ except Exception as e:
623
+ terminal_ui.print_error(str(e))
624
+ if Config.TUI_STATUS_BAR:
625
+ self.status_bar.update(is_processing=False)
626
+ continue
627
+
628
+ except KeyboardInterrupt:
629
+ terminal_ui.console.print(
630
+ f"\n\n[bold {colors.warning}]Interrupted. Type /exit to quit or continue chatting.[/bold {colors.warning}]\n"
631
+ )
632
+ continue
633
+ except EOFError:
634
+ terminal_ui.console.print(
635
+ f"\n[bold {colors.warning}]Exiting interactive mode. Goodbye![/bold {colors.warning}]"
636
+ )
637
+ break
638
+
639
+ # Show final statistics
640
+ terminal_ui.console.print(
641
+ f"\n[bold {colors.primary}]Final Session Statistics:[/bold {colors.primary}]"
642
+ )
643
+ stats = self.agent.memory.get_stats()
644
+ terminal_ui.print_memory_stats(stats)
645
+
646
+ # Show log file location
647
+ log_file = get_log_file_path()
648
+ if log_file:
649
+ terminal_ui.print_log_location(log_file)
650
+
651
+
652
+ class ModelSetupSession:
653
+ """Lightweight interactive session for configuring models before the agent can run."""
654
+
655
+ def __init__(self, model_manager: ModelManager | None = None):
656
+ self.model_manager = model_manager or ModelManager()
657
+ self.command_registry = CommandRegistry(
658
+ commands=[
659
+ CommandSpec("help", "Show this help message"),
660
+ CommandSpec(
661
+ "model",
662
+ "Pick a model",
663
+ subcommands={
664
+ "edit": CommandSpec("edit", "Open `.aloop/models.yaml` in editor")
665
+ },
666
+ ),
667
+ CommandSpec("exit", "Quit"),
668
+ ]
669
+ )
670
+ self.input_handler = InputHandler(
671
+ history_file=get_history_file(),
672
+ command_registry=self.command_registry,
673
+ )
674
+
675
+ def _show_help(self) -> None:
676
+ colors = Theme.get_colors()
677
+ terminal_ui.console.print(
678
+ f"\n[bold {colors.primary}]Model Setup[/bold {colors.primary}] "
679
+ f"[{colors.text_muted}](edit `.aloop/models.yaml`)[/{colors.text_muted}]\n"
680
+ )
681
+ terminal_ui.console.print(f"[{colors.text_muted}]Commands:[/{colors.text_muted}]\n")
682
+ for cmd in self.command_registry.commands:
683
+ terminal_ui.console.print(
684
+ f" [{colors.primary}]{cmd.display}[/{colors.primary}] - {cmd.description}"
685
+ )
686
+ if cmd.subcommands:
687
+ for sub_name, sub in cmd.subcommands.items():
688
+ extra = f" {sub.args_hint}" if sub.args_hint else ""
689
+ terminal_ui.console.print(
690
+ f" [{colors.text_muted}]/{cmd.name} {sub_name}{extra} - {sub.description}[/{colors.text_muted}]"
691
+ )
692
+
693
+ def _show_models(self) -> None:
694
+ colors = Theme.get_colors()
695
+ models = self.model_manager.list_models()
696
+ current = self.model_manager.get_current_model()
697
+ default_model_id = self.model_manager.get_default_model_id()
698
+
699
+ terminal_ui.console.print(
700
+ f"\n[bold {colors.primary}]Configured Models:[/bold {colors.primary}]\n"
701
+ )
702
+
703
+ if not models:
704
+ terminal_ui.print_error("No models configured yet.")
705
+ terminal_ui.console.print(
706
+ f"[{colors.text_muted}]Use /model edit to configure `.aloop/models.yaml`.[/{colors.text_muted}]\n"
707
+ )
708
+ return
709
+
710
+ for i, model in enumerate(models, start=1):
711
+ markers: list[str] = []
712
+ if current and model.model_id == current.model_id:
713
+ markers.append(f"[{colors.success}]CURRENT[/{colors.success}]")
714
+ if default_model_id and model.model_id == default_model_id:
715
+ markers.append(f"[{colors.primary}]DEFAULT[/{colors.primary}]")
716
+ marker = (
717
+ " ".join(markers)
718
+ if markers
719
+ else f"[{colors.text_muted}] [/{colors.text_muted}]"
720
+ )
721
+ terminal_ui.console.print(f" {marker} [{colors.text_muted}]{i:>2}[/] {model.model_id}")
722
+
723
+ terminal_ui.console.print()
724
+ terminal_ui.console.print(
725
+ f"[{colors.text_muted}]Tip: run /model to pick; /model edit to change config.[/]\n"
726
+ )
727
+
728
+ def _parse_kv_args(self, tokens: list[str]) -> tuple[dict[str, str], list[str]]:
729
+ return parse_kv_args(tokens)
730
+
731
+ def _mask_secret(self, value: str | None) -> str:
732
+ return mask_secret(value)
733
+
734
+ async def _handle_model_command(self, user_input: str) -> bool:
735
+ colors = Theme.get_colors()
736
+
737
+ try:
738
+ parts = shlex.split(user_input)
739
+ except ValueError as e:
740
+ terminal_ui.print_error(str(e), title="Invalid /model command")
741
+ return False
742
+
743
+ if len(parts) == 1:
744
+ if not self.model_manager.list_models():
745
+ terminal_ui.print_error("No models configured yet.")
746
+ terminal_ui.console.print(
747
+ f"[{colors.text_muted}]Run /model edit to configure `.aloop/models.yaml`.[/{colors.text_muted}]\n"
748
+ )
749
+ return False
750
+ picked = await pick_model_id(self.model_manager, title="Select Model")
751
+ if picked:
752
+ self.model_manager.set_default(picked)
753
+ self.model_manager.switch_model(picked)
754
+ terminal_ui.print_success(f"Selected model: {picked}")
755
+ return self._maybe_ready_to_start()
756
+ return False
757
+
758
+ sub = parts[1]
759
+
760
+ if sub == "edit":
761
+ if len(parts) != 2:
762
+ terminal_ui.print_error("Usage: /model edit")
763
+ terminal_ui.console.print(
764
+ f"[{colors.text_muted}]Edit the YAML directly instead of using subcommands.[/{colors.text_muted}]\n"
765
+ )
766
+ return False
767
+
768
+ terminal_ui.console.print(
769
+ f"[{colors.text_muted}]Save the file to auto-reload (Ctrl+C to cancel)...[/]\n"
770
+ )
771
+ ok = await open_config_and_wait_for_save(self.model_manager.config_path)
772
+ if not ok:
773
+ terminal_ui.print_error(
774
+ f"Could not open editor. Please edit `{self.model_manager.config_path}` manually."
775
+ )
776
+ terminal_ui.console.print(
777
+ f"[{colors.text_muted}]Tip: set EDITOR='code' (or similar) for /model edit.[/{colors.text_muted}]\n"
778
+ )
779
+ return False
780
+ self.model_manager.reload()
781
+ terminal_ui.print_success("Reloaded `.aloop/models.yaml`")
782
+ self._show_models()
783
+ return self._maybe_ready_to_start()
784
+
785
+ model_id = " ".join(parts[1:]).strip()
786
+ if model_id and self.model_manager.get_model(model_id):
787
+ self.model_manager.set_default(model_id)
788
+ self.model_manager.switch_model(model_id)
789
+ terminal_ui.print_success(f"Selected model: {model_id}")
790
+ return self._maybe_ready_to_start()
791
+
792
+ terminal_ui.print_error("Unknown /model command.")
793
+ terminal_ui.console.print(
794
+ f"[{colors.text_muted}]Use /model to pick, or /model edit to configure.[/{colors.text_muted}]\n"
795
+ )
796
+ return False
797
+
798
+ def _maybe_ready_to_start(self) -> bool:
799
+ current = self.model_manager.get_current_model()
800
+ if not current:
801
+ return False
802
+ is_valid, _ = self.model_manager.validate_model(current)
803
+ return is_valid and self.model_manager.is_configured()
804
+
805
+ async def run(self) -> bool:
806
+ colors = Theme.get_colors()
807
+ terminal_ui.print_header(
808
+ "Agentic Loop - Model Setup", subtitle="Configure `.aloop/models.yaml` to start"
809
+ )
810
+ terminal_ui.console.print(
811
+ f"[{colors.text_muted}]Tip: Use /model edit (recommended) to configure, or /model to pick.[/{colors.text_muted}]\n"
812
+ )
813
+ self._show_help()
814
+
815
+ while True:
816
+ user_input = await self.input_handler.prompt_async("> ")
817
+ if not user_input:
818
+ continue
819
+
820
+ # Allow typing a model_id without the /model prefix, but avoid
821
+ # accidentally interpreting normal text as model selection.
822
+ if not user_input.startswith("/"):
823
+ model_ids = set(self.model_manager.get_model_ids())
824
+ if user_input in model_ids:
825
+ user_input = f"/model {user_input}"
826
+ else:
827
+ terminal_ui.print_error(
828
+ "You're in model setup mode. Pick a model or run /model edit.",
829
+ title="Model Setup",
830
+ )
831
+ continue
832
+
833
+ parts = user_input.split()
834
+ cmd = parts[0].lower()
835
+
836
+ if cmd in ("/exit", "/quit"):
837
+ return False
838
+
839
+ if cmd == "/help":
840
+ self._show_help()
841
+ continue
842
+
843
+ if cmd == "/model":
844
+ ready = await self._handle_model_command(user_input)
845
+ if ready:
846
+ terminal_ui.print_success("Model configuration looks good. Starting agent…")
847
+ return True
848
+ continue
849
+ terminal_ui.print_error(f"Unknown command: {cmd}. Try /help.")
850
+
851
+
852
+ async def run_interactive_mode(agent) -> None:
853
+ """Run agent in interactive multi-turn conversation mode.
854
+
855
+ Args:
856
+ agent: The agent instance
857
+ """
858
+ session = InteractiveSession(agent)
859
+ await session.run()
860
+
861
+
862
+ async def run_model_setup_mode(model_manager: ModelManager | None = None) -> bool:
863
+ """Run model setup mode; returns True when ready to start agent."""
864
+ session = ModelSetupSession(model_manager=model_manager)
865
+ return await session.run()