# Plan: LLM call spinner

## Problem

During `call_llm()`, the user sees nothing on stderr — not even in verbose mode.
The turn header prints *before* the call, and `llm_timing` prints *after*.
The gap can be several seconds (or much longer for big contexts), with zero feedback.

## Approach

Use Rich's `Status` spinner on stderr. It shows an animated spinner while the
blocking `litellm.completion()` call is in flight, then clears itself when
the response arrives.

## Design

### New `fmt` function

Add one function to `fmt.py`:

```python
def llm_spinner(label: str = "Waiting for LLM"):
    """Return a Rich Status context manager that spins on stderr."""
    return _console.status(label, spinner="dots")
```

No return type annotation — callers only use it as a context manager, and
importing `rich.status.Status` just for the annotation isn't worth it.

`Status` is already a context manager (`__enter__`/`__exit__`).
Rich clears the spinner line automatically on exit, so the subsequent
`llm_timing` output appears cleanly.

Since `llm_spinner()` reads `_console` at call time (not import time), it
always uses the console configured by `fmt.init()`. And `fmt.init()` is
called once at startup before the loop, so there's no race.

### Integration in `agent.py`

Wrap each `call_llm()` call site in `run_agent_loop()` with the spinner.
There are three call sites (lines 1368, 1404, 1448 — initial + two overflow retries).

The `with` must go **inside** each `try` block, not around it — otherwise
the spinner would stay open during error-handling and compaction logic:

```python
t0 = time.monotonic()
try:
    with fmt.llm_spinner(f"Waiting for LLM (turn {turns}/{max_turns})") if verbose else nullcontext():
        msg, finish_reason = call_llm(*_llm_args, **llm_kwargs)
except ContextOverflowError:
    ...
```

`nullcontext` (from `contextlib`, new import) keeps it a no-op when quiet.

### Elapsed time in the spinner

Rich `Status` doesn't show elapsed time by default. Two options:

**Option A (simple):** Just show the spinner + static label. The elapsed time
already prints afterward via `fmt.llm_timing()`. Minimal change.

**Option B (live elapsed):** Run `litellm.completion()` in a thread and update
the status label every second with the running time. More code, more complexity,
threading concerns.

Going with **Option A**. The spinner solves the "is it stuck?" problem.
The exact elapsed time prints right after. No threading needed.

### Quiet mode

The spinner is gated behind `if verbose` (same as all other stderr output),
so `-q`/`--quiet` continues to produce zero output.

### REPL mode

In `--repl`, `prompt_toolkit` handles terminal input. The spinner only runs
during `call_llm()`, which fires *after* the user submits input and *before*
the next prompt. No overlap with prompt_toolkit's input handling — safe.

## Files changed

| File | Change |
|------|--------|
| `swival/fmt.py` | Add `llm_spinner()` function (~3 lines) |
| `swival/agent.py` | Wrap the three `call_llm()` sites with the spinner context manager, add `from contextlib import nullcontext` |

## Testing

- Existing tests mock `call_llm` and never see the spinner (it only activates when `verbose=True`, and test runs don't use real stderr rendering).
- Manual test: `uv run swival "hello" --base-dir /tmp/test` and observe spinner on stderr.
- No new test file needed — this is a pure UI/stderr concern.

## Risks

- **None for correctness.** The spinner is cosmetic stderr output; it doesn't touch messages, tools, or the LLM call itself.
- **Rich version.** `Status` has been in Rich since 10.x; the project already depends on Rich, so no new dependency.
- **Piping.** Rich auto-detects non-TTY stderr and disables animation, so `2>/dev/null` or piped stderr won't produce garbage.
