1 Commits

Author SHA1 Message Date
2c738579bd feat(ux): improve interactive mode readability and input visibility
All checks were successful
CI / test (push) Successful in 19s
- Replace plain 'tai>' prompt with styled console.input() bold cyan prompt
- Wrap interactive mode entry in a Rich Panel with border
- Frame each AI response with Rule dividers (──── AI Response ────)
- Style guardrail warnings with ⚠ prefix and bold yellow
- Improve /help output with formatted Panel showing all commands
- Style collection report: ✓/✗ per item with color, truncation in dim
- Style probe output: ✓/✗ with green/red, host info in dim
- Add Rule header divider on session start
2026-05-04 06:37:50 +02:00
3 changed files with 68 additions and 28 deletions

3
.gitignore vendored
View File

@@ -24,3 +24,6 @@ htmlcov/
# IDE
.vscode/
# Logs and session files
logs/

View File

@@ -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(

View File

@@ -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