diff --git a/.gitignore b/.gitignore index ccb7ac0..fc8a201 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ htmlcov/ # IDE .vscode/ + +# Logs and session files +logs/ diff --git a/src/tai/cli.py b/src/tai/cli.py index 09e121d..5ac933d 100644 --- a/src/tai/cli.py +++ b/src/tai/cli.py @@ -8,6 +8,9 @@ from typing import Annotated import typer from rich.console import Console from rich.markdown import Markdown +from rich.panel import Panel +from rich.rule import Rule +from rich.text import Text from tai.ai_client import DEFAULT_AI_HOST, DEFAULT_MODEL, AIClient, AIConfig from tai.ai_guardrails import validate_ai_response @@ -119,11 +122,12 @@ def run( ) summary = SSHClient(config).summary() - console.print("[bold green]tai[/bold green]") - console.print(f"Issue: {req.issue}") - console.print(f"SSH: {summary}") + console.print(Rule("[bold green]tai[/bold green]", style="green")) + console.print(f" [bold]Issue:[/bold] {req.issue}") + console.print(f" [bold]SSH:[/bold] {summary}") if req.target_paths: - console.print(f"Paths: {', '.join(str(p) for p in req.target_paths)}") + console.print(f" [bold]Paths:[/bold] {', '.join(str(p) for p in req.target_paths)}") + console.print() if not (probe or collect or analyze or interactive): return # nothing SSH-related requested @@ -227,15 +231,20 @@ async def _interactive_loop( ) -> None: """Run a follow-up loop for collecting and conversational analysis.""" console.print( - "[cyan]Interactive mode:[/cyan] " - "ask questions directly, or use /collect, /analyze, /help, /quit" + Panel( + "Ask questions directly, or use [bold]/collect[/bold], " + "[bold]/analyze[/bold], [bold]/help[/bold], [bold]/quit[/bold]", + title="[bold cyan]Interactive Mode[/bold cyan]", + border_style="cyan", + padding=(0, 1), + ) ) prior_questions: list[str] = [] while True: try: - command = input("tai> ").strip() + command = console.input("\n[bold cyan]tai[/bold cyan][dim] >[/dim] ").strip() except (EOFError, KeyboardInterrupt): console.print("\n[yellow]Exiting interactive mode.[/yellow]") if logger is not None: @@ -252,8 +261,18 @@ async def _interactive_loop( return if command == "/help": - console.print("Commands: /collect, /analyze, /help, /quit") - console.print("Tip: any non-slash text is treated as a follow-up AI question.") + console.print( + Panel( + "[bold]/collect[/bold] — re-run diagnostics\n" + "[bold]/analyze[/bold] — re-analyze current diagnostics\n" + "[bold]/help[/bold] — show this message\n" + "[bold]/quit[/bold] — end session\n" + "[dim]Anything else is sent directly to the AI as a question.[/dim]", + title="[bold]Commands[/bold]", + border_style="dim", + padding=(0, 1), + ) + ) continue if command == "/collect": @@ -319,26 +338,32 @@ async def _interactive_loop( def _handle_probe_result(result: SSHCommandResult) -> None: """Handle and render probe output for success or failure.""" - console.print("[cyan]Running SSH probe:[/cyan] uname -a") + console.print("[dim]▶ SSH probe:[/dim] uname -a") if result.exit_code != 0: details = result.stderr or result.stdout or "no error output from ssh" - console.print(f"[red]Probe failed (exit {result.exit_code}):[/red] {details}") + console.print(f"[bold red]✗ Probe failed[/bold red] (exit {result.exit_code}): {details}") raise typer.Exit(code=1) output = result.stdout or "(no output)" - console.print("[bold green]Probe succeeded.[/bold green]") - console.print(f"Remote: {output}") + console.print("[bold green]✓ Probe succeeded.[/bold green]") + console.print(f" [dim]{output}[/dim]") def _handle_collection_report(report: CollectionReport) -> None: """Render collected command status and truncation hints.""" - console.print( - f"[bold]Collection complete:[/bold] {report.total} commands, {report.failed} failed" + failed_label = ( + f"[red]{report.failed} failed[/red]" if report.failed else "[green]0 failed[/green]" ) + console.print(f"[bold]Collection complete:[/bold] {report.total} commands, {failed_label}") for item in report.items: - status = "ok" if item.result.exit_code == 0 else f"exit {item.result.exit_code}" truncated = item.result.stdout_truncated or item.result.stderr_truncated - trunc = " (truncated)" if truncated else "" - console.print(f"- {item.name}: {status}{trunc}") + trunc_label = " [dim](truncated)[/dim]" if truncated else "" + if item.result.exit_code == 0: + console.print(f" [green]✓[/green] [dim]{item.name}[/dim]{trunc_label}") + else: + console.print( + f" [red]✗[/red] {item.name} " + f"[red](exit {item.result.exit_code})[/red]{trunc_label}" + ) def _run_analysis( @@ -349,7 +374,9 @@ def _run_analysis( logger: SessionLogger | None, ) -> None: """Send collected data to the AI and stream the analysis to stdout.""" - console.print("[cyan]Analyzing...[/cyan]\n") + console.print() + console.print(Rule("[bold cyan]Analysis[/bold cyan]", style="cyan")) + console.print() ai = AIClient(ai_config) system_prompt = build_system_prompt() user_message = build_user_message(issue, report) @@ -362,7 +389,10 @@ def _run_analysis( warnings = validate_ai_response(response) for item in warnings: - console.print(f"[yellow]Guardrail warning:[/yellow] {item}") + warn_text = Text() + warn_text.append("⚠ Guardrail: ", style="bold yellow") + warn_text.append(item, style="yellow") + console.print(warn_text) if logger is not None: logger.log_event( @@ -390,7 +420,9 @@ def _run_followup_analysis( logger: SessionLogger | None, ) -> str: """Run grounded follow-up analysis re-anchored to current diagnostics.""" - console.print("[cyan]Analyzing...[/cyan]\n") + console.print() + console.print(Rule("[bold cyan]AI Response[/bold cyan]", style="cyan")) + console.print() ai = AIClient(ai_config) system_prompt = build_system_prompt() user_message = build_followup_message(issue, report, question, prior_questions) @@ -401,10 +433,14 @@ def _run_followup_analysis( chunks.append(chunk) response = "".join(chunks) console.print(Markdown(response)) + console.print(Rule(style="dim")) warnings = validate_ai_response(response) for item in warnings: - console.print(f"[yellow]Guardrail warning:[/yellow] {item}") + warn_text = Text() + warn_text.append("⚠ Guardrail: ", style="bold yellow") + warn_text.append(item, style="yellow") + console.print(warn_text) if logger is not None: logger.log_event( diff --git a/tests/test_cli.py b/tests/test_cli.py index 9046ede..b13fa58 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -137,8 +137,9 @@ def test_collect_success_prints_summary(monkeypatch) -> None: # type: ignore[no assert result.exit_code == 0 assert "Collection complete" in result.stdout - assert "kernel: ok" in result.stdout - assert "journal: ok (truncated)" in result.stdout + assert "kernel" in result.stdout + assert "journal" in result.stdout + assert "truncated" in result.stdout def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-untyped-def] @@ -163,7 +164,7 @@ def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no- commands = iter(["/collect", "/quit"]) monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan) - monkeypatch.setattr("builtins.input", lambda _prompt: next(commands)) + monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands)) runner = CliRunner() result = runner.invoke( @@ -180,7 +181,7 @@ def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no- ) assert result.exit_code == 0 - assert "Interactive mode" in result.stdout + assert "ask questions directly" in result.stdout.lower() assert "Collection complete" in result.stdout assert "Bye." in result.stdout @@ -210,7 +211,7 @@ def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type: "tai.cli.AIClient.stream", lambda *_args, **_kwargs: iter(["Check logs."]), ) - monkeypatch.setattr("builtins.input", lambda _prompt: next(commands)) + monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands)) runner = CliRunner() result = runner.invoke( @@ -227,5 +228,5 @@ def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type: ) assert result.exit_code == 0 - assert "Analyzing..." in result.stdout + assert "AI Response" in result.stdout assert "Check logs." in result.stdout