@@ -4,13 +4,16 @@ import asyncio |
| 4 | 4 | import json |
| 5 | 5 | import re |
| 6 | 6 | import sys |
| 7 | +from typing import Any |
| 7 | 8 | |
| 8 | 9 | import click |
| 9 | | -from rich.console import Console |
| 10 | +from rich import box |
| 11 | +from rich.console import Console, Group |
| 10 | 12 | from rich.markdown import Markdown |
| 11 | 13 | from rich.panel import Panel |
| 12 | 14 | from rich.prompt import Confirm, Prompt |
| 13 | 15 | from rich.table import Table |
| 16 | +from rich.text import Text |
| 14 | 17 | |
| 15 | 18 | from ..runtime.inspection import ( |
| 16 | 19 | CheckStatus, |
@@ -469,7 +472,10 @@ async def _main( |
| 469 | 472 | "[bold blue]Loader[/bold blue]\n" + " | ".join(status_parts), |
| 470 | 473 | border_style="blue", |
| 471 | 474 | )) |
| 472 | | - console.print("[dim]Type 'exit' to quit, 'clear' to reset conversation[/dim]\n") |
| 475 | + console.print( |
| 476 | + "[dim]Type 'exit' to quit, 'clear' to reset conversation, " |
| 477 | + "'jobs' to inspect bash jobs[/dim]\n" |
| 478 | + ) |
| 473 | 479 | await run_interactive(shell_owner, skip_confirmation=yes) |
| 474 | 480 | else: |
| 475 | 481 | # Launch TUI |
@@ -503,6 +509,212 @@ def _format_tool_args(args: dict | None) -> str: |
| 503 | 509 | return ", ".join(parts) |
| 504 | 510 | |
| 505 | 511 | |
| 512 | +_SPECIAL_TOOL_LABELS = { |
| 513 | + "bash": "Bash", |
| 514 | + "bash_jobs": "Bash Jobs", |
| 515 | + "bash_wait": "Bash Wait", |
| 516 | + "bash_kill": "Bash Kill", |
| 517 | +} |
| 518 | + |
| 519 | + |
| 520 | +def _tool_label(tool_name: str, phase: str | None = None) -> str: |
| 521 | + label = _SPECIAL_TOOL_LABELS.get(tool_name, tool_name) |
| 522 | + if phase == "verification": |
| 523 | + return f"Verify {label}" |
| 524 | + return label |
| 525 | + |
| 526 | + |
| 527 | +def _truncate_tool_text( |
| 528 | + text: str, |
| 529 | + *, |
| 530 | + line_limit: int, |
| 531 | + char_limit: int = 6_000, |
| 532 | +) -> tuple[str, bool]: |
| 533 | + lines = text.splitlines() |
| 534 | + if len(lines) <= line_limit and len(text) <= char_limit: |
| 535 | + return text, False |
| 536 | + |
| 537 | + preview = "\n".join(lines[:line_limit]) |
| 538 | + if len(preview) > char_limit: |
| 539 | + preview = preview[:char_limit] |
| 540 | + return preview, True |
| 541 | + |
| 542 | + |
| 543 | +def _render_bash_call(tool_args: dict | None, *, phase: str | None = None): |
| 544 | + command = str((tool_args or {}).get("command", "")).strip() or "(empty command)" |
| 545 | + title = _tool_label("bash", phase) |
| 546 | + border_style = "magenta" if phase == "verification" else "cyan" |
| 547 | + return Group( |
| 548 | + Text(title, style=f"bold {border_style}"), |
| 549 | + Panel( |
| 550 | + Text(command), |
| 551 | + title="Command", |
| 552 | + border_style=border_style, |
| 553 | + box=box.SQUARE, |
| 554 | + expand=True, |
| 555 | + ), |
| 556 | + ) |
| 557 | + |
| 558 | + |
| 559 | +def _render_bash_result( |
| 560 | + content: str, |
| 561 | + *, |
| 562 | + metadata: dict[str, Any] | None, |
| 563 | + is_error: bool, |
| 564 | + phase: str | None = None, |
| 565 | +) -> Panel: |
| 566 | + metadata = metadata or {} |
| 567 | + title = _tool_label("bash", phase) |
| 568 | + lines = [] |
| 569 | + status_value = str(metadata.get("status", "failed" if is_error else "completed")) |
| 570 | + lines.append(f"Status: {status_value.replace('_', ' ')}") |
| 571 | + if metadata.get("job_id"): |
| 572 | + lines.append(f"Job: {metadata['job_id']}") |
| 573 | + if metadata.get("pid"): |
| 574 | + lines.append(f"PID: {metadata['pid']}") |
| 575 | + if metadata.get("exit_code") is not None: |
| 576 | + lines.append(f"Exit: {metadata['exit_code']}") |
| 577 | + if metadata.get("background") is not None: |
| 578 | + lines.append( |
| 579 | + f"Mode: {'background' if metadata.get('background') else 'foreground'}" |
| 580 | + ) |
| 581 | + |
| 582 | + stdout_text = str(metadata.get("stdout", "") or "") |
| 583 | + stderr_text = str(metadata.get("stderr", "") or "") |
| 584 | + show_summary_note = ( |
| 585 | + (not stdout_text and not stderr_text and bool(content.strip())) |
| 586 | + or status_value not in {"completed", "running"} |
| 587 | + ) |
| 588 | + body = "\n".join(lines) |
| 589 | + if show_summary_note and content.strip(): |
| 590 | + preview, truncated = _truncate_tool_text(content, line_limit=20) |
| 591 | + body = f"{body}\n\n{preview}" if body else preview |
| 592 | + if truncated: |
| 593 | + body += "\n… truncated for display; full result preserved in session" |
| 594 | + |
| 595 | + border_style = "red" if is_error else ("magenta" if phase == "verification" else "green") |
| 596 | + return Panel( |
| 597 | + body or "(no output)", |
| 598 | + title=f"[bold {border_style}]{title}[/bold {border_style}]", |
| 599 | + border_style=border_style, |
| 600 | + box=box.SQUARE, |
| 601 | + expand=True, |
| 602 | + ) |
| 603 | + |
| 604 | + |
| 605 | +def _print_tool_call(tool_name: str, tool_args: dict | None, phase: str | None = None) -> None: |
| 606 | + if tool_name == "bash": |
| 607 | + console.print(_render_bash_call(tool_args, phase=phase)) |
| 608 | + return |
| 609 | + |
| 610 | + args_str = _format_tool_args(tool_args) |
| 611 | + console.print(f"[cyan]> {_tool_label(tool_name, phase)}[/cyan]({args_str})") |
| 612 | + |
| 613 | + |
| 614 | +def _print_tool_result( |
| 615 | + tool_name: str, |
| 616 | + content: str, |
| 617 | + *, |
| 618 | + metadata: dict[str, Any] | None = None, |
| 619 | + is_error: bool = False, |
| 620 | + phase: str | None = None, |
| 621 | + preview_lines: int = 10, |
| 622 | +) -> None: |
| 623 | + if tool_name == "bash": |
| 624 | + console.print( |
| 625 | + _render_bash_result( |
| 626 | + content, |
| 627 | + metadata=metadata, |
| 628 | + is_error=is_error, |
| 629 | + phase=phase, |
| 630 | + ) |
| 631 | + ) |
| 632 | + return |
| 633 | + |
| 634 | + preview, truncated = _truncate_tool_text(content, line_limit=preview_lines) |
| 635 | + if truncated: |
| 636 | + preview += "\n[dim]... truncated for display; full result preserved in session[/dim]" |
| 637 | + border_style = "red" if is_error else ("magenta" if phase == "verification" else "dim") |
| 638 | + console.print(Panel(preview or "(no output)", border_style=border_style)) |
| 639 | + |
| 640 | + |
| 641 | +def _parse_local_bash_command(user_input: str) -> tuple[str, dict[str, object]] | None: |
| 642 | + parts = user_input.strip().split() |
| 643 | + if not parts: |
| 644 | + return None |
| 645 | + |
| 646 | + command = parts[0].lstrip("/").lower() |
| 647 | + if command == "jobs": |
| 648 | + if len(parts) > 2: |
| 649 | + raise ValueError("Usage: jobs [limit]") |
| 650 | + tool_args: dict[str, object] = {} |
| 651 | + if len(parts) == 2: |
| 652 | + tool_args["limit"] = max(1, int(parts[1])) |
| 653 | + return "bash_jobs", tool_args |
| 654 | + |
| 655 | + if command == "wait": |
| 656 | + if len(parts) not in {2, 3}: |
| 657 | + raise ValueError("Usage: wait <job-id> [timeout-seconds]") |
| 658 | + tool_args = {"job_id": parts[1]} |
| 659 | + if len(parts) == 3: |
| 660 | + tool_args["timeout"] = float(parts[2]) |
| 661 | + return "bash_wait", tool_args |
| 662 | + |
| 663 | + if command == "kill": |
| 664 | + if len(parts) not in {2, 3}: |
| 665 | + raise ValueError("Usage: kill <job-id> [force-after-ms]") |
| 666 | + tool_args = {"job_id": parts[1]} |
| 667 | + if len(parts) == 3: |
| 668 | + tool_args["force_after_ms"] = int(parts[2]) |
| 669 | + return "bash_kill", tool_args |
| 670 | + |
| 671 | + return None |
| 672 | + |
| 673 | + |
| 674 | +async def _run_local_bash_command( |
| 675 | + shell_owner: RuntimeShellOwner, |
| 676 | + tool_name: str, |
| 677 | + tool_args: dict[str, object], |
| 678 | +) -> None: |
| 679 | + _print_tool_call(tool_name, tool_args, phase="local") |
| 680 | + result = await shell_owner.registry.execute(tool_name, **tool_args) |
| 681 | + _print_tool_result( |
| 682 | + tool_name, |
| 683 | + result.output, |
| 684 | + metadata=result.metadata, |
| 685 | + is_error=result.is_error, |
| 686 | + phase="local", |
| 687 | + preview_lines=8, |
| 688 | + ) |
| 689 | + |
| 690 | + |
| 691 | +def _get_bash_tool(shell_owner: RuntimeShellOwner): |
| 692 | + from ..tools.shell_tools import BashTool |
| 693 | + |
| 694 | + tool = shell_owner.registry.get("bash") |
| 695 | + return tool if isinstance(tool, BashTool) else None |
| 696 | + |
| 697 | + |
| 698 | +async def _interrupt_active_foreground_bash(shell_owner: RuntimeShellOwner) -> bool: |
| 699 | + bash_tool = _get_bash_tool(shell_owner) |
| 700 | + if bash_tool is None: |
| 701 | + return False |
| 702 | + |
| 703 | + result = await bash_tool.manager.interrupt_active_foreground() |
| 704 | + if result is None: |
| 705 | + return False |
| 706 | + |
| 707 | + _print_tool_result( |
| 708 | + "bash", |
| 709 | + result.output, |
| 710 | + metadata=result.metadata, |
| 711 | + is_error=result.is_error, |
| 712 | + phase="local", |
| 713 | + preview_lines=8, |
| 714 | + ) |
| 715 | + return True |
| 716 | + |
| 717 | + |
| 506 | 718 | async def run_once( |
| 507 | 719 | shell_owner: RuntimeShellOwner, |
| 508 | 720 | prompt: str, |
@@ -552,21 +764,20 @@ async def run_once( |
| 552 | 764 | elapsed = time.time() - thinking_start |
| 553 | 765 | console.print(f" [dim]({elapsed:.1f}s)[/dim]") |
| 554 | 766 | thinking_start = None |
| 555 | | - args_str = _format_tool_args(event.tool_args) |
| 556 | | - tool_label = ( |
| 557 | | - f"verify {event.tool_name}" |
| 558 | | - if event.phase == "verification" |
| 559 | | - else event.tool_name |
| 767 | + _print_tool_call( |
| 768 | + getattr(event, "tool_name", "") or "", |
| 769 | + getattr(event, "tool_args", None), |
| 770 | + getattr(event, "phase", None), |
| 560 | 771 | ) |
| 561 | | - console.print(f"[cyan]> {tool_label}[/cyan]({args_str})") |
| 562 | 772 | elif event.type == "tool_result": |
| 563 | | - # Show result in a compact panel |
| 564 | | - lines = event.content.splitlines() |
| 565 | | - preview = "\n".join(lines[:10]) |
| 566 | | - if len(lines) > 10: |
| 567 | | - preview += f"\n[dim]... ({len(lines) - 10} more lines)[/dim]" |
| 568 | | - border_style = "magenta" if event.phase == "verification" else "dim" |
| 569 | | - console.print(Panel(preview, border_style=border_style)) |
| 773 | + _print_tool_result( |
| 774 | + getattr(event, "tool_name", "") or "", |
| 775 | + event.content, |
| 776 | + metadata=getattr(event, "tool_metadata", None), |
| 777 | + is_error=getattr(event, "is_error", False), |
| 778 | + phase=getattr(event, "phase", None), |
| 779 | + preview_lines=10, |
| 780 | + ) |
| 570 | 781 | elif event.type == "dod_status": |
| 571 | 782 | console.print(f"[dim]{format_dod_status(event)}[/dim]") |
| 572 | 783 | elif event.type == "recovery": |
@@ -588,6 +799,11 @@ async def run_once( |
| 588 | 799 | console.print("\n[red]Request timed out.[/red]") |
| 589 | 800 | console.print("[dim]The model is taking too long. Try a smaller model or simpler prompt.[/dim]") |
| 590 | 801 | return |
| 802 | + except KeyboardInterrupt: |
| 803 | + console.print() |
| 804 | + if not await _interrupt_active_foreground_bash(shell_owner): |
| 805 | + console.print("[yellow]Cancelled.[/yellow]") |
| 806 | + return |
| 591 | 807 | except ConfirmationRequired as e: |
| 592 | 808 | console.print(f"\n[yellow]Confirmation required:[/yellow] {e.message}") |
| 593 | 809 | if e.details: |
@@ -595,14 +811,20 @@ async def run_once( |
| 595 | 811 | if Confirm.ask("Proceed?"): |
| 596 | 812 | shell_owner.registry.skip_confirmation = True |
| 597 | 813 | streamed_response = False # Reset for continuation |
| 598 | | - response = await shell_owner.run( |
| 599 | | - "Continue with the previous action.", |
| 600 | | - on_event=on_event, |
| 601 | | - on_user_question=_ask_user_question_cli, |
| 602 | | - ) |
| 603 | | - if not streamed_response: |
| 604 | | - console.print(Markdown(clean_response(response))) |
| 605 | | - shell_owner.registry.skip_confirmation = skip_confirmation |
| 814 | + try: |
| 815 | + response = await shell_owner.run( |
| 816 | + "Continue with the previous action.", |
| 817 | + on_event=on_event, |
| 818 | + on_user_question=_ask_user_question_cli, |
| 819 | + ) |
| 820 | + if not streamed_response: |
| 821 | + console.print(Markdown(clean_response(response))) |
| 822 | + except KeyboardInterrupt: |
| 823 | + console.print() |
| 824 | + if not await _interrupt_active_foreground_bash(shell_owner): |
| 825 | + console.print("[yellow]Cancelled.[/yellow]") |
| 826 | + finally: |
| 827 | + shell_owner.registry.skip_confirmation = skip_confirmation |
| 606 | 828 | else: |
| 607 | 829 | console.print("[red]Aborted.[/red]") |
| 608 | 830 | |
@@ -622,7 +844,10 @@ async def run_interactive( |
| 622 | 844 | history_file = os.path.expanduser("~/.loader_history") |
| 623 | 845 | session = PromptSession(history=FileHistory(history_file)) |
| 624 | 846 | |
| 625 | | - console.print("[dim]Type 'exit' to quit, 'clear' to reset conversation[/dim]\n") |
| 847 | + console.print( |
| 848 | + "[dim]Type 'exit' to quit, 'clear' to reset conversation, " |
| 849 | + "'jobs' to inspect bash jobs[/dim]\n" |
| 850 | + ) |
| 626 | 851 | |
| 627 | 852 | while True: |
| 628 | 853 | try: |
@@ -647,6 +872,18 @@ async def run_interactive( |
| 647 | 872 | console.print("[dim]Conversation cleared[/dim]") |
| 648 | 873 | continue |
| 649 | 874 | |
| 875 | + try: |
| 876 | + local_bash = _parse_local_bash_command(user_input) |
| 877 | + except ValueError as exc: |
| 878 | + console.print(f"[red]{exc}[/red]\n") |
| 879 | + continue |
| 880 | + |
| 881 | + if local_bash is not None: |
| 882 | + tool_name, tool_args = local_bash |
| 883 | + await _run_local_bash_command(shell_owner, tool_name, tool_args) |
| 884 | + console.print() |
| 885 | + continue |
| 886 | + |
| 650 | 887 | import time |
| 651 | 888 | thinking_start = None |
| 652 | 889 | streaming_started = False |
@@ -699,22 +936,20 @@ async def run_interactive( |
| 699 | 936 | if streaming_started: |
| 700 | 937 | console.print() # New line after any streamed content |
| 701 | 938 | streaming_started = False |
| 702 | | - args_str = _format_tool_args(event.tool_args) |
| 703 | | - tool_label = ( |
| 704 | | - f"verify {event.tool_name}" |
| 705 | | - if event.phase == "verification" |
| 706 | | - else event.tool_name |
| 939 | + _print_tool_call( |
| 940 | + getattr(event, "tool_name", "") or "", |
| 941 | + getattr(event, "tool_args", None), |
| 942 | + getattr(event, "phase", None), |
| 707 | 943 | ) |
| 708 | | - console.print(f"[cyan]> {tool_label}[/cyan]({args_str})") |
| 709 | 944 | elif event.type == "tool_result": |
| 710 | | - # Show compact result |
| 711 | | - lines = event.content.splitlines() |
| 712 | | - if len(lines) <= 3: |
| 713 | | - preview = event.content |
| 714 | | - else: |
| 715 | | - preview = "\n".join(lines[:3]) + f"\n[dim]... ({len(lines) - 3} more lines)[/dim]" |
| 716 | | - style = "magenta" if event.phase == "verification" else "dim" |
| 717 | | - console.print(f"[{style}]{preview}[/{style}]") |
| 945 | + _print_tool_result( |
| 946 | + getattr(event, "tool_name", "") or "", |
| 947 | + event.content, |
| 948 | + metadata=getattr(event, "tool_metadata", None), |
| 949 | + is_error=getattr(event, "is_error", False), |
| 950 | + phase=getattr(event, "phase", None), |
| 951 | + preview_lines=3, |
| 952 | + ) |
| 718 | 953 | elif event.type == "dod_status": |
| 719 | 954 | console.print(f"\n[dim]{format_dod_status(event)}[/dim]") |
| 720 | 955 | elif event.type == "recovery": |
@@ -740,6 +975,12 @@ async def run_interactive( |
| 740 | 975 | console.print(" • A simpler prompt") |
| 741 | 976 | console.print(" • Check if Ollama is overloaded") |
| 742 | 977 | continue |
| 978 | + except KeyboardInterrupt: |
| 979 | + console.print() |
| 980 | + if not await _interrupt_active_foreground_bash(shell_owner): |
| 981 | + console.print("[yellow]Cancelled.[/yellow]") |
| 982 | + console.print() |
| 983 | + continue |
| 743 | 984 | except ConfirmationRequired as e: |
| 744 | 985 | console.print(f"\n[yellow]Confirmation required:[/yellow] {e.message}") |
| 745 | 986 | if e.details: |
@@ -757,6 +998,11 @@ async def run_interactive( |
| 757 | 998 | if not streamed_response: |
| 758 | 999 | console.print(Markdown(clean_response(response))) |
| 759 | 1000 | console.print() |
| 1001 | + except KeyboardInterrupt: |
| 1002 | + console.print() |
| 1003 | + if not await _interrupt_active_foreground_bash(shell_owner): |
| 1004 | + console.print("[yellow]Cancelled.[/yellow]") |
| 1005 | + console.print() |
| 760 | 1006 | finally: |
| 761 | 1007 | shell_owner.registry.skip_confirmation = skip_confirmation |
| 762 | 1008 | else: |
@@ -1312,14 +1558,20 @@ async def _explore_main( |
| 1312 | 1558 | |
| 1313 | 1559 | def on_event(event) -> None: |
| 1314 | 1560 | if event.type == "tool_call": |
| 1315 | | - args_str = _format_tool_args(event.tool_args) |
| 1316 | | - console.print(f"[cyan]> {event.tool_name}[/cyan]({args_str})") |
| 1561 | + _print_tool_call( |
| 1562 | + getattr(event, "tool_name", "") or "", |
| 1563 | + getattr(event, "tool_args", None), |
| 1564 | + getattr(event, "phase", None), |
| 1565 | + ) |
| 1317 | 1566 | elif event.type == "tool_result": |
| 1318 | | - lines = event.content.splitlines() |
| 1319 | | - preview = "\n".join(lines[:8]) |
| 1320 | | - if len(lines) > 8: |
| 1321 | | - preview += f"\n[dim]... ({len(lines) - 8} more lines)[/dim]" |
| 1322 | | - console.print(Panel(preview, border_style="dim")) |
| 1567 | + _print_tool_result( |
| 1568 | + getattr(event, "tool_name", "") or "", |
| 1569 | + event.content, |
| 1570 | + metadata=getattr(event, "tool_metadata", None), |
| 1571 | + is_error=getattr(event, "is_error", False), |
| 1572 | + phase=getattr(event, "phase", None), |
| 1573 | + preview_lines=8, |
| 1574 | + ) |
| 1323 | 1575 | |
| 1324 | 1576 | response = await shell_owner.run_explore(prompt, on_event=on_event, fresh=fresh) |
| 1325 | 1577 | console.print(Markdown(clean_response(response))) |