@@ -1,5 +1,6 @@ |
| 1 | 1 | """Main Textual application for Loader TUI.""" |
| 2 | 2 | |
| 3 | +import asyncio |
| 3 | 4 | import time |
| 4 | 5 | from pathlib import Path |
| 5 | 6 | |
@@ -9,7 +10,7 @@ from textual.app import App, ComposeResult |
| 9 | 10 | from textual.binding import Binding |
| 10 | 11 | from textual.containers import Container, ScrollableContainer |
| 11 | 12 | from textual.reactive import reactive |
| 12 | | -from textual.widgets import Footer, Static |
| 13 | +from textual.widgets import Footer, Input, Static |
| 13 | 14 | from textual import work |
| 14 | 15 | from textual.worker import Worker, get_current_worker |
| 15 | 16 | |
@@ -35,7 +36,7 @@ from .adapter import ( |
| 35 | 36 | ToolCallStarted, |
| 36 | 37 | VerificationPerformed, |
| 37 | 38 | ) |
| 38 | | -from .widgets import ConfirmationModal, DiffWidget, InputArea, StatusLine, StreamingText, ToolCallWidget |
| 39 | +from .widgets import ApprovalBar, ConfirmationModal, DiffWidget, InputArea, StatusLine, StreamingText, ToolCallWidget |
| 39 | 40 | |
| 40 | 41 | |
| 41 | 42 | class LoaderApp(App): |
@@ -69,6 +70,9 @@ class LoaderApp(App): |
| 69 | 70 | self._streamed_content: bool = False # Track if any content was streamed |
| 70 | 71 | self._tool_widget_queue: list[ToolCallWidget] = [] # Queue of pending tool widgets |
| 71 | 72 | self._timer_handle = None |
| 73 | + # Approval bar state |
| 74 | + self._pending_confirmation: asyncio.Future | None = None |
| 75 | + self._pending_command: str = "" |
| 72 | 76 | |
| 73 | 77 | def _debug_log(self, message: str) -> None: |
| 74 | 78 | """Write debug message to log file.""" |
@@ -81,6 +85,7 @@ class LoaderApp(App): |
| 81 | 85 | def compose(self) -> ComposeResult: |
| 82 | 86 | yield Container( |
| 83 | 87 | ScrollableContainer(id="message-area"), |
| 88 | + ApprovalBar(id="approval-bar"), |
| 84 | 89 | InputArea(id="input-area"), |
| 85 | 90 | StatusLine(id="status-line"), |
| 86 | 91 | id="main-container", |
@@ -210,49 +215,51 @@ class LoaderApp(App): |
| 210 | 215 | """Show help message with available commands.""" |
| 211 | 216 | help_text = """[bold]Available Commands:[/bold] |
| 212 | 217 | |
| 213 | | -[cyan]/help[/cyan], [cyan]/h[/cyan] Show this help message |
| 214 | | -[cyan]/exit[/cyan], [cyan]/q[/cyan] Exit the application |
| 215 | | -[cyan]/clear[/cyan], [cyan]/c[/cyan] Clear the conversation |
| 216 | | -[cyan]/model[/cyan] [dim]<name>[/dim] Switch to a different model |
| 217 | | -[cyan]/models[/cyan] List available models |
| 218 | +[cyan]/help[/cyan], [cyan]/h[/cyan] Show this help message |
| 219 | +[cyan]/exit[/cyan], [cyan]/q[/cyan] Exit the application |
| 220 | +[cyan]/clear[/cyan], [cyan]/c[/cyan] Clear the conversation |
| 221 | +[cyan]/model[/cyan], [cyan]/models[/cyan] Open model selector (fzf-style) |
| 222 | +[cyan]/model[/cyan] [dim]<name>[/dim] Switch to a specific model |
| 218 | 223 | |
| 219 | 224 | [bold]Shortcuts:[/bold] |
| 220 | | -[dim]Ctrl+C[/dim] Exit |
| 221 | | -[dim]Ctrl+L[/dim] Clear conversation""" |
| 225 | +[dim]Ctrl+C[/dim] Exit |
| 226 | +[dim]Ctrl+L[/dim] Clear conversation""" |
| 222 | 227 | self._add_message(help_text) |
| 223 | 228 | |
| 224 | 229 | def _handle_model_command(self, args: str) -> None: |
| 225 | | - """Handle /model command - switch or list models.""" |
| 230 | + """Handle /model command - switch or show selector.""" |
| 226 | 231 | if not args: |
| 227 | | - # List available models |
| 228 | | - self._list_models() |
| 232 | + # Show interactive model selector |
| 233 | + self._show_model_selector() |
| 229 | 234 | else: |
| 230 | | - # Switch to specified model |
| 235 | + # Switch to specified model directly |
| 231 | 236 | self._switch_model(args.strip()) |
| 232 | 237 | |
| 233 | | - def _list_models(self) -> None: |
| 234 | | - """List available Ollama models.""" |
| 235 | | - # Use Textual's worker to run async code |
| 236 | | - self.run_worker(self._fetch_and_display_models()) |
| 238 | + def _show_model_selector(self) -> None: |
| 239 | + """Show interactive model selection modal.""" |
| 240 | + # Use Textual's worker to fetch models then show modal |
| 241 | + self.run_worker(self._fetch_and_show_selector()) |
| 242 | + |
| 243 | + async def _fetch_and_show_selector(self) -> None: |
| 244 | + """Fetch models and show the selection modal.""" |
| 245 | + from .widgets import ModelSelectModal |
| 237 | 246 | |
| 238 | | - async def _fetch_and_display_models(self) -> None: |
| 239 | | - """Fetch and display available models (async worker).""" |
| 240 | 247 | try: |
| 241 | 248 | models = [] |
| 242 | 249 | if hasattr(self.agent.backend, "list_models"): |
| 243 | 250 | models = await self.agent.backend.list_models() |
| 244 | 251 | |
| 245 | 252 | if models: |
| 246 | | - lines = ["[bold]Available Models:[/bold]", ""] |
| 247 | 253 | current = self.agent.backend.model if hasattr(self.agent.backend, "model") else "" |
| 248 | | - for m in models: |
| 249 | | - name = m.get("name", "") |
| 250 | | - size_mb = m.get("size", 0) / (1024 * 1024) |
| 251 | | - marker = "[green]●[/green]" if name == current else "[dim]○[/dim]" |
| 252 | | - lines.append(f" {marker} [cyan]{name}[/cyan] [dim]({size_mb:.0f}MB)[/dim]") |
| 253 | | - lines.append("") |
| 254 | | - lines.append("[dim]Use /model <name> to switch[/dim]") |
| 255 | | - self._add_message("\n".join(lines)) |
| 254 | + |
| 255 | + def on_select(selected: str | None) -> None: |
| 256 | + if selected: |
| 257 | + self._switch_model(selected) |
| 258 | + |
| 259 | + # Schedule on main thread - workers can use call_later |
| 260 | + self.call_later( |
| 261 | + lambda: self.push_screen(ModelSelectModal(models, current), on_select) |
| 262 | + ) |
| 256 | 263 | else: |
| 257 | 264 | self._add_message("[yellow]No models found. Is Ollama running?[/yellow]") |
| 258 | 265 | except Exception as e: |
@@ -281,13 +288,45 @@ class LoaderApp(App): |
| 281 | 288 | message: str, |
| 282 | 289 | details: str, |
| 283 | 290 | ) -> bool: |
| 284 | | - """Show confirmation modal and wait for user response.""" |
| 285 | | - modal = ConfirmationModal( |
| 286 | | - tool_name=tool_name, |
| 287 | | - message=message, |
| 288 | | - details=details, |
| 289 | | - ) |
| 290 | | - return await self.push_screen_wait(modal) |
| 291 | + """Show approval bar and wait for user response.""" |
| 292 | + # Create a future to wait on |
| 293 | + loop = asyncio.get_event_loop() |
| 294 | + self._pending_confirmation = loop.create_future() |
| 295 | + self._pending_command = details |
| 296 | + |
| 297 | + # Show the approval bar |
| 298 | + approval_bar = self.query_one("#approval-bar", ApprovalBar) |
| 299 | + self.call_later(lambda: approval_bar.show_approval(tool_name, message, details)) |
| 300 | + |
| 301 | + # Wait for user response |
| 302 | + try: |
| 303 | + return await self._pending_confirmation |
| 304 | + finally: |
| 305 | + self._pending_confirmation = None |
| 306 | + self._pending_command = "" |
| 307 | + |
| 308 | + def on_approval_bar_approved(self, event: ApprovalBar.Approved) -> None: |
| 309 | + """Handle approval from the bar.""" |
| 310 | + if self._pending_confirmation and not self._pending_confirmation.done(): |
| 311 | + self._pending_confirmation.set_result(True) |
| 312 | + |
| 313 | + def on_approval_bar_rejected(self, event: ApprovalBar.Rejected) -> None: |
| 314 | + """Handle rejection from the bar.""" |
| 315 | + if self._pending_confirmation and not self._pending_confirmation.done(): |
| 316 | + self._pending_confirmation.set_result(False) |
| 317 | + # Refocus input |
| 318 | + self.query_one(InputArea).focus_input() |
| 319 | + |
| 320 | + def on_approval_bar_edit_requested(self, event: ApprovalBar.EditRequested) -> None: |
| 321 | + """Handle edit request - put command in input for editing.""" |
| 322 | + if self._pending_confirmation and not self._pending_confirmation.done(): |
| 323 | + self._pending_confirmation.set_result(False) |
| 324 | + # Put the command in the input field for editing |
| 325 | + input_area = self.query_one(InputArea) |
| 326 | + input_widget = input_area.query_one("#user-input", Input) |
| 327 | + input_widget.value = event.command |
| 328 | + input_widget.cursor_position = len(event.command) |
| 329 | + input_area.focus_input() |
| 291 | 330 | |
| 292 | 331 | @work(exclusive=True) |
| 293 | 332 | async def run_agent(self, user_input: str) -> str: |
@@ -487,11 +526,9 @@ class LoaderApp(App): |
| 487 | 526 | |
| 488 | 527 | def on_steering_received(self, message: SteeringReceived) -> None: |
| 489 | 528 | """Handle steering message being processed by agent.""" |
| 490 | | - # The steering message was already displayed when sent, |
| 491 | | - # this confirms it was injected into the agent's context |
| 492 | | - self._add_message( |
| 493 | | - "[dim italic]↪ Steering message injected, agent will incorporate it...[/dim italic]" |
| 494 | | - ) |
| 529 | + # Don't display anything - auto-steering is internal |
| 530 | + # Only show if this was user-initiated (but we don't track that currently) |
| 531 | + pass |
| 495 | 532 | |
| 496 | 533 | # Reasoning event handlers |
| 497 | 534 | def on_decomposition_created(self, message: DecompositionCreated) -> None: |
@@ -592,19 +629,9 @@ class LoaderApp(App): |
| 592 | 629 | |
| 593 | 630 | def on_completion_check_performed(self, message: CompletionCheckPerformed) -> None: |
| 594 | 631 | """Handle task completion check.""" |
| 595 | | - check = message.completion_check |
| 596 | | - if check and not check.is_complete: |
| 597 | | - lines = ["[bold yellow]⚡ Task not complete yet![/bold yellow]"] |
| 598 | | - if check.accomplished: |
| 599 | | - lines.append(f" [green]Done:[/green] {', '.join(check.accomplished[:3])}") |
| 600 | | - if check.suggested_next_steps: |
| 601 | | - lines.append(" [yellow]Next steps:[/yellow]") |
| 602 | | - for step in check.suggested_next_steps[:2]: |
| 603 | | - lines.append(f" → {step}") |
| 604 | | - lines.append(" [italic]Continuing...[/italic]") |
| 605 | | - self._add_message("\n".join(lines), "completion-check") |
| 606 | | - else: |
| 607 | | - self._add_message(f"[dim]{escape(message.content)}[/dim]") |
| 632 | + # Don't display anything - completion checks are internal steering |
| 633 | + # The agent will continue automatically if needed |
| 634 | + pass |
| 608 | 635 | |
| 609 | 636 | def on_rollback_tracked(self, message: RollbackTracked) -> None: |
| 610 | 637 | """Handle rollback action tracking (verbose mode only).""" |